Compare commits

..

7 Commits

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

@ -101,6 +101,16 @@ impl SensorSource for MockSource {
self.commands.lock().unwrap().push(cmd);
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)]

@ -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<u8, BleError>;
}
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<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> {

@ -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<u8>) {
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!(

@ -5,19 +5,53 @@ mod state;
mod telemetry;
mod commands;
use std::sync::Arc;
use std::pin::Pin;
use std::sync::{Arc, Mutex, RwLock};
use std::time::Duration;
use futures::{Stream, StreamExt};
use serde::Serialize;
use tauri::{AppHandle, Emitter, Manager, async_runtime};
use tokio::time;
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::state::{State, TrainerTargets};
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,
}
// Watchdog: how long without telemetry before the connection is considered dead.
const WATCHDOG_STALE_TIMEOUT: Duration = Duration::from_secs(5);
// How many times to retry reconnect before giving up.
const RECONNECT_MAX_ATTEMPTS: u32 = 5;
// Base backoff between reconnect attempts.
const RECONNECT_BACKOFF: Duration = Duration::from_secs(2);
#[derive(Clone, Serialize)]
struct ConnectionEvent {
device_id: String,
category: String,
status: &'static str, // "reconnecting" | "reconnected" | "gave_up"
}
#[tauri::command]
async fn init(app: AppHandle) -> Result<(), String> {
let repo = scan_and_classify(&app).await.map_err(|e| e.to_string())?;
@ -30,6 +64,7 @@ async fn scan_and_classify(app: &AppHandle) -> Result<Repo, ble::BleError> {
let state = app.state::<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 +85,231 @@ 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::<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 source_for_spawn = source.clone();
let app_t = app.clone();
let device_t = device.clone();
let category_t = category.clone();
let handle = async_runtime::spawn(async move {
run_watchdog(app_t, source_for_spawn, device_t, category_t).await;
});
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())?;
state.trainer_targets.lock().unwrap().power = Some(watts);
Ok(())
}
/// Задать целевой каденс станку (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())?;
state.trainer_targets.lock().unwrap().cadence = Some(rpm);
Ok(())
}
/// Запустить станок (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())
}
/// Why the watchdog loop stopped consuming a stream.
enum StreamOutcome {
/// The stream ended (peripheral disconnected / notifications closed).
Ended,
/// No item arrived within `timeout` (stale connection).
Stale,
}
async fn consume_with_watchdog<F>(
stream: &mut Pin<Box<dyn Stream<Item = Telemetry> + Send>>,
timeout: Duration,
mut on_item: F,
) -> StreamOutcome
where
F: FnMut(Telemetry),
{
loop {
match time::timeout(timeout, stream.next()).await {
Ok(Some(t)) => on_item(t),
Ok(None) => return StreamOutcome::Ended,
Err(_) => return StreamOutcome::Stale,
}
}
}
async fn run_watchdog(
app: AppHandle,
source: Arc<dyn SensorSource>,
device: Device,
category: String,
) {
let device_id = device.id.clone();
let mut attempts: u32 = 0;
loop {
// stream() does ensure_connected() internally, so a fresh call = reconnect.
let mut stream = match source.stream(&device).await {
Ok(s) => {
attempts = 0;
emit_connection(&app, &device_id, &category, "reconnected");
s
}
Err(_) => {
attempts += 1;
emit_connection(&app, &device_id, &category, "reconnecting");
if attempts >= RECONNECT_MAX_ATTEMPTS {
emit_connection(&app, &device_id, &category, "gave_up");
return;
}
time::sleep(RECONNECT_BACKOFF * attempts).await;
continue;
}
};
// Trainer: re-request control and re-apply last targets after reconnect.
if category == "trainer" {
let _ = source.command(&device, Command::FtmsRequestControl).await;
reapply_trainer_targets(&app, &source, &device).await;
}
let app_emit = app.clone();
let dev_id = device_id.clone();
let outcome = consume_with_watchdog(
&mut stream,
WATCHDOG_STALE_TIMEOUT,
move |t| {
let _ = app_emit.emit("telemetry", TelemetryEvent {
device_id: dev_id.clone(),
telemetry: t,
});
},
).await;
// Stream broke (Ended or Stale). Loop back and reconnect.
let _ = outcome;
emit_connection(&app, &device_id, &category, "reconnecting");
time::sleep(RECONNECT_BACKOFF).await;
}
}
fn emit_connection(app: &AppHandle, device_id: &str, category: &str, status: &'static str) {
let _ = app.emit("connection", ConnectionEvent {
device_id: device_id.to_string(),
category: category.to_string(),
status,
});
}
async fn reapply_trainer_targets(
app: &AppHandle,
source: &Arc<dyn SensorSource>,
device: &Device,
) {
let state = app.state::<State>();
let (power, cadence) = {
let targets = state.trainer_targets.lock().unwrap();
(targets.power, targets.cadence)
};
if let Some(watts) = power {
let _ = source.command(device, Command::FtmsSetTargetPower(watts)).await;
}
if let Some(rpm) = cadence {
let _ = source.command(device, Command::FtmsSetTargetCadence(rpm * 2)).await;
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
@ -65,11 +325,66 @@ 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()),
trainer_targets: Mutex::new(TrainerTargets::default()),
});
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!())
.expect("error while running tauri application");
}
#[cfg(test)]
mod tests {
use super::*;
use futures::stream;
use std::time::Duration;
fn pin(s: impl Stream<Item = Telemetry> + Send + 'static) -> Pin<Box<dyn Stream<Item = Telemetry> + Send>> {
Box::pin(s)
}
#[tokio::test]
async fn ends_when_stream_is_done() {
let items = vec![Telemetry::HeartRate { bpm: 120 }];
let mut s = pin(stream::iter(items));
let mut seen = Vec::new();
let outcome = consume_with_watchdog(&mut s, Duration::from_secs(5), |t| seen.push(t)).await;
assert!(matches!(outcome, StreamOutcome::Ended));
assert_eq!(seen, vec![Telemetry::HeartRate { bpm: 120 }]);
}
#[tokio::test]
async fn stale_when_silent_past_timeout() {
let mut s = pin(stream::pending::<Telemetry>());
let mut seen = Vec::new();
let outcome = consume_with_watchdog(&mut s, Duration::from_millis(50), |t| seen.push(t)).await;
assert!(matches!(outcome, StreamOutcome::Stale));
assert!(seen.is_empty());
}
#[tokio::test]
async fn forwards_every_item_via_callback() {
let items = vec![
Telemetry::Power { watts: 200 },
Telemetry::Cadence { rpm: 90.0 },
];
let mut s = pin(stream::iter(items));
let mut seen = Vec::new();
let _ = consume_with_watchdog(&mut s, Duration::from_secs(5), |t| seen.push(t)).await;
assert_eq!(seen.len(), 2);
}
}

