diff --git a/src-tauri/src/ble/error.rs b/src-tauri/src/ble/error.rs index 87e7898..76210bd 100644 --- a/src-tauri/src/ble/error.rs +++ b/src-tauri/src/ble/error.rs @@ -35,4 +35,8 @@ pub enum BleError { /// Не удалось записать команду. #[error("не удалось записать команду: {0}")] WriteFailed(btleplug::Error), + + /// Не удалось прочитать характеристику. + #[error("не удалось прочитать характеристику: {0}")] + ReadFailed(btleplug::Error), } diff --git a/src-tauri/src/ble/mock.rs b/src-tauri/src/ble/mock.rs index d1851f4..f13803b 100644 --- a/src-tauri/src/ble/mock.rs +++ b/src-tauri/src/ble/mock.rs @@ -101,6 +101,16 @@ impl SensorSource for MockSource { self.commands.lock().unwrap().push(cmd); Ok(()) } + + async fn battery(&self, device: &Device) -> Result { + let percent = match device.id.as_str() { + "mock-hr" => 15, + "mock-cad" => 60, + "mock-trainer" => 85, + _ => 100, + }; + Ok(percent) + } } #[cfg(test)] diff --git a/src-tauri/src/ble/source.rs b/src-tauri/src/ble/source.rs index 959e5e6..53da46f 100644 --- a/src-tauri/src/ble/source.rs +++ b/src-tauri/src/ble/source.rs @@ -21,6 +21,9 @@ pub(crate) trait SensorSource: Send + Sync { /// Отправить команду на управляющую точку устройства (ERG и т.п.). async fn command(&self, device: &Device, cmd: Command) -> Result<(), BleError>; + + /// Прочитать уровень заряда батареи (0..=100). Best-effort. + async fn battery(&self, device: &Device) -> Result; } const HR_MEASUREMENT: Uuid = uuid_from_u16(0x2A37); @@ -96,6 +99,25 @@ impl SensorSource for BleSource { .await .map_err(BleError::WriteFailed) } + + async fn battery(&self, device: &Device) -> Result { + let peripheral = self.find_peripheral(device).await?; + ensure_connected(&peripheral).await?; + + let batt_uuid = uuid_from_u16(0x2A19); + let ch = peripheral + .characteristics() + .into_iter() + .find(|c| c.uuid == batt_uuid) + .ok_or_else(|| BleError::DeviceNotFound("0x2A19".into()))?; + + let value = peripheral.read(&ch).await.map_err(BleError::ReadFailed)?; + value + .first() + .copied() + .filter(|p| *p <= 100) + .ok_or_else(|| BleError::DeviceNotFound("уровень батареи".into())) + } } async fn ensure_connected(peripheral: &Peripheral) -> Result<(), BleError> { diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 6f48432..5787a15 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -5,6 +5,7 @@ pub(crate) enum Command { FtmsReset, // opcode 0x01 FtmsSetTargetResistance(u8), // opcode 0x04 FtmsSetTargetPower(i16), // opcode 0x05 ERG + FtmsSetTargetCadence(u16), // opcode 0x0B (в единицах 0.5 rpm) FtmsStart, // opcode 0x07 FtmsStop, // opcode 0x08 (0x01=stop, 0x02=pause) @@ -41,6 +42,11 @@ pub(crate) fn encode_command(cmd: Command) -> (ControlPoint, Vec) { p.extend_from_slice(&watts.to_le_bytes()); (ControlPoint::Ftms, p) } + Command::FtmsSetTargetCadence(half_rpm) => { + let mut p = vec![0x0B]; + p.extend_from_slice(&half_rpm.to_le_bytes()); + (ControlPoint::Ftms, p) + } Command::FtmsStart => (ControlPoint::Ftms, vec![0x07]), Command::FtmsStop => (ControlPoint::Ftms, vec![0x08, 0x01]), Command::CscSetCumulativeValue(val) => { @@ -77,6 +83,15 @@ mod tests { ); } + #[test] + fn test_encode_set_target_cadence() { + // 90 rpm = 180 (в 0.5-rpm) = 0x00B4 → LE [0xB4, 0x00] + assert_eq!( + encode_command(Command::FtmsSetTargetCadence(180)), + (ControlPoint::Ftms, vec![0x0B, 0xB4, 0x00]) + ); + } + #[test] fn test_encode_set_target_resistance() { assert_eq!( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e4b8e32..243f6f9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,19 +5,36 @@ mod state; mod telemetry; mod commands; -use std::sync::Arc; +use std::sync::{Arc, Mutex, RwLock}; +use futures::StreamExt; +use serde::Serialize; use tauri::{AppHandle, Emitter, Manager, async_runtime}; 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; +use crate::telemetry::Telemetry; use btleplug::api::Manager as ApiManager; use btleplug::platform::Manager as BleManager; /// Сколько секунд сканируем BLE на старте. const SCAN_SECONDS: u64 = 5; +#[derive(Clone, Serialize)] +struct TelemetryEvent { + device_id: String, + telemetry: Telemetry, +} + +#[derive(Clone, Serialize)] +struct BatteryEvent { + device_id: String, + percent: u8, +} + #[tauri::command] async fn init(app: AppHandle) -> Result<(), String> { let repo = scan_and_classify(&app).await.map_err(|e| e.to_string())?; @@ -30,6 +47,7 @@ async fn scan_and_classify(app: &AppHandle) -> Result { let state = app.state::(); let source = state.source.clone(); let devices = source.scan(SCAN_SECONDS).await?; + *state.devices.write().unwrap() = devices.clone(); Ok(repo_from_devices(devices)) } @@ -50,6 +68,130 @@ fn present_repo(app: &AppHandle, repo: Repo) -> Result<(), String> { Ok(()) } +#[tauri::command] +async fn connect(app: AppHandle, device_id: String, category: String) -> Result<(), String> { + let state = app.state::(); + + let device = { + let devices = state.devices.read().unwrap(); + devices + .iter() + .find(|d| d.id == device_id) + .cloned() + .ok_or_else(|| "устройство не найдено".to_string())? + }; + + if let Some(old) = state.streams.lock().unwrap().remove(&category) { + old.abort(); + } + + let source = state.source.clone(); + let stream = source.stream(&device).await.map_err(|e| e.to_string())?; + + let app_t = app.clone(); + let device_id_t = device.id.clone(); + let handle = async_runtime::spawn(async move { + let mut stream = stream; + while let Some(telemetry) = stream.next().await { + let _ = app_t.emit( + "telemetry", + TelemetryEvent { + device_id: device_id_t.clone(), + telemetry, + }, + ); + } + }); + + state + .streams + .lock() + .unwrap() + .insert(category.clone(), handle); + + if let Ok(percent) = source.battery(&device).await { + let _ = app.emit( + "battery", + BatteryEvent { + device_id: device.id.clone(), + percent, + }, + ); + } + + // станок: запрашиваем контроль и запоминаем + if category == "trainer" { + let _ = source.command(&device, Command::FtmsRequestControl).await; + *state.trainer.lock().unwrap() = Some(device_id); + } + + Ok(()) +} + +/// Задать целевую мощность станку (ERG). +#[tauri::command] +async fn set_target_power(app: AppHandle, watts: i16) -> Result<(), String> { + let state = app.state::(); + let device = trainer_device(&state)?; + state + .source + .command(&device, Command::FtmsSetTargetPower(watts)) + .await + .map_err(|e| e.to_string()) +} + +/// Задать целевой каденс станку (rpm; внутри переводится в 0.5-rpm единицы FTMS). +#[tauri::command] +async fn set_target_cadence(app: AppHandle, rpm: u16) -> Result<(), String> { + let state = app.state::(); + let device = trainer_device(&state)?; + state + .source + .command(&device, Command::FtmsSetTargetCadence(rpm * 2)) + .await + .map_err(|e| e.to_string()) +} + +/// Запустить станок (FTMS Start or Resume, opcode 0x07). +#[tauri::command] +async fn trainer_start(app: AppHandle) -> Result<(), String> { + let state = app.state::(); + let device = trainer_device(&state)?; + state + .source + .command(&device, Command::FtmsStart) + .await + .map_err(|e| e.to_string()) +} + +/// Остановить станок (FTMS Stop or Pause, opcode 0x08). +#[tauri::command] +async fn trainer_stop(app: AppHandle) -> Result<(), String> { + let state = app.state::(); + let device = trainer_device(&state)?; + state + .source + .command(&device, Command::FtmsStop) + .await + .map_err(|e| e.to_string()) +} + +/// Возвращает Device подключённого станка. +fn trainer_device(state: &State) -> Result { + let trainer_id = state + .trainer + .lock() + .unwrap() + .clone() + .ok_or("станок не подключен")?; + let devices = state.devices.read().unwrap(); + devices + .iter() + .find(|d| d.id == trainer_id) + .cloned() + .ok_or_else(|| "станок не найден".to_string()) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -65,11 +207,23 @@ pub fn run() { Arc::new(BleSource::new(adapter)) }; - app.manage(State { source }); + app.manage(State { + source, + devices: RwLock::new(Vec::new()), + trainer: Mutex::new(None), + streams: Mutex::new(std::collections::HashMap::new()), + }); Ok::<(), Box>(()) }) }) - .invoke_handler(tauri::generate_handler![init]) + .invoke_handler(tauri::generate_handler![ + init, + connect, + set_target_power, + set_target_cadence, + trainer_start, + trainer_stop + ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 9a00fdc..9f46c97 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1,7 +1,17 @@ -use std::sync::Arc; +use std::collections::HashMap; +use std::sync::{Arc, Mutex, RwLock}; + +use tauri::async_runtime::JoinHandle; use crate::ble::SensorSource; +use crate::device::Device; pub struct State { pub source: Arc, + /// Все найденные при скане устройства (для поиска по id при подключении). + pub devices: RwLock>, + /// id подключённого станка (тренера). + pub trainer: Mutex>, + /// Потоки телеметрии по категориям ("hr"/"cadence"/"trainer") + pub streams: Mutex>>, } diff --git a/src/index.html b/src/index.html index 67586ee..60329cb 100644 --- a/src/index.html +++ b/src/index.html @@ -9,6 +9,7 @@ +

ridein

@@ -20,19 +21,55 @@

чсс

    -

    каденс

      -

      станок

        - + +
        + + +
        +
        +
        00:00
        + +
        + +
        +
        + мощность + — + W +
        +
        + каденс + — + rpm +
        +
        + чсс + — + bpm +
        +
        + +
        + + + +
        diff --git a/src/main.js b/src/main.js index f7eb605..7043d3b 100644 --- a/src/main.js +++ b/src/main.js @@ -1,50 +1,203 @@ const { once } = window.__TAURI__.event; +const { invoke } = window.__TAURI__.core; + +const connectScreen = document.querySelector("#connect"); +const workoutScreen = document.querySelector("#workout"); +const startBtn = document.querySelector("#start-btn"); const groups = { - hr_sensors: document.querySelector("#hr-list"), - cad_sensors: document.querySelector("#cadence-list"), - trainers: document.querySelector("#trainer-list"), + 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" }, }; -function renderDevice(device) { - const li = document.createElement("li"); - li.className = "device"; +const deviceCards = new Map(); // device_id -> li +const selected = {}; // category -> { btn, card } подключённого датчика - const name = document.createElement("span"); - name.className = "device__name"; - name.textContent = device.display_name || "без имени"; +function el(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text != null) node.textContent = text; + return node; +} - const rssi = document.createElement("span"); - rssi.className = "device__rssi"; - rssi.textContent = device.rssi != null ? `${device.rssi} dBm` : ""; +// --- Батарея --- +function batteryIcon(percent) { + const low = percent < 20; + const wrap = el("span", "battery" + (low ? " battery--low" : "")); + wrap.title = `${percent}%`; - const address = document.createElement("span"); - address.className = "device__meta"; - address.textContent = device.address; + const ns = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(ns, "svg"); + svg.setAttribute("viewBox", "0 0 24 12"); + svg.classList.add("battery__icon"); - li.append(name, rssi, address); - return li; + const rect = (x, y, w, h, cls) => { + const r = document.createElementNS(ns, "rect"); + r.setAttribute("x", x); + r.setAttribute("y", y); + r.setAttribute("width", w); + r.setAttribute("height", h); + if (cls) r.classList.add(cls); + return r; + }; + + svg.append( + rect(0, 0, 20, 12, "battery__body"), + rect(21, 3, 2, 6, "battery__terminal"), + rect(1, 1, low ? 0 : (18 * percent) / 100, 10, "battery__fill"), + ); + + if (low) { + const warn = document.createElementNS(ns, "text"); + warn.setAttribute("x", "10"); + warn.setAttribute("y", "10"); + warn.setAttribute("text-anchor", "middle"); + warn.classList.add("battery__warn"); + warn.textContent = "!"; + svg.append(warn); + } + + wrap.append(svg); + return wrap; } -function renderGroup(list, devices) { - list.replaceChildren(); +// --- Датчики --- +function renderDevice(device, category) { + const li = el("li", "device"); + deviceCards.set(device.id, li); + + const head = el("div", "device__head"); + head.append(el("span", "device__name", device.display_name || "без имени")); + head.append(el("span", "device__rssi", device.rssi != null ? `${device.rssi} dBm` : "")); + + const connectBtn = el("button", "device__connect", "подключить"); + connectBtn.addEventListener("click", () => connect(device.id, connectBtn, category, li)); + li.append(head, el("span", "device__meta", device.address), connectBtn); + return li; +} + +function renderGroup(group, devices) { + group.list.replaceChildren(); if (!devices.length) { - const empty = document.createElement("li"); - empty.className = "device device--empty"; - empty.textContent = "нет"; - list.appendChild(empty); + group.list.appendChild(el("li", "device device--empty", "нет")); return; } + for (const d of devices) group.list.appendChild(renderDevice(d, group.category)); +} - for (const device of devices) { - list.appendChild(renderDevice(device)); +// --- Подключение (режим радио-кнопки: один на категорию) --- +function resetSelected(category) { + const prev = selected[category]; + if (!prev) return; + prev.btn.textContent = "подключить"; + prev.btn.classList.remove("device__connect--done"); + prev.btn.disabled = false; + const battery = prev.card.querySelector(".battery"); + if (battery) battery.remove(); + selected[category] = null; +} + +async function connect(deviceId, btn, category, card) { + btn.disabled = true; + btn.textContent = "…"; + try { + await invoke("connect", { deviceId, category }); + resetSelected(category); + btn.textContent = "✓"; + btn.classList.add("device__connect--done"); + selected[category] = { btn, card }; + if (category === "trainer") startBtn.disabled = false; + } catch (e) { + console.error(e); + btn.disabled = false; + btn.textContent = "ошибка"; } } +// --- Переход на экран упражнения --- +startBtn.addEventListener("click", () => { + connectScreen.hidden = true; + workoutScreen.hidden = false; +}); + +// --- Телеметрия --- +function setMetric(id, value) { + document.querySelector(`#${id}`).textContent = value; +} + once("devices-loaded", (event) => { const repo = event.payload; renderGroup(groups.hr_sensors, repo.hr_sensors ?? []); renderGroup(groups.cad_sensors, repo.cad_sensors ?? []); renderGroup(groups.trainers, repo.trainers ?? []); }); + +once("battery", (event) => { + const { device_id, percent } = event.payload; + const card = deviceCards.get(device_id); + if (card) card.querySelector(".device__head").append(batteryIcon(percent)); +}); + +once("telemetry", (event) => { + const t = event.payload.telemetry; + if (t.Power) setMetric("power-value", t.Power.watts); + else if (t.Cadence) setMetric("cadence-value", Math.round(t.Cadence.rpm)); + else if (t.HeartRate) setMetric("hr-value", t.HeartRate.bpm); +}); + +// --- Таймер --- +const timerEl = document.querySelector("#timer"); +const timerBtn = document.querySelector("#timer-btn"); +let running = false; +let startTs = 0; +let elapsed = 0; +let raf = 0; + +function formatTime(ms) { + const sec = Math.floor(ms / 1000); + const m = Math.floor(sec / 60); + const s = sec % 60; + return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; +} + +function tick() { + elapsed = performance.now() - startTs; + timerEl.textContent = formatTime(elapsed); + if (running) raf = requestAnimationFrame(tick); +} + +timerBtn.addEventListener("click", async () => { + running = !running; + if (running) { + startTs = performance.now() - elapsed; + timerBtn.textContent = "стоп"; + tick(); + try { + await invoke("trainer_start"); + } catch (e) { + console.error(e); + } + } else { + cancelAnimationFrame(raf); + timerBtn.textContent = "старт"; + try { + await invoke("trainer_stop"); + } catch (e) { + console.error(e); + } + } +}); + +// --- Целевая мощность/каденс --- +document.querySelector("#apply-btn").addEventListener("click", async () => { + const watts = Number(document.querySelector("#power-input").value); + const rpm = Number(document.querySelector("#cadence-input").value); + try { + await invoke("set_target_power", { watts }); + await invoke("set_target_cadence", { rpm }); + } catch (e) { + console.error(e); + } +}); diff --git a/src/reset.css b/src/reset.css index 7d4619c..23c8352 100644 --- a/src/reset.css +++ b/src/reset.css @@ -1,3 +1,9 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + html, body, div, span, applet, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite, code, diff --git a/src/styles.css b/src/styles.css index 94969f2..ddbee3d 100644 --- a/src/styles.css +++ b/src/styles.css @@ -23,6 +23,11 @@ body { text-rendering: optimizeLegibility; } +/* атрибут hidden должен перебивать display: grid у .screen */ +[hidden] { + display: none !important; +} + /* ============================================================ Экраны ============================================================ */ @@ -49,8 +54,8 @@ body { #connect { grid-template-rows: auto 1fr auto; - gap: var(--space-6); - padding: var(--space-6); + gap: var(--space-4); + padding: var(--space-4); width: 100%; max-width: var(--container-max); margin-inline: auto; @@ -109,32 +114,61 @@ body { .device { display: grid; - grid-template-columns: 1fr auto; - gap: var(--space-1) var(--space-3); + gap: var(--space-2); padding: var(--space-2) var(--space-3); background: var(--c-surface-2); border-radius: var(--radius-sm); } +.device__head { + display: flex; + align-items: center; + gap: var(--space-2); +} + .device__name { font-size: var(--text-sm); font-weight: var(--fw-medium); } .device__rssi { + margin-left: auto; font-family: var(--font-mono); font-size: var(--text-xs); color: var(--c-text-muted); - justify-self: end; } .device__meta { - grid-column: 1 / -1; font-family: var(--font-mono); font-size: var(--text-xs); color: var(--c-text-faint); } +.device__connect { + padding: var(--space-1) var(--space-3); + border: 1px solid var(--c-border-strong); + border-radius: var(--radius-sm); + font-size: var(--text-xs); + letter-spacing: var(--tracking-wide); + text-transform: uppercase; + color: var(--c-text); + cursor: pointer; + transition: background var(--duration-fast) var(--ease); +} + +.device__connect:hover { + background: var(--c-surface); +} + +.device__connect:disabled { + cursor: default; + opacity: 0.6; +} + +.device__connect--done { + border-color: var(--c-text); +} + .device--empty { justify-items: center; padding: var(--space-3); @@ -144,7 +178,44 @@ body { border: 1px dashed var(--c-border); } -/* --- Кнопка --- */ +/* --- Батарея --- */ + +.battery { + display: inline-flex; + align-items: center; +} + +.battery__icon { + width: 22px; + height: 11px; + display: block; +} + +.battery__body { + fill: none; + stroke: var(--c-text); + stroke-width: 1; +} + +.battery__terminal { + fill: var(--c-text); +} + +.battery__fill { + fill: var(--c-text); +} + +.battery--low .battery__fill { + fill: none; +} + +.battery__warn { + fill: var(--c-text); + font-size: 8px; + font-weight: var(--fw-bold); +} + +/* --- Кнопки --- */ .btn { font-family: var(--font-sans); @@ -158,16 +229,141 @@ body { transition: opacity var(--duration-fast) var(--ease); } +.btn:disabled { + cursor: default; + opacity: 0.35; +} + .btn--primary { color: var(--c-accent-contrast); background: var(--c-accent); } -.btn--primary:hover { +.btn--primary:hover:not(:disabled) { opacity: 0.85; } +.btn--ghost { + color: var(--c-text); + border: 1px solid var(--c-border-strong); +} + +.btn--ghost:hover { + background: var(--c-surface); +} + #connect .btn { justify-self: center; align-self: end; } + +/* ============================================================ + Экран упражнения + ============================================================ */ + +#workout { + grid-template-rows: auto 1fr auto; + gap: var(--space-4); + padding: var(--space-4); + width: 100%; + max-width: var(--container-max); + margin-inline: auto; +} + +.workout-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.timer { + font-size: var(--text-3xl); + font-weight: var(--fw-semibold); + line-height: 1; + letter-spacing: var(--tracking-tight); + font-variant-numeric: tabular-nums; +} + +/* --- Метрики --- */ + +.metrics { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-4); + align-content: center; +} + +.metric { + display: grid; + justify-items: center; + align-content: center; + gap: var(--space-1); + padding: var(--space-3); + background: var(--c-surface); + border: 1px solid var(--c-border); + border-radius: var(--radius-lg); +} + +.metric--power { + grid-column: 1 / -1; +} + +.metric__label { + font-size: var(--text-xs); + letter-spacing: var(--tracking-wide); + text-transform: uppercase; + color: var(--c-text-muted); +} + +.metric__value { + font-size: var(--text-3xl); + font-weight: var(--fw-semibold); + line-height: 1; + font-variant-numeric: tabular-nums; +} + +.metric--cadence .metric__value, +.metric--hr .metric__value { + font-size: var(--text-xl); +} + +.metric__unit { + font-size: var(--text-sm); + color: var(--c-text-muted); +} + +/* --- Управление --- */ + +.controls { + display: grid; + grid-template-columns: 1fr 1fr auto; + gap: var(--space-4); + align-items: end; +} + +.field { + display: grid; + gap: var(--space-2); +} + +.field__label { + font-size: var(--text-xs); + letter-spacing: var(--tracking-wide); + text-transform: uppercase; + color: var(--c-text-muted); +} + +.field__input { + font-family: var(--font-sans); + font-size: var(--text-base); + padding: var(--space-2) var(--space-3); + color: var(--c-text); + background: var(--c-surface); + border: 1px solid var(--c-border-strong); + border-radius: var(--radius-sm); + font-variant-numeric: tabular-nums; +} + +.field__input:focus { + border-color: var(--c-text); +}