fix: using power meter as cadence sensor

workout-saving
lambda 4 days ago
parent 0c1b9e70d7
commit 39756c9ed4
No known key found for this signature in database
GPG Key ID: CF45A8EE158BBCB6

@ -14,7 +14,7 @@ use uuid::Uuid;
use crate::ble::BleError; use crate::ble::BleError;
use crate::commands::{encode_command, parse_control_result, Command, ControlPoint, ControlResult}; use crate::commands::{encode_command, parse_control_result, Command, ControlPoint, ControlResult};
use crate::device::Device; 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] #[async_trait]
pub(crate) trait SensorSource: Send + Sync { pub(crate) trait SensorSource: Send + Sync {
@ -231,9 +231,11 @@ impl SensorSource for BleSource {
let mut notifications = notifications; let mut notifications = notifications;
let mut acc = CscAccumulator::new(); let mut acc = CscAccumulator::new();
let mut cancel_rx = cancel_rx; let mut cancel_rx = cancel_rx;
// Первые 30 уведомлений логируем с сырыми байтами — чтобы видеть, что // Логируем сырые байты для диагностики. 0x2AD2 (Indoor Bike Data — там
// реально шлёт устройство (какие флаги/поля в 0x2AD2 и т.п.). // каденс/скорость/мощность) логируем дольше (до 500), остальные — до 30,
let mut logged = 0u32; // чтобы «пустые» уведомления до начала вращения не съели лимит.
let mut bike_logged = 0u32;
let mut other_logged = 0u32;
loop { loop {
tokio::select! { tokio::select! {
n = notifications.next() => { n = notifications.next() => {
@ -242,8 +244,11 @@ impl SensorSource for BleSource {
let uuid = n.uuid; let uuid = n.uuid;
let raw = hex::encode(&n.value); let raw = hex::encode(&n.value);
let parsed = parse_notification(n, &mut acc, &control); let parsed = parse_notification(n, &mut acc, &control);
if logged < 30 { let is_bike = uuid.to_string().starts_with("00002ad2");
logged += 1; 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:?}")); crate::logging::log("telemetry", &format!("uuid={uuid} raw={raw} parsed={parsed:?}"));
} }
for t in parsed { for t in parsed {
@ -371,7 +376,14 @@ fn parse_notification(
.into_iter() .into_iter()
.collect() .collect()
} else if n.uuid == POWER_MEASUREMENT { } else if n.uuid == POWER_MEASUREMENT {
parse_power(&n.value).into_iter().collect() // мощность + каденс из crank revolution data (пауэрметр Quarq и т.п.)
let mut out: Vec<Telemetry> = 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 { } else if n.uuid == INDOOR_BIKE_DATA {
parse_indoor_bike_data(&n.value) parse_indoor_bike_data(&n.value)
} else if n.uuid == CONTROL_POINT { } else if n.uuid == CONTROL_POINT {

@ -23,15 +23,17 @@ pub enum DeviceType {
HrSensor, HrSensor,
CadSensor, CadSensor,
Trainer, Trainer,
PowerMeter,
} }
impl DeviceType { impl DeviceType {
/// Категория на фронте ("hr"/"cadence"/"trainer") для маршрутизации события. /// Категория на фронте ("hr"/"cadence"/"trainer"/"power") для маршрутизации события.
pub fn category(&self) -> &'static str { pub fn category(&self) -> &'static str {
match self { match self {
DeviceType::HrSensor => "hr", DeviceType::HrSensor => "hr",
DeviceType::CadSensor => "cadence", DeviceType::CadSensor => "cadence",
DeviceType::Trainer => "trainer", DeviceType::Trainer => "trainer",
DeviceType::PowerMeter => "power",
} }
} }
} }
@ -43,9 +45,13 @@ pub fn get_device_types(device: &Device) -> Vec<DeviceType> {
let csc_services = ["1816"]; let csc_services = ["1816"];
let csc_characteristics = ["2a5b"]; let csc_characteristics = ["2a5b"];
// trainer / cycling power / fitness machine / indoor bike // smart trainer / FTMS (indoor bike, fitness machine)
let trainer_services = ["1818", "1826"]; let trainer_services = ["1826"];
let trainer_characteristics = ["2a63", "2ad2", "2acc"]; // 2AD2 = Indoor Bike Data, 2ACC = Fitness Machine Characteristics, 2A63 = Cycling Power Measurement 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) - учитывать как признак каденса доступного в тренажёре // любые 2ADx характеристики (indoor bike related) - учитывать как признак каденса доступного в тренажёре
let indoor_bike_chars_prefix = "2ad"; // covers 2ad2,2ad5,2ad6,2ad8,2ad9,2ada etc. 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<DeviceType> {
out.push(DeviceType::CadSensor); out.push(DeviceType::CadSensor);
} }
// Trainer/Cycling power/Indoor bike // Trainer (FTMS). Приоритет: станок рекламирует и 0x1826, и 0x1818 — он всё равно станок.
let is_trainer = contains_any(&trainer_services) || contains_any(&trainer_characteristics); let is_trainer = contains_any(&trainer_services) || contains_any(&trainer_characteristics);
if is_trainer { if is_trainer {
out.push(DeviceType::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 - // Доп. логика: если найдены Indoor Bike Data (любые 2ADx characteristics), считаем, что устройство может отдавать cadence -
@ -174,13 +183,13 @@ mod tests {
} }
#[test] #[test]
fn test_trainer_by_power_service() { fn test_power_meter_by_service() {
assert_eq!(get_device_types(&dev(&["1818"], &[])), [DeviceType::Trainer]); assert_eq!(get_device_types(&dev(&["1818"], &[])), [DeviceType::PowerMeter]);
} }
#[test] #[test]
fn test_trainer_by_power_characteristic() { fn test_power_meter_by_characteristic() {
assert_eq!(get_device_types(&dev(&[], &["2a63"])), [DeviceType::Trainer]); assert_eq!(get_device_types(&dev(&[], &["2a63"])), [DeviceType::PowerMeter]);
} }
#[test] #[test]
@ -239,5 +248,6 @@ mod tests {
assert_eq!(DeviceType::HrSensor.category(), "hr"); assert_eq!(DeviceType::HrSensor.category(), "hr");
assert_eq!(DeviceType::CadSensor.category(), "cadence"); assert_eq!(DeviceType::CadSensor.category(), "cadence");
assert_eq!(DeviceType::Trainer.category(), "trainer"); assert_eq!(DeviceType::Trainer.category(), "trainer");
assert_eq!(DeviceType::PowerMeter.category(), "power");
} }
} }

@ -9,6 +9,8 @@ pub struct Repo {
cad_sensors: Vec<Device>, cad_sensors: Vec<Device>,
#[serde(default)] #[serde(default)]
trainers: Vec<Device>, trainers: Vec<Device>,
#[serde(default)]
power_meters: Vec<Device>,
} }
impl Repo { impl Repo {
@ -17,6 +19,7 @@ impl Repo {
hr_sensors: Vec::new(), hr_sensors: Vec::new(),
cad_sensors: Vec::new(), cad_sensors: Vec::new(),
trainers: Vec::new(), trainers: Vec::new(),
power_meters: Vec::new(),
} }
} }
} }

@ -274,6 +274,13 @@ fn get_workout(path: String) -> Result<workout::Workout, String> {
workout::read_workout(&path).map_err(|e| e.to_string()) workout::read_workout(&path).map_err(|e| e.to_string())
} }
/// Развёрнутые линейные сегменты воркаута (для отрисовки профиля).
#[tauri::command]
fn get_segments(path: String) -> Result<Vec<workout::Segment>, String> {
let w = workout::read_workout(&path).map_err(|e| e.to_string())?;
Ok(workout::flatten(&w.steps))
}
/// Задать FTP гонщика (Вт). Используется для перевода %FTP → ватты. /// Задать FTP гонщика (Вт). Используется для перевода %FTP → ватты.
#[tauri::command] #[tauri::command]
fn set_ftp(app: AppHandle, ftp: u16) -> Result<(), String> { fn set_ftp(app: AppHandle, ftp: u16) -> Result<(), String> {
@ -344,10 +351,9 @@ async fn run_workout_engine(
tick_duration: Duration, tick_duration: Duration,
mut on_progress: impl FnMut(WorkoutProgressEvent) + Send, mut on_progress: impl FnMut(WorkoutProgressEvent) + Send,
) -> bool { ) -> bool {
if let Err(e) = source.request_control(device).await { // Контроль станка уже запрошен при подключении (run_watchdog). Повторный
crate::logging::log("workout", &format!("request_control failed: {e}")); // request_control здесь срывает тренировку на Magene (отказ/таймаут при уже
return false; // выданном контроле) — воркаут мгновенно «останавливается» с ok:false.
}
send_command(source, device, Command::FtmsStart).await; send_command(source, device, Command::FtmsStart).await;
let total = workout::total_duration_secs(segments); let total = workout::total_duration_secs(segments);
@ -678,6 +684,7 @@ pub fn run() {
trainer_stop, trainer_stop,
list_workouts, list_workouts,
get_workout, get_workout,
get_segments,
set_ftp, set_ftp,
get_ftp, get_ftp,
start_workout, start_workout,

@ -35,6 +35,35 @@ pub(crate) fn parse_power(bytes: &[u8]) -> Option<Telemetry> {
Some(Telemetry::Power { watts }) Some(Telemetry::Power { watts })
} }
/// Cycling Power Measurement (0x2A63): crank revolution data (кумулятивные обороты
/// шатуна + время) для вычисления каденса. Поле опциональное (флаг bit 5 = 0x0020),
/// после мгновенной мощности и предшествующих опциональных полей.
pub(crate) fn parse_power_crank(bytes: &[u8]) -> Option<CscRaw> {
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. /// Indoor Bike Data (0x2AD2): variable-length, fields driven by flags.
/// Extracts instantaneous cadence (bit 3) and instantaneous power (bit 5). /// Extracts instantaneous cadence (bit 3) and instantaneous power (bit 5).
pub(crate) fn parse_indoor_bike_data(bytes: &[u8]) -> Vec<Telemetry> { pub(crate) fn parse_indoor_bike_data(bytes: &[u8]) -> Vec<Telemetry> {
@ -44,15 +73,10 @@ pub(crate) fn parse_indoor_bike_data(bytes: &[u8]) -> Vec<Telemetry> {
let flags = u16::from_le_bytes([bytes[0], bytes[1]]); let flags = u16::from_le_bytes([bytes[0], bytes[1]]);
let mut out = Vec::new(); let mut out = Vec::new();
// Magene T110 (и, возможно, другие Magene): шлёт НЕстандартный Indoor Bike Data — // Magene T110: НЕстандартный Indoor Bike Data — флаги всегда 0x0040, раскладка
// флаги всегда 0x0040, а раскладка фиксированная: speed(2-3), power(4-5), // speed(2-3, 0.01 km/h), power(4-5, W), flywheel speed(6-7, rpm). Каденса в 0x2AD2
// cadence(6-7, 0.1 rpm). Стандартный разбор по флагам тут не срабатывает. // НЕТ: станок не отдаёт обороты шатунов по стандартному BLE. Поэтому здесь ничего
// Единица 0.1 rpm (не стандартные 0.5): проверено по соотношению скорость/каденс. // не извлекаем — стандартный разбор ниже вернёт пусто (флаги 0x0040 = avg power).
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;
}
// Byte offset of instantaneous cadence (after the 2-byte flags). // Byte offset of instantaneous cadence (after the 2-byte flags).
let cadence_off = 2 let cadence_off = 2
@ -175,6 +199,21 @@ mod tests {
assert_eq!(parse_power(&[0x00, 0x00, 0xC8]), None); 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] #[test]
fn test_indoor_bike_cadence_and_power() { fn test_indoor_bike_cadence_and_power() {
// flags: bit 3 (cadence) + bit 5 (power) = 0x0028 // flags: bit 3 (cadence) + bit 5 (power) = 0x0028
@ -196,14 +235,11 @@ mod tests {
} }
#[test] #[test]
fn test_magene_indoor_bike_cadence() { fn test_magene_indoor_bike_has_no_cadence() {
// Реальный пакет Magene T110 (ручное вращение): flags=0x0040, speed=0x02f2, // Magene T110 0x2AD2: flags=0x0040, speed(2-3), power(4-5), flywheel(6-7).
// power=8 (4-5), cadence=0x01fd (6-7) = 509 * 0.1 = 50.9 rpm. // Каденса нет — стандартный разбор по флагам (0x0040 = avg power) пуст.
let bytes = [0x40, 0x00, 0xf2, 0x02, 0x08, 0x00, 0xfd, 0x01, 0x09, 0x12, 0x00, 0x00, 0x1c, 0x00, 0xe8, 0x03]; let bytes = [0x40, 0x00, 0xf2, 0x02, 0x08, 0x00, 0xfd, 0x01, 0x09, 0x12, 0x00, 0x00, 0x1c, 0x00, 0xe8, 0x03];
assert_eq!( assert!(parse_indoor_bike_data(&bytes).is_empty());
parse_indoor_bike_data(&bytes),
vec![Telemetry::Cadence { rpm: 50.9 }]
);
} }
#[test] #[test]

@ -25,12 +25,21 @@
<h2 class="device-group__title">каденс</h2> <h2 class="device-group__title">каденс</h2>
<ul class="device-list" id="cadence-list"></ul> <ul class="device-list" id="cadence-list"></ul>
</section> </section>
<section class="device-group">
<h2 class="device-group__title">мощность</h2>
<ul class="device-list" id="power-list"></ul>
</section>
<section class="device-group"> <section class="device-group">
<h2 class="device-group__title">станок</h2> <h2 class="device-group__title">станок</h2>
<ul class="device-list" id="trainer-list"></ul> <ul class="device-list" id="trainer-list"></ul>
</section> </section>
</section> </section>
<label class="power-source">
<input type="checkbox" id="power-source-check" />
<span>мощность с пауэрметра</span>
</label>
<button class="btn btn--primary" id="start-btn" disabled>начать</button> <button class="btn btn--primary" id="start-btn" disabled>начать</button>
</main> </main>
@ -53,6 +62,8 @@
<p class="workout-meta" id="workout-meta"></p> <p class="workout-meta" id="workout-meta"></p>
</section> </section>
<section class="workout-profile" id="workout-profile" aria-hidden="true"></section>
<section class="metrics"> <section class="metrics">
<div class="metric metric--power" id="power-metric"> <div class="metric metric--power" id="power-metric">
<span class="metric__label">мощность</span> <span class="metric__label">мощность</span>

@ -4,11 +4,13 @@ const { invoke } = window.__TAURI__.core;
const connectScreen = document.querySelector("#connect"); const connectScreen = document.querySelector("#connect");
const workoutScreen = document.querySelector("#workout"); const workoutScreen = document.querySelector("#workout");
const startBtn = document.querySelector("#start-btn"); const startBtn = document.querySelector("#start-btn");
const powerSourceCheck = document.querySelector("#power-source-check");
const groups = { const groups = {
hr_sensors: { list: document.querySelector("#hr-list"), category: "hr" }, hr_sensors: { list: document.querySelector("#hr-list"), category: "hr" },
cad_sensors: { list: document.querySelector("#cadence-list"), category: "cadence" }, cad_sensors: { list: document.querySelector("#cadence-list"), category: "cadence" },
trainers: { list: document.querySelector("#trainer-list"), category: "trainer" }, trainers: { list: document.querySelector("#trainer-list"), category: "trainer" },
power_meters: { list: document.querySelector("#power-list"), category: "power" },
}; };
const deviceCards = new Map(); // device_id -> li const deviceCards = new Map(); // device_id -> li
@ -108,7 +110,7 @@ async function connect(deviceId, btn, category, card) {
resetSelected(category); resetSelected(category);
btn.textContent = "✓"; btn.textContent = "✓";
btn.classList.add("device__connect--done"); btn.classList.add("device__connect--done");
selected[category] = { btn, card }; selected[category] = { btn, card, deviceId };
if (pendingStatus[category]) { if (pendingStatus[category]) {
applyStatus(selected[category], category, pendingStatus[category]); applyStatus(selected[category], category, pendingStatus[category]);
delete pendingStatus[category]; delete pendingStatus[category];
@ -139,6 +141,7 @@ once("devices-loaded", (event) => {
renderGroup(groups.hr_sensors, repo.hr_sensors ?? []); renderGroup(groups.hr_sensors, repo.hr_sensors ?? []);
renderGroup(groups.cad_sensors, repo.cad_sensors ?? []); renderGroup(groups.cad_sensors, repo.cad_sensors ?? []);
renderGroup(groups.trainers, repo.trainers ?? []); renderGroup(groups.trainers, repo.trainers ?? []);
renderGroup(groups.power_meters, repo.power_meters ?? []);
}); });
// Непрерывный скан: новые датчики добавляются в список по мере появления. // Непрерывный скан: новые датчики добавляются в список по мере появления.
@ -164,12 +167,19 @@ listen("battery", (event) => {
listen("telemetry", (event) => { listen("telemetry", (event) => {
const t = event.payload.telemetry; const t = event.payload.telemetry;
const devId = event.payload.device_id;
if (t.Power) { if (t.Power) {
actualWatts = t.Power.watts; // источник мощности: пауэрметр (чекбокс + он подключён) или станок
setMetric("power-value", `${actualWatts} W`); const pmId = selected.power?.deviceId;
updatePowerDeviation(); const trainerId = selected.trainer?.deviceId;
samplePower(actualWatts); const expectedId = powerSourceCheck.checked && pmId ? pmId : trainerId;
scheduleDraw(); if (!expectedId || devId === expectedId) {
actualWatts = t.Power.watts;
setMetric("power-value", `${actualWatts} W`);
updatePowerDeviation();
samplePower(actualWatts);
scheduleDraw();
}
} else if (t.Cadence) { } else if (t.Cadence) {
actualCadence = Math.round(t.Cadence.rpm); actualCadence = Math.round(t.Cadence.rpm);
setMetric("cadence-value", `${actualCadence} rpm`); setMetric("cadence-value", `${actualCadence} rpm`);
@ -217,6 +227,7 @@ const workoutMeta = document.querySelector("#workout-meta");
const workoutName = document.querySelector("#workout-name"); const workoutName = document.querySelector("#workout-name");
const workoutStep = document.querySelector("#workout-step"); const workoutStep = document.querySelector("#workout-step");
const workoutBar = document.querySelector("#workout-bar"); const workoutBar = document.querySelector("#workout-bar");
const workoutProfile = document.querySelector("#workout-profile");
const workoutStartBtn = document.querySelector("#workout-start"); const workoutStartBtn = document.querySelector("#workout-start");
const workoutPauseBtn = document.querySelector("#workout-pause"); const workoutPauseBtn = document.querySelector("#workout-pause");
const workoutStopBtn = document.querySelector("#workout-stop"); const workoutStopBtn = document.querySelector("#workout-stop");
@ -288,8 +299,7 @@ const powerHistory = [];
const hrHistory = []; const hrHistory = [];
let graphDirty = false; let graphDirty = false;
function powerZone(watts, ftp) { function percentZone(pct) {
const pct = (watts / ftp) * 100;
if (pct < 55) return 0; if (pct < 55) return 0;
if (pct < 76) return 1; if (pct < 76) return 1;
if (pct < 91) return 2; if (pct < 91) return 2;
@ -299,6 +309,10 @@ function powerZone(watts, ftp) {
return 6; return 6;
} }
function powerZone(watts, ftp) {
return percentZone((watts / ftp) * 100);
}
function currentFtp() { function currentFtp() {
const v = Number(ftpInput.value); const v = Number(ftpInput.value);
return Number.isFinite(v) && v > 0 ? v : 250; return Number.isFinite(v) && v > 0 ? v : 250;
@ -429,6 +443,60 @@ let workoutStartMs = 0;
let workoutSaveName = "workout"; let workoutSaveName = "workout";
let lastSampleSecs = -1; 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) { function formatClock(sec) {
const m = Math.floor(sec / 60); const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60); const s = Math.floor(sec % 60);
@ -461,6 +529,11 @@ workoutSelect.addEventListener("change", () => {
const w = selectedWorkout(); const w = selectedWorkout();
workoutName.textContent = w ? w.name : "—"; workoutName.textContent = w ? w.name : "—";
workoutMeta.textContent = w ? `${w.duration_min} мин · load ${w.load ?? "—"}` : ""; workoutMeta.textContent = w ? `${w.duration_min} мин · load ${w.load ?? "—"}` : "";
if (w) {
renderWorkoutProfile(w.path);
} else {
workoutProfile.replaceChildren();
}
}); });
async function setRunning(active) { async function setRunning(active) {
@ -476,6 +549,7 @@ async function setRunning(active) {
workoutBar.style.width = "0%"; workoutBar.style.width = "0%";
timerEl.textContent = "00:00"; timerEl.textContent = "00:00";
clearTargets(); clearTargets();
highlightSegment(-1);
} }
} }
@ -521,6 +595,7 @@ workoutStopBtn.addEventListener("click", async () => {
listen("workout-progress", (event) => { listen("workout-progress", (event) => {
const p = event.payload; const p = event.payload;
workoutStep.textContent = p.step_name || `шаг ${p.step_index + 1}`; workoutStep.textContent = p.step_name || `шаг ${p.step_index + 1}`;
highlightSegment(p.step_index);
targetWatts = p.target_watts; targetWatts = p.target_watts;
targetCadence = p.target_cadence; targetCadence = p.target_cadence;
updatePowerDeviation(); updatePowerDeviation();

@ -53,7 +53,7 @@ body {
/* --- Подключение датчиков --- */ /* --- Подключение датчиков --- */
#connect { #connect {
grid-template-rows: auto 1fr auto; grid-template-rows: auto 1fr auto auto;
gap: var(--space-4); gap: var(--space-4);
padding: var(--space-4); padding: var(--space-4);
width: 100%; width: 100%;
@ -257,6 +257,20 @@ body {
align-self: end; 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 { .controls--workout {
grid-template-columns: 1fr 1fr 1fr; 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);
}

Loading…
Cancel
Save