@ -1,7 +1,26 @@
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;
/// Last trainer targets, reapplied after reconnect.
#[derive(Default)]
pub struct TrainerTargets {
pub power: Option<i16>, // watts
pub cadence: Option<u16>, // rpm (raw, as entered by user)
}
pub struct State {
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<()>>>,
/// Last trainer targets, reapplied after reconnect.
pub trainer_targets: Mutex<TrainerTargets>,
}

@ -9,6 +9,7 @@
</head>
<body>
<!-- ЭКРАН ПОДКЛЮЧЕНИЯ -->
<main class="screen" id="connect">
<header class="screen-header">
<h1 class="title">ridein</h1>
@ -20,19 +21,55 @@
<h2 class="device-group__title">чсс</h2>
<ul class="device-list" id="hr-list"></ul>
</section>
<section class="device-group">
<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="trainer-list"></ul>
</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>
</body>
</html>

@ -1,50 +1,223 @@
const { once } = window.__TAURI__.event;
const { once, listen } = 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));
}
// --- Подключение (режим радио-кнопки: один на категорию) ---
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;
}
for (const device of devices) {
list.appendChild(renderDevice(device));
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);
});
listen("connection", (event) => {
const { category, status } = event.payload;
const sel = selected[category];
if (!sel) return;
const { btn } = sel;
if (status === "reconnecting") {
btn.textContent = "переподключение…";
btn.disabled = true;
} else if (status === "reconnected") {
btn.textContent = "✓";
btn.classList.add("device__connect--done");
btn.disabled = true;
} else if (status === "gave_up") {
btn.textContent = "подключить";
btn.classList.remove("device__connect--done");
btn.disabled = false;
selected[category] = null;
}
});
// --- Таймер ---
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,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,

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

Loading…
Cancel
Save