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::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<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 {
parse_indoor_bike_data(&n.value)
} else if n.uuid == CONTROL_POINT {

@ -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<DeviceType> {
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<DeviceType> {
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");
}
}

@ -9,6 +9,8 @@ pub struct Repo {
cad_sensors: Vec<Device>,
#[serde(default)]
trainers: Vec<Device>,
#[serde(default)]
power_meters: Vec<Device>,
}
impl Repo {
@ -17,6 +19,7 @@ impl Repo {
hr_sensors: Vec::new(),
cad_sensors: 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())
}
/// Развёрнутые линейные сегменты воркаута (для отрисовки профиля).
#[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 → ватты.
#[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,

@ -35,6 +35,35 @@ pub(crate) fn parse_power(bytes: &[u8]) -> Option<Telemetry> {
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.
/// Extracts instantaneous cadence (bit 3) and instantaneous power (bit 5).
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 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]

@ -25,12 +25,21 @@
<h2 class="device-group__title">каденс</h2>
<ul class="device-list" id="cadence-list"></ul>
</section>
<section class="device-group">
<h2 class="device-group__title">мощность</h2>
<ul class="device-list" id="power-list"></ul>
</section>
<section class="device-group">
<h2 class="device-group__title">станок</h2>
<ul class="device-list" id="trainer-list"></ul>
</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>
</main>
@ -53,6 +62,8 @@
<p class="workout-meta" id="workout-meta"></p>
</section>
<section class="workout-profile" id="workout-profile" aria-hidden="true"></section>
<section class="metrics">
<div class="metric metric--power" id="power-metric">
<span class="metric__label">мощность</span>

@ -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) {
// источник мощности: пауэрметр (чекбокс + он подключён) или станок
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();

@ -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);
}

Loading…
Cancel
Save