Подключение и чтение датчиков + запись

pull/3/head
lambda 1 week ago
parent 9fb6a22c84
commit 3f48886f5c
No known key found for this signature in database
GPG Key ID: CF45A8EE158BBCB6

@ -35,4 +35,8 @@ pub enum BleError {
/// Не удалось записать команду. /// Не удалось записать команду.
#[error("не удалось записать команду: {0}")] #[error("не удалось записать команду: {0}")]
WriteFailed(btleplug::Error), WriteFailed(btleplug::Error),
/// Не удалось прочитать характеристику.
#[error("не удалось прочитать характеристику: {0}")]
ReadFailed(btleplug::Error),
} }

@ -101,6 +101,16 @@ impl SensorSource for MockSource {
self.commands.lock().unwrap().push(cmd); self.commands.lock().unwrap().push(cmd);
Ok(()) Ok(())
} }
async fn battery(&self, device: &Device) -> Result<u8, BleError> {
let percent = match device.id.as_str() {
"mock-hr" => 15,
"mock-cad" => 60,
"mock-trainer" => 85,
_ => 100,
};
Ok(percent)
}
} }
#[cfg(test)] #[cfg(test)]

@ -21,6 +21,9 @@ pub(crate) trait SensorSource: Send + Sync {
/// Отправить команду на управляющую точку устройства (ERG и т.п.). /// Отправить команду на управляющую точку устройства (ERG и т.п.).
async fn command(&self, device: &Device, cmd: Command) -> Result<(), BleError>; async fn command(&self, device: &Device, cmd: Command) -> Result<(), BleError>;
/// Прочитать уровень заряда батареи (0..=100). Best-effort.
async fn battery(&self, device: &Device) -> Result<u8, BleError>;
} }
const HR_MEASUREMENT: Uuid = uuid_from_u16(0x2A37); const HR_MEASUREMENT: Uuid = uuid_from_u16(0x2A37);
@ -96,6 +99,25 @@ impl SensorSource for BleSource {
.await .await
.map_err(BleError::WriteFailed) .map_err(BleError::WriteFailed)
} }
async fn battery(&self, device: &Device) -> Result<u8, BleError> {
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> { async fn ensure_connected(peripheral: &Peripheral) -> Result<(), BleError> {

@ -5,6 +5,7 @@ pub(crate) enum Command {
FtmsReset, // opcode 0x01 FtmsReset, // opcode 0x01
FtmsSetTargetResistance(u8), // opcode 0x04 FtmsSetTargetResistance(u8), // opcode 0x04
FtmsSetTargetPower(i16), // opcode 0x05 ERG FtmsSetTargetPower(i16), // opcode 0x05 ERG
FtmsSetTargetCadence(u16), // opcode 0x0B (в единицах 0.5 rpm)
FtmsStart, // opcode 0x07 FtmsStart, // opcode 0x07
FtmsStop, // opcode 0x08 (0x01=stop, 0x02=pause) FtmsStop, // opcode 0x08 (0x01=stop, 0x02=pause)
@ -41,6 +42,11 @@ pub(crate) fn encode_command(cmd: Command) -> (ControlPoint, Vec<u8>) {
p.extend_from_slice(&watts.to_le_bytes()); p.extend_from_slice(&watts.to_le_bytes());
(ControlPoint::Ftms, p) (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::FtmsStart => (ControlPoint::Ftms, vec![0x07]),
Command::FtmsStop => (ControlPoint::Ftms, vec![0x08, 0x01]), Command::FtmsStop => (ControlPoint::Ftms, vec![0x08, 0x01]),
Command::CscSetCumulativeValue(val) => { 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] #[test]
fn test_encode_set_target_resistance() { fn test_encode_set_target_resistance() {
assert_eq!( assert_eq!(

@ -5,19 +5,36 @@ mod state;
mod telemetry; mod telemetry;
mod commands; 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 tauri::{AppHandle, Emitter, Manager, async_runtime};
use crate::ble::{BleSource, MockSource, SensorSource}; use crate::ble::{BleSource, MockSource, SensorSource};
use crate::commands::Command;
use crate::device::Device;
use crate::device_repo::{repo_from_devices, Repo}; use crate::device_repo::{repo_from_devices, Repo};
use crate::state::State; use crate::state::State;
use crate::telemetry::Telemetry;
use btleplug::api::Manager as ApiManager; use btleplug::api::Manager as ApiManager;
use btleplug::platform::Manager as BleManager; use btleplug::platform::Manager as BleManager;
/// Сколько секунд сканируем BLE на старте. /// Сколько секунд сканируем BLE на старте.
const SCAN_SECONDS: u64 = 5; 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] #[tauri::command]
async fn init(app: AppHandle) -> Result<(), String> { async fn init(app: AppHandle) -> Result<(), String> {
let repo = scan_and_classify(&app).await.map_err(|e| e.to_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<Repo, ble::BleError> {
let state = app.state::<State>(); let state = app.state::<State>();
let source = state.source.clone(); let source = state.source.clone();
let devices = source.scan(SCAN_SECONDS).await?; let devices = source.scan(SCAN_SECONDS).await?;
*state.devices.write().unwrap() = devices.clone();
Ok(repo_from_devices(devices)) Ok(repo_from_devices(devices))
} }
@ -50,6 +68,130 @@ fn present_repo(app: &AppHandle, repo: Repo) -> Result<(), String> {
Ok(()) Ok(())
} }
#[tauri::command]
async fn connect(app: AppHandle, device_id: String, category: String) -> Result<(), String> {
let state = app.state::<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::<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::<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::<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::<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<Device, String> {
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)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
@ -65,11 +207,23 @@ pub fn run() {
Arc::new(BleSource::new(adapter)) 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<dyn std::error::Error>>(()) Ok::<(), Box<dyn std::error::Error>>(())
}) })
}) })
.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!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }

@ -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::ble::SensorSource;
use crate::device::Device;
pub struct State { pub struct State {
pub source: Arc<dyn SensorSource>, pub source: Arc<dyn SensorSource>,
/// Все найденные при скане устройства (для поиска по id при подключении).
pub devices: RwLock<Vec<Device>>,
/// id подключённого станка (тренера).
pub trainer: Mutex<Option<String>>,
/// Потоки телеметрии по категориям ("hr"/"cadence"/"trainer")
pub streams: Mutex<HashMap<String, JoinHandle<()>>>,
} }

@ -9,6 +9,7 @@
</head> </head>
<body> <body>
<!-- ЭКРАН ПОДКЛЮЧЕНИЯ -->
<main class="screen" id="connect"> <main class="screen" id="connect">
<header class="screen-header"> <header class="screen-header">
<h1 class="title">ridein</h1> <h1 class="title">ridein</h1>
@ -20,19 +21,55 @@
<h2 class="device-group__title">чсс</h2> <h2 class="device-group__title">чсс</h2>
<ul class="device-list" id="hr-list"></ul> <ul class="device-list" id="hr-list"></ul>
</section> </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="cadence-list"></ul> <ul class="device-list" id="cadence-list"></ul>
</section> </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>
<button class="btn btn--primary" id="start-btn">начать</button> <button class="btn btn--primary" id="start-btn" disabled>начать</button>
</main>
<!-- ЭКРАН УПРАЖНЕНИЯ -->
<main class="screen" id="workout" hidden>
<header class="workout-header">
<div class="timer" id="timer">00:00</div>
<button class="btn btn--ghost" id="timer-btn">старт</button>
</header>
<section class="metrics">
<div class="metric metric--power">
<span class="metric__label">мощность</span>
<span class="metric__value" id="power-value">—</span>
<span class="metric__unit">W</span>
</div>
<div class="metric metric--cadence">
<span class="metric__label">каденс</span>
<span class="metric__value" id="cadence-value">—</span>
<span class="metric__unit">rpm</span>
</div>
<div class="metric metric--hr">
<span class="metric__label">чсс</span>
<span class="metric__value" id="hr-value">—</span>
<span class="metric__unit">bpm</span>
</div>
</section>
<section class="controls">
<label class="field">
<span class="field__label">целевая мощность</span>
<input class="field__input" id="power-input" type="number" value="200" min="0" />
</label>
<label class="field">
<span class="field__label">целевой каденс</span>
<input class="field__input" id="cadence-input" type="number" value="90" min="0" />
</label>
<button class="btn btn--primary" id="apply-btn">применить</button>
</section>
</main> </main>
</body> </body>
</html> </html>

@ -1,50 +1,203 @@
const { once } = window.__TAURI__.event; 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 = { const groups = {
hr_sensors: document.querySelector("#hr-list"), hr_sensors: { list: document.querySelector("#hr-list"), category: "hr" },
cad_sensors: document.querySelector("#cadence-list"), cad_sensors: { list: document.querySelector("#cadence-list"), category: "cadence" },
trainers: document.querySelector("#trainer-list"), trainers: { list: document.querySelector("#trainer-list"), category: "trainer" },
}; };
function renderDevice(device) { const deviceCards = new Map(); // device_id -> li
const li = document.createElement("li"); const selected = {}; // category -> { btn, card } подключённого датчика
li.className = "device";
const name = document.createElement("span"); function el(tag, className, text) {
name.className = "device__name"; const node = document.createElement(tag);
name.textContent = device.display_name || "без имени"; if (className) node.className = className;
if (text != null) node.textContent = text;
return node;
}
const rssi = document.createElement("span"); // --- Батарея ---
rssi.className = "device__rssi"; function batteryIcon(percent) {
rssi.textContent = device.rssi != null ? `${device.rssi} dBm` : ""; const low = percent < 20;
const wrap = el("span", "battery" + (low ? " battery--low" : ""));
wrap.title = `${percent}%`;
const address = document.createElement("span"); const ns = "http://www.w3.org/2000/svg";
address.className = "device__meta"; const svg = document.createElementNS(ns, "svg");
address.textContent = device.address; svg.setAttribute("viewBox", "0 0 24 12");
svg.classList.add("battery__icon");
li.append(name, rssi, address); const rect = (x, y, w, h, cls) => {
return li; 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) { if (!devices.length) {
const empty = document.createElement("li"); group.list.appendChild(el("li", "device device--empty", "нет"));
empty.className = "device device--empty";
empty.textContent = "нет";
list.appendChild(empty);
return; 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) => { once("devices-loaded", (event) => {
const repo = event.payload; const repo = event.payload;
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 ?? []);
}); });
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);
}
});

@ -1,3 +1,9 @@
*,
*::before,
*::after {
box-sizing: border-box;
}
html, body, div, span, applet, object, iframe, html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre, h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code, a, abbr, acronym, address, big, cite, code,

@ -23,6 +23,11 @@ body {
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
} }
/* атрибут hidden должен перебивать display: grid у .screen */
[hidden] {
display: none !important;
}
/* ============================================================ /* ============================================================
Экраны Экраны
============================================================ */ ============================================================ */
@ -49,8 +54,8 @@ body {
#connect { #connect {
grid-template-rows: auto 1fr auto; grid-template-rows: auto 1fr auto;
gap: var(--space-6); gap: var(--space-4);
padding: var(--space-6); padding: var(--space-4);
width: 100%; width: 100%;
max-width: var(--container-max); max-width: var(--container-max);
margin-inline: auto; margin-inline: auto;
@ -109,32 +114,61 @@ body {
.device { .device {
display: grid; display: grid;
grid-template-columns: 1fr auto; gap: var(--space-2);
gap: var(--space-1) var(--space-3);
padding: var(--space-2) var(--space-3); padding: var(--space-2) var(--space-3);
background: var(--c-surface-2); background: var(--c-surface-2);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
.device__head {
display: flex;
align-items: center;
gap: var(--space-2);
}
.device__name { .device__name {
font-size: var(--text-sm); font-size: var(--text-sm);
font-weight: var(--fw-medium); font-weight: var(--fw-medium);
} }
.device__rssi { .device__rssi {
margin-left: auto;
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: var(--text-xs); font-size: var(--text-xs);
color: var(--c-text-muted); color: var(--c-text-muted);
justify-self: end;
} }
.device__meta { .device__meta {
grid-column: 1 / -1;
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: var(--text-xs); font-size: var(--text-xs);
color: var(--c-text-faint); 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 { .device--empty {
justify-items: center; justify-items: center;
padding: var(--space-3); padding: var(--space-3);
@ -144,7 +178,44 @@ body {
border: 1px dashed var(--c-border); 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 { .btn {
font-family: var(--font-sans); font-family: var(--font-sans);
@ -158,16 +229,141 @@ body {
transition: opacity var(--duration-fast) var(--ease); transition: opacity var(--duration-fast) var(--ease);
} }
.btn:disabled {
cursor: default;
opacity: 0.35;
}
.btn--primary { .btn--primary {
color: var(--c-accent-contrast); color: var(--c-accent-contrast);
background: var(--c-accent); background: var(--c-accent);
} }
.btn--primary:hover { .btn--primary:hover:not(:disabled) {
opacity: 0.85; 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 { #connect .btn {
justify-self: center; justify-self: center;
align-self: end; 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);
}

Loading…
Cancel
Save