Merge pull request 'reading-sensors' (#3) from reading-sensors into main

Reviewed-on: #3
main
lambda 6 days ago
commit 94cfe8f102

59
src-tauri/Cargo.lock generated

@ -178,13 +178,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]]
name = "async-trait"
version = "0.1.89"
version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
"syn 3.0.9",
]
[[package]]
@ -1114,9 +1114,9 @@ dependencies = [
[[package]]
name = "futures"
version = "0.3.31"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
dependencies = [
"futures-channel",
"futures-core",
@ -1129,9 +1129,9 @@ dependencies = [
[[package]]
name = "futures-channel"
version = "0.3.31"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [
"futures-core",
"futures-sink",
@ -1139,15 +1139,15 @@ dependencies = [
[[package]]
name = "futures-core"
version = "0.3.31"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-executor"
version = "0.3.31"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
dependencies = [
"futures-core",
"futures-task",
@ -1156,9 +1156,9 @@ dependencies = [
[[package]]
name = "futures-io"
version = "0.3.31"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
[[package]]
name = "futures-lite"
@ -1175,32 +1175,32 @@ dependencies = [
[[package]]
name = "futures-macro"
version = "0.3.31"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
"syn 3.0.9",
]
[[package]]
name = "futures-sink"
version = "0.3.31"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
[[package]]
name = "futures-task"
version = "0.3.31"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.31"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-channel",
"futures-core",
@ -1210,7 +1210,6 @@ dependencies = [
"futures-task",
"memchr",
"pin-project-lite",
"pin-utils",
"slab",
]
@ -3190,7 +3189,9 @@ dependencies = [
name = "rideinn"
version = "0.1.0"
dependencies = [
"async-trait",
"btleplug",
"futures",
"hex",
"serde",
"serde_json",
@ -3199,6 +3200,7 @@ dependencies = [
"tauri-plugin-opener",
"thiserror 1.0.69",
"tokio",
"tokio-stream",
"uuid",
]
@ -3709,6 +3711,17 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "1.0.2"

@ -23,10 +23,13 @@ tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
btleplug = "0.11.8"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync"] }
tokio-stream = "0.1"
hex = "0.4"
uuid = "1.16.0"
thiserror = "1"
async-trait = "0.1.92"
futures = "0.3.34"
[profile.dev]
incremental = true # Compile your binary in smaller steps.

@ -1,5 +1,7 @@
use thiserror::Error;
use crate::commands::ControlResult;
/// Ошибки BLE-слоя. В отличие от прежней заглушки сохраняют источник
/// (`btleplug::Error`), чтобы при отладке было видно, что именно пошло не так.
#[derive(Debug, Error)]
@ -11,4 +13,40 @@ pub enum BleError {
/// Не удалось получить список найденных устройств.
#[error("не удалось получить список BLE-устройств: {0}")]
PeripheralsFailed(btleplug::Error),
/// Устройство не найдено по id.
#[error("устройство не найдено: {0}")]
DeviceNotFound(String),
/// Не удалось подключиться к устройству.
#[error("не удалось подключиться к устройству: {0}")]
ConnectFailed(btleplug::Error),
/// Не удалось обнаружить сервисы/характеристики.
#[error("не удалось обнаружить сервисы: {0}")]
DiscoverFailed(btleplug::Error),
/// Не удалось подписаться на уведомления.
#[error("не удалось подписаться на уведомления: {0}")]
SubscribeFailed(btleplug::Error),
/// Не удалось получить поток уведомлений.
#[error("не удалось получить поток уведомлений: {0}")]
NotificationsFailed(btleplug::Error),
/// Не удалось записать команду.
#[error("не удалось записать команду: {0}")]
WriteFailed(btleplug::Error),
/// Не удалось прочитать характеристику.
#[error("не удалось прочитать характеристику: {0}")]
ReadFailed(btleplug::Error),
/// Control Point Response вернулся с ошибкой (не Success).
#[error("станок отклонил управление: {0:?}")]
ControlRejected(ControlResult),
/// Не дождались подтверждения управления за таймаут.
#[error("таймаут подтверждения управления станком")]
ControlTimeout,
}

@ -0,0 +1,140 @@
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use async_trait::async_trait;
use futures::stream::{self, Stream};
use futures::StreamExt;
use crate::ble::source::SensorSource;
use crate::ble::BleError;
use crate::commands::Command;
use crate::device::{get_device_types, Device, DeviceType};
use crate::telemetry::Telemetry;
/// Виртуальный источник: scripted-поток, без реального BLE.
pub(crate) struct MockSource {
devices: Vec<Device>,
commands: Arc<Mutex<Vec<Command>>>,
}
impl MockSource {
pub(crate) fn new() -> Self {
let devices = vec![
Device {
id: "mock-hr".into(),
address: "00:00:00:00:00:01".into(),
display_name: Some("Mock HR".into()),
rssi: Some(-45),
service_uuids: vec!["0000180d-0000-1000-8000-00805f9b34fb".into()],
characteristics: vec!["180d::2a37".into()],
manufacturer_data: None,
metadata: None,
},
Device {
id: "mock-cad".into(),
address: "00:00:00:00:00:02".into(),
display_name: Some("Mock Cadence".into()),
rssi: Some(-50),
service_uuids: vec!["00001816-0000-1000-8000-00805f9b34fb".into()],
characteristics: vec!["1816::2a5b".into()],
manufacturer_data: None,
metadata: None,
},
Device {
id: "mock-trainer".into(),
address: "00:00:00:00:00:03".into(),
display_name: Some("Mock Trainer".into()),
rssi: Some(-55),
service_uuids: vec!["00001826-0000-1000-8000-00805f9b34fb".into()],
characteristics: vec!["1826::2ad2".into()],
manufacturer_data: None,
metadata: None,
},
];
Self {
devices,
commands: Arc::new(Mutex::new(Vec::new())),
}
}
}
#[async_trait]
impl SensorSource for MockSource {
async fn scan(&self, _seconds: u64) -> Result<Vec<Device>, BleError> {
Ok(self.devices.clone())
}
async fn stream(&self, device: &Device)
-> Result<Pin<Box<dyn Stream<Item = Telemetry> + Send>>, BleError>
{
let types = get_device_types(device);
let s: Pin<Box<dyn Stream<Item = Telemetry> + Send>> = if types.contains(&DeviceType::Trainer) {
// мощность рампой 150..300 Вт
stream::unfold(0u16, |i| async move {
tokio::time::sleep(Duration::from_millis(500)).await;
let watts = 150 + ((i % 16) * 10) as i16;
Some((Telemetry::Power { watts }, i.wrapping_add(1)))
})
.boxed()
} else if types.contains(&DeviceType::CadSensor) {
// каденс 85..95 rpm
stream::unfold(0u16, |i| async move {
tokio::time::sleep(Duration::from_millis(500)).await;
let rpm = 85.0 + (i % 10) as f32;
Some((Telemetry::Cadence { rpm }, i.wrapping_add(1)))
})
.boxed()
} else {
// пульс 120..169
stream::unfold(0u16, |i| async move {
tokio::time::sleep(Duration::from_millis(1000)).await;
let bpm = 120 + (i % 50);
Some((Telemetry::HeartRate { bpm }, i.wrapping_add(1)))
})
.boxed()
};
Ok(s)
}
async fn command(&self, _device: &Device, cmd: Command) -> Result<(), BleError> {
self.commands.lock().unwrap().push(cmd);
Ok(())
}
async fn request_control(&self, _device: &Device) -> Result<(), BleError> {
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)]
mod tests {
use super::*;
#[tokio::test]
async fn test_mock_scan_returns_three_devices() {
let src = MockSource::new();
let devices = src.scan(0).await.unwrap();
assert_eq!(devices.len(), 3);
}
#[tokio::test]
async fn test_mock_stream_yields_telemetry() {
let src = MockSource::new();
let devices = src.scan(0).await.unwrap();
let hr = devices.iter().find(|d| d.id == "mock-hr").unwrap();
let mut stream = src.stream(hr).await.unwrap();
let first = stream.next().await;
assert!(matches!(first, Some(Telemetry::HeartRate { .. })));
}
}

@ -1,7 +1,11 @@
mod error;
mod utils;
mod scanner;
mod source;
mod mock;
pub use error::BleError;
pub use scanner::scan_devices;
pub use utils::normalize_uuid;
pub(crate) use source::{BleSource, SensorSource};
pub(crate) use mock::MockSource;

@ -0,0 +1,282 @@
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll};
use std::time::Duration;
use async_trait::async_trait;
use btleplug::api::{bleuuid::uuid_from_u16, Central, Peripheral as _, ValueNotification, WriteType};
use btleplug::platform::{Adapter, Peripheral};
use futures::{Stream, StreamExt};
use tokio::sync::oneshot;
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};
#[async_trait]
pub(crate) trait SensorSource: Send + Sync {
async fn scan(&self, seconds: u64) -> Result<Vec<Device>, BleError>;
/// Подключиться и вернуть поток распарсенных измерений.
async fn stream(&self, device: &Device)
-> Result<Pin<Box<dyn Stream<Item = Telemetry> + Send>>, BleError>;
/// Отправить команду на управляющую точку устройства (ERG и т.п.).
async fn command(&self, device: &Device, cmd: Command) -> Result<(), BleError>;
/// Запросить контроль у FTMS-станка и дождаться подтверждения (0x80).
async fn request_control(&self, device: &Device) -> Result<(), BleError>;
/// Прочитать уровень заряда батареи (0..=100). Best-effort.
async fn battery(&self, device: &Device) -> Result<u8, BleError>;
}
const HR_MEASUREMENT: Uuid = uuid_from_u16(0x2A37);
const CSC_MEASUREMENT: Uuid = uuid_from_u16(0x2A5B);
const POWER_MEASUREMENT: Uuid = uuid_from_u16(0x2A63);
const INDOOR_BIKE_DATA: Uuid = uuid_from_u16(0x2AD2);
const FITNESS_MACHINE_STATUS: Uuid = uuid_from_u16(0x2ADA);
// How long to wait for the trainer to confirm control (Control Point Response 0x80).
const CONTROL_CONFIRM_TIMEOUT: Duration = Duration::from_secs(3);
const CONTROL_POLL_INTERVAL: Duration = Duration::from_millis(50);
/// Реальный источник данных через btleplug.
pub(crate) struct BleSource {
adapter: Adapter,
/// Current cycle's control-result slot. Each `stream()` swaps in a fresh slot so a
/// still-winding-down router from the previous cycle writes to a discarded slot,
/// never the one the new cycle's `request_control` polls.
control: Mutex<Arc<Mutex<Option<ControlResult>>>>,
}
impl BleSource {
pub(crate) fn new(adapter: Adapter) -> Self {
Self {
adapter,
control: Mutex::new(Arc::new(Mutex::new(None))),
}
}
async fn find_peripheral(&self, device: &Device) -> Result<Peripheral, BleError> {
let peripherals = self
.adapter
.peripherals()
.await
.map_err(BleError::PeripheralsFailed)?;
peripherals
.into_iter()
.find(|p| p.id().to_string() == device.id)
.ok_or_else(|| BleError::DeviceNotFound(device.id.clone()))
}
}
/// Stream wrapper that cancels the background notification router on drop, so a
/// reconnect does not leak the previous cycle's routing task.
struct CancellableStream {
inner: tokio_stream::wrappers::UnboundedReceiverStream<Telemetry>,
cancel: Option<oneshot::Sender<()>>,
}
impl Stream for CancellableStream {
type Item = Telemetry;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Telemetry>> {
Pin::new(&mut self.inner).poll_next(cx)
}
}
impl Drop for CancellableStream {
fn drop(&mut self) {
if let Some(cancel) = self.cancel.take() {
let _ = cancel.send(());
}
}
}
#[async_trait]
impl SensorSource for BleSource {
async fn scan(&self, seconds: u64) -> Result<Vec<Device>, BleError> {
crate::ble::scan_devices(&self.adapter, seconds).await
}
async fn stream(&self, device: &Device)
-> Result<Pin<Box<dyn Stream<Item = Telemetry> + Send>>, BleError>
{
let peripheral = self.find_peripheral(device).await?;
ensure_connected(&peripheral).await?;
let targets = [
HR_MEASUREMENT,
CSC_MEASUREMENT,
POWER_MEASUREMENT,
INDOOR_BIKE_DATA,
FITNESS_MACHINE_STATUS,
];
for ch in peripheral.characteristics().iter() {
if targets.contains(&ch.uuid) {
// Unsubscribe first (best-effort) so a stale reconnect does not double-subscribe.
let _ = peripheral.unsubscribe(ch).await;
peripheral.subscribe(ch).await.map_err(BleError::SubscribeFailed)?;
}
}
let notifications = peripheral
.notifications()
.await
.map_err(BleError::NotificationsFailed)?;
// Route notifications on a background task: telemetry -> mpsc, control result -> shared state.
// This keeps the control-result flow concurrent with the telemetry consumer so that
// `request_control` can await confirmation while telemetry is still being consumed.
let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<Telemetry>();
// Swap in a fresh control slot for this cycle; the previous cycle's router holds
// the old slot and can only write there, so it cannot race this cycle's poll.
let control = Arc::new(Mutex::new(None));
*self.control.lock().unwrap() = control.clone();
let (cancel_tx, cancel_rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let mut notifications = notifications;
let mut acc = CscAccumulator::new();
let mut cancel_rx = cancel_rx;
loop {
tokio::select! {
n = notifications.next() => {
match n {
Some(n) => {
for t in parse_notification(n, &mut acc, &control) {
let _ = tx.send(t);
}
}
None => break,
}
}
_ = &mut cancel_rx => break,
}
}
});
let stream: Pin<Box<dyn Stream<Item = Telemetry> + Send>> = Box::pin(CancellableStream {
inner: tokio_stream::wrappers::UnboundedReceiverStream::new(rx),
cancel: Some(cancel_tx),
});
Ok(stream)
}
async fn command(&self, device: &Device, cmd: Command) -> Result<(), BleError> {
let peripheral = self.find_peripheral(device).await?;
ensure_connected(&peripheral).await?;
let (control_point, payload) = encode_command(cmd);
let cp_uuid = uuid_from_u16(control_point.short_uuid());
let ch = peripheral
.characteristics()
.into_iter()
.find(|c| c.uuid == cp_uuid)
.ok_or_else(|| BleError::DeviceNotFound(cp_uuid.to_string()))?;
peripheral
.write(&ch, &payload, WriteType::WithoutResponse)
.await
.map_err(BleError::WriteFailed)
}
async fn request_control(&self, device: &Device) -> Result<(), BleError> {
let peripheral = self.find_peripheral(device).await?;
ensure_connected(&peripheral).await?;
let cp_uuid = uuid_from_u16(ControlPoint::Ftms.short_uuid());
let ch = peripheral
.characteristics()
.into_iter()
.find(|c| c.uuid == cp_uuid)
.ok_or_else(|| BleError::DeviceNotFound(cp_uuid.to_string()))?;
// Reset and send Request Control (opcode 0x00).
let slot = self.control.lock().unwrap().clone();
*slot.lock().unwrap() = None;
let (_, payload) = encode_command(Command::FtmsRequestControl);
peripheral
.write(&ch, &payload, WriteType::WithoutResponse)
.await
.map_err(BleError::WriteFailed)?;
// Wait for the Control Point Response (routed by the stream's notification task).
let result = tokio::time::timeout(CONTROL_CONFIRM_TIMEOUT, async move {
loop {
let current = *slot.lock().unwrap();
if let Some(r) = current {
return r;
}
tokio::time::sleep(CONTROL_POLL_INTERVAL).await;
}
})
.await
.map_err(|_| BleError::ControlTimeout)?;
match result {
ControlResult::Success => Ok(()),
other => Err(BleError::ControlRejected(other)),
}
}
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> {
if !peripheral.is_connected().await.unwrap_or(false) {
peripheral.connect().await.map_err(BleError::ConnectFailed)?;
}
peripheral
.discover_services()
.await
.map_err(BleError::DiscoverFailed)?;
Ok(())
}
fn parse_notification(
n: ValueNotification,
acc: &mut CscAccumulator,
control: &Arc<Mutex<Option<ControlResult>>>,
) -> Vec<Telemetry> {
if n.uuid == HR_MEASUREMENT {
parse_hr(&n.value).into_iter().collect()
} else if n.uuid == CSC_MEASUREMENT {
parse_csc(&n.value)
.and_then(|raw| acc.push(raw))
.map(|rpm| Telemetry::Cadence { rpm })
.into_iter()
.collect()
} else if n.uuid == POWER_MEASUREMENT {
parse_power(&n.value).into_iter().collect()
} else if n.uuid == INDOOR_BIKE_DATA {
parse_indoor_bike_data(&n.value)
} else if n.uuid == FITNESS_MACHINE_STATUS {
if let Some(result) = parse_control_result(&n.value) {
*control.lock().unwrap() = Some(result);
}
Vec::new()
} else {
Vec::new()
}
}

@ -7,3 +7,36 @@ pub fn normalize_uuid(u: &str) -> String {
}
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_strips_standard_base_suffix() {
assert_eq!(normalize_uuid("00001816-0000-1000-8000-00805f9b34fb"), "1816");
}
#[test]
fn test_short_uuid_is_unchanged() {
assert_eq!(normalize_uuid("1816"), "1816");
}
#[test]
fn test_trims_whitespace() {
assert_eq!(normalize_uuid(" 1816 "), "1816");
}
#[test]
fn test_lowercases_input() {
assert_eq!(normalize_uuid("181D"), "181d");
}
#[test]
fn test_custom_128bit_uuid_is_left_untouched() {
assert_eq!(
normalize_uuid("f0001816-0451-4000-b000-000000000000"),
"f0001816-0451-4000-b000-000000000000"
);
}
}

@ -0,0 +1,182 @@
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Command {
// FTMS Fitness Machine Control Point (0x2AD9)
FtmsRequestControl, // opcode 0x00
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)
// CSC SC Control Point (0x2A55)
CscSetCumulativeValue(u32), // opcode 0x01
CscSetWheelCircumference(u16), // opcode 0x10
}
/// Управляющая точка, на которую пишется команда.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ControlPoint {
Ftms,
Csc,
}
impl ControlPoint {
/// Короткий UUID характеристики (16-бит).
pub(crate) fn short_uuid(self) -> u16 {
match self {
ControlPoint::Ftms => 0x2AD9,
ControlPoint::Csc => 0x2A55,
}
}
}
/// Чистая функция: команда → (точка, байты payload).
pub(crate) fn encode_command(cmd: Command) -> (ControlPoint, Vec<u8>) {
match cmd {
Command::FtmsRequestControl => (ControlPoint::Ftms, vec![0x00]),
Command::FtmsReset => (ControlPoint::Ftms, vec![0x01]),
Command::FtmsSetTargetResistance(level) => (ControlPoint::Ftms, vec![0x04, level]),
Command::FtmsSetTargetPower(watts) => {
let mut p = vec![0x05];
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) => {
let mut p = vec![0x01];
p.extend_from_slice(&val.to_le_bytes());
(ControlPoint::Csc, p)
}
Command::CscSetWheelCircumference(mm) => {
let mut p = vec![0x10];
p.extend_from_slice(&mm.to_le_bytes());
(ControlPoint::Csc, p)
}
}
}
/// Result of a Control Point Response (Fitness Machine Status opcode 0x80).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ControlResult {
Success,
OpCodeNotSupported,
InvalidParameter,
OperationFailed,
ControlNotPermitted,
}
/// Parses a Fitness Machine Status (0x2ADA) notification for a Control Point
/// Response to Request Control (opcode 0x00).
/// Layout: [opcode=0x80][request opcode=0x00][result code].
pub(crate) fn parse_control_result(bytes: &[u8]) -> Option<ControlResult> {
if bytes.first() != Some(&0x80) {
return None;
}
// Only accept responses to Request Control; ignore responses to other commands.
if bytes.get(1) != Some(&0x00) {
return None;
}
match bytes.get(2) {
Some(0x01) => Some(ControlResult::Success),
Some(0x02) => Some(ControlResult::OpCodeNotSupported),
Some(0x03) => Some(ControlResult::InvalidParameter),
Some(0x04) => Some(ControlResult::OperationFailed),
Some(0x05) => Some(ControlResult::ControlNotPermitted),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_control_result_success() {
assert_eq!(parse_control_result(&[0x80, 0x00, 0x01]), Some(ControlResult::Success));
}
#[test]
fn test_parse_control_result_rejected() {
assert_eq!(
parse_control_result(&[0x80, 0x00, 0x05]),
Some(ControlResult::ControlNotPermitted)
);
}
#[test]
fn test_parse_control_result_not_a_response() {
// opcode != 0x80 (e.g. Fitness Machine Stopped)
assert_eq!(parse_control_result(&[0x02]), None);
assert_eq!(parse_control_result(&[]), None);
}
#[test]
fn test_parse_control_result_ignores_other_request_opcodes() {
// opcode 0x80 but request opcode != 0x00 (e.g. response to Set Target Power 0x05)
assert_eq!(parse_control_result(&[0x80, 0x05, 0x01]), None);
}
#[test]
fn test_encode_request_control() {
assert_eq!(
encode_command(Command::FtmsRequestControl),
(ControlPoint::Ftms, vec![0x00])
);
}
#[test]
fn test_encode_set_target_power() {
// 200 Вт = 0x00C8 → LE [0xC8, 0x00]
assert_eq!(
encode_command(Command::FtmsSetTargetPower(200)),
(ControlPoint::Ftms, vec![0x05, 0xC8, 0x00])
);
}
#[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!(
encode_command(Command::FtmsSetTargetResistance(3)),
(ControlPoint::Ftms, vec![0x04, 0x03])
);
}
#[test]
fn test_encode_stop() {
assert_eq!(
encode_command(Command::FtmsStop),
(ControlPoint::Ftms, vec![0x08, 0x01])
);
}
#[test]
fn test_encode_csc_cumulative_value() {
// 1000 = 0x000003E8 → LE [0xE8, 0x03, 0x00, 0x00]
assert_eq!(
encode_command(Command::CscSetCumulativeValue(1000)),
(ControlPoint::Csc, vec![0x01, 0xE8, 0x03, 0x00, 0x00])
);
}
#[test]
fn test_control_point_uuids() {
assert_eq!(ControlPoint::Ftms.short_uuid(), 0x2AD9);
assert_eq!(ControlPoint::Csc.short_uuid(), 0x2A55);
}
}

@ -93,6 +93,23 @@ pub fn get_device_types(device: &Device) -> Vec<DeviceType> {
mod tests {
use super::*;
/// Test helper: builds a Device from service UUIDs and characteristics,
/// with all other fields stubbed. Tests only exercise classification.
fn dev(services: &[&str], characteristics: &[&str]) -> Device {
Device {
id: "test-device".into(),
address: "00:00:00:00:00:00".into(),
display_name: None,
rssi: None,
service_uuids: services.iter().map(|s| s.to_string()).collect(),
characteristics: characteristics.iter().map(|s| s.to_string()).collect(),
manufacturer_data: None,
metadata: None,
}
}
// Real-device fixture: Magene speed/cadence sensor advertising the CSC
// service (0x1816) as a full 128-bit UUID.
#[test]
fn test_service_types_for_magene_speedcadence() {
let magene = Device {
@ -108,4 +125,101 @@ mod tests {
assert_eq!(get_device_types(&magene), [DeviceType::CadSensor]);
}
// Heart rate
#[test]
fn test_hr_sensor_by_service() {
assert_eq!(get_device_types(&dev(&["180d"], &[])), [DeviceType::HrSensor]);
}
#[test]
fn test_hr_sensor_by_characteristic() {
assert_eq!(get_device_types(&dev(&[], &["2a37"])), [DeviceType::HrSensor]);
}
#[test]
fn test_hr_sensor_by_full_128bit_uuid() {
assert_eq!(
get_device_types(&dev(&["0000180d-0000-1000-8000-00805f9b34fb"], &[])),
[DeviceType::HrSensor]
);
}
// Cadence / speed
#[test]
fn test_cadence_by_service() {
assert_eq!(get_device_types(&dev(&["1816"], &[])), [DeviceType::CadSensor]);
}
#[test]
fn test_cadence_by_characteristic() {
assert_eq!(get_device_types(&dev(&[], &["2a5b"])), [DeviceType::CadSensor]);
}
// Trainer / cycling power / FTMS
#[test]
fn test_trainer_by_ftms_service() {
assert_eq!(get_device_types(&dev(&["1826"], &[])), [DeviceType::Trainer]);
}
#[test]
fn test_trainer_by_power_service() {
assert_eq!(get_device_types(&dev(&["1818"], &[])), [DeviceType::Trainer]);
}
#[test]
fn test_trainer_by_power_characteristic() {
assert_eq!(get_device_types(&dev(&[], &["2a63"])), [DeviceType::Trainer]);
}
#[test]
fn test_trainer_by_fitness_machine_characteristic() {
assert_eq!(get_device_types(&dev(&[], &["2acc"])), [DeviceType::Trainer]);
}
// Indoor bike data (2ADx): a trainer characteristic that also implies cadence.
#[test]
fn test_indoor_bike_data_is_trainer_and_cadence() {
// 2ad2 is both a trainer characteristic and an indoor-bike marker.
assert_eq!(
get_device_types(&dev(&[], &["2ad2"])),
[DeviceType::Trainer, DeviceType::CadSensor]
);
}
#[test]
fn test_indoor_bike_variant_is_cadence_only() {
// 2ad5 is an indoor-bike marker but not in the trainer list.
assert_eq!(get_device_types(&dev(&[], &["2ad5"])), [DeviceType::CadSensor]);
}
// "service::characteristic" strings produced by the scanner.
#[test]
fn test_characteristic_split_on_double_colon() {
assert_eq!(get_device_types(&dev(&[], &["1816::2a5b"])), [DeviceType::CadSensor]);
}
// Combined and negative cases
#[test]
fn test_hr_and_cadence_combo() {
assert_eq!(
get_device_types(&dev(&["180d", "1816"], &[])),
[DeviceType::HrSensor, DeviceType::CadSensor]
);
}
#[test]
fn test_battery_service_is_ignored() {
assert!(get_device_types(&dev(&["180f"], &[])).is_empty());
}
#[test]
fn test_empty_device_has_no_types() {
assert!(get_device_types(&dev(&[], &[])).is_empty());
}
#[test]
fn test_uuid_case_and_whitespace_are_normalized() {
assert_eq!(get_device_types(&dev(&[" 180D "], &[])), [DeviceType::HrSensor]);
}
}

@ -60,6 +60,20 @@ pub fn repo_from_devices(devices: Vec<Device>) -> Repo {
mod tests {
use super::*;
/// Helper: a cadence device (CSC service 0x1816) with the given RSSI.
fn cadence_device(rssi: Option<i16>) -> Device {
Device {
id: format!("cad-{rssi:?}"),
address: "00:00:00:00:00:00".into(),
display_name: None,
rssi,
service_uuids: vec!["1816".into()],
characteristics: vec![],
manufacturer_data: None,
metadata: None,
}
}
#[test]
fn test_repo_classifies_magene_as_cadence_sensor() {
let magene = Device {
@ -79,4 +93,42 @@ mod tests {
assert!(repo.hr_sensors.is_empty());
assert!(repo.trainers.is_empty());
}
#[test]
fn test_sorts_devices_by_rssi_descending() {
let devices = vec![
cadence_device(Some(-80)),
cadence_device(Some(-42)),
cadence_device(Some(-60)),
];
let repo = repo_from_devices(devices);
let rssis: Vec<_> = repo.cad_sensors.iter().map(|d| d.rssi).collect();
assert_eq!(rssis, vec![Some(-42), Some(-60), Some(-80)]);
}
#[test]
fn test_devices_without_rssi_go_last() {
let devices = vec![cadence_device(None), cadence_device(Some(-50))];
let repo = repo_from_devices(devices);
assert_eq!(repo.cad_sensors[0].rssi, Some(-50));
assert_eq!(repo.cad_sensors[1].rssi, None);
}
#[test]
fn test_indoor_bike_lands_in_trainer_and_cadence_buckets() {
let indoor_bike = Device {
id: "indoor-bike".into(),
address: "00:00:00:00:00:00".into(),
display_name: None,
rssi: Some(-70),
service_uuids: vec![],
characteristics: vec!["2ad2".into()],
manufacturer_data: None,
metadata: None,
};
let repo = repo_from_devices(vec![indoor_bike]);
assert_eq!(repo.trainers.len(), 1);
assert_eq!(repo.cad_sensors.len(), 1);
assert!(repo.hr_sensors.is_empty());
}
}

@ -2,32 +2,69 @@ mod device;
mod ble;
mod device_repo;
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 crate::{ble::scan_devices, device_repo::{repo_from_devices, Repo}};
use crate::state::State;
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, TrainerState};
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 consecutive failed/silent cycles before giving up on reconnect.
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> {
println!("init rideinn app");
let repo = scan_and_classify(&app).await.map_err(|e| e.to_string())?;
present_repo(&app, repo).map_err(|e| e.to_string())?;
Ok(())
}
/// Этап 1: скан BLE-устройств и раскладка по типам датчиков.
async fn scan_and_classify(app: &AppHandle) -> Result<Repo, ble::BleError> {
let state = app.state::<State>();
let adapter = state.adapter.clone();
let devices = scan_devices(&adapter, SCAN_SECONDS).await?;
let source = state.source.clone();
let devices = source.scan(SCAN_SECONDS).await?;
*state.devices.write().unwrap() = devices.clone();
Ok(repo_from_devices(devices))
}
@ -48,21 +85,351 @@ 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();
// Battery first, before the watchdog task touches the peripheral (avoids a
// connect/discover race on the same peripheral).
if let Ok(percent) = source.battery(&device).await {
let _ = app.emit(
"battery",
BatteryEvent {
device_id: device.id.clone(),
percent,
},
);
}
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, device_t, category_t).await;
});
state
.streams
.lock()
.unwrap()
.insert(category.clone(), handle);
// Trainer: remember the device id and reset the previous trainer's targets.
// Control is requested (with confirmation) inside run_watchdog, after the
// notification subscription is live.
if category == "trainer" {
*state.trainer.lock().unwrap() = Some(device.id.clone());
*state.trainer_state.lock().unwrap() = TrainerState::default();
}
Ok(())
}
/// Задать целевую мощность станку (ERG).
#[tauri::command]
async fn set_target_power(app: AppHandle, watts: i16) -> Result<(), String> {
let state = app.state::<State>();
if watts < 0 {
return Err("мощность не может быть отрицательной".to_string());
}
let device = trainer_device(&state)?;
state
.source
.command(&device, Command::FtmsSetTargetPower(watts))
.await
.map_err(|e| e.to_string())?;
state.trainer_state.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 half_rpm = rpm.checked_mul(2).ok_or("каденс слишком большой")?;
let device = trainer_device(&state)?;
state
.source
.command(&device, Command::FtmsSetTargetCadence(half_rpm))
.await
.map_err(|e| e.to_string())?;
state.trainer_state.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())?;
state.trainer_state.lock().unwrap().running = true;
Ok(())
}
/// Остановить станок (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())?;
state.trainer_state.lock().unwrap().running = false;
Ok(())
}
/// Возвращает 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,
}
/// Consumes a telemetry stream, invoking `on_item` per item, until it ends or goes
/// silent for `timeout`. Returns the outcome and whether at least one item was seen.
async fn consume_with_watchdog<F>(
stream: &mut Pin<Box<dyn Stream<Item = Telemetry> + Send>>,
timeout: Duration,
mut on_item: F,
) -> (StreamOutcome, bool)
where
F: FnMut(Telemetry),
{
let mut saw = false;
loop {
match time::timeout(timeout, stream.next()).await {
Ok(Some(t)) => {
saw = true;
on_item(t);
}
Ok(None) => return (StreamOutcome::Ended, saw),
Err(_) => return (StreamOutcome::Stale, saw),
}
}
}
async fn run_watchdog(
app: AppHandle,
source: Arc<dyn SensorSource>,
device: Device,
category: String,
) {
let device_id = device.id.clone();
let mut silent_cycles: u32 = 0;
loop {
// stream() does ensure_connected() internally, so a fresh call = reconnect.
let mut stream = match source.stream(&device).await {
Ok(s) => {
emit_connection(&app, &device_id, &category, "reconnected");
s
}
Err(_) => {
silent_cycles += 1;
emit_connection(&app, &device_id, &category, "reconnecting");
if silent_cycles >= RECONNECT_MAX_ATTEMPTS {
emit_connection(&app, &device_id, &category, "gave_up");
app.state::<State>().streams.lock().unwrap().remove(&category);
return;
}
time::sleep(RECONNECT_BACKOFF * silent_cycles).await;
continue;
}
};
// Trainer: request control (with confirmation) and restore targets/start.
if category == "trainer" {
match source.request_control(&device).await {
Ok(()) => reapply_trainer_state(&app, &source, &device).await,
Err(e) => eprintln!("request_control failed: {e}"),
}
}
let app_emit = app.clone();
let dev_id = device_id.clone();
let (outcome, saw_data) = consume_with_watchdog(
&mut stream,
WATCHDOG_STALE_TIMEOUT,
move |t| {
let _ = app_emit.emit(
"telemetry",
TelemetryEvent {
device_id: dev_id.clone(),
telemetry: t,
},
);
},
)
.await;
let _ = outcome;
// A healthy cycle (received data) resets the counter; a silent one does not.
if saw_data {
silent_cycles = 0;
} else {
silent_cycles += 1;
}
emit_connection(&app, &device_id, &category, "reconnecting");
if silent_cycles >= RECONNECT_MAX_ATTEMPTS {
emit_connection(&app, &device_id, &category, "gave_up");
app.state::<State>().streams.lock().unwrap().remove(&category);
return;
}
time::sleep(RECONNECT_BACKOFF * silent_cycles.max(1)).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_state(
app: &AppHandle,
source: &Arc<dyn SensorSource>,
device: &Device,
) {
let state = app.state::<State>();
let (power, cadence, running) = {
let t = state.trainer_state.lock().unwrap();
(t.power, t.cadence, t.running)
};
if let Some(watts) = power {
let _ = source.command(device, Command::FtmsSetTargetPower(watts)).await;
}
if let Some(rpm) = cadence {
if let Some(half_rpm) = rpm.checked_mul(2) {
let _ = source.command(device, Command::FtmsSetTargetCadence(half_rpm)).await;
}
}
if running {
let _ = source.command(device, Command::FtmsStart).await;
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.setup(|app| {
async_runtime::block_on(async move {
let manager = BleManager::new().await?;
let adapters = manager.adapters().await?;
let adapter = adapters.into_iter().next()
.ok_or_else(|| Box::<dyn std::error::Error>::from("No BLE adapter found"))?;
let source: Arc<dyn SensorSource> = if std::env::var("RIDEIN_MOCK").is_ok() {
Arc::new(MockSource::new())
} else {
let manager = BleManager::new().await?;
let adapters = manager.adapters().await?;
let adapter = adapters.into_iter().next()
.ok_or_else(|| Box::<dyn std::error::Error>::from("No BLE adapter found"))?;
Arc::new(BleSource::new(adapter))
};
app.manage(State { adapter: Arc::new(adapter) });
app.manage(State {
source,
devices: RwLock::new(Vec::new()),
trainer: Mutex::new(None),
streams: Mutex::new(std::collections::HashMap::new()),
trainer_state: Mutex::new(TrainerState::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, saw) = consume_with_watchdog(&mut s, Duration::from_secs(5), |t| seen.push(t)).await;
assert!(matches!(outcome, StreamOutcome::Ended));
assert!(saw);
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, saw) = consume_with_watchdog(&mut s, Duration::from_millis(50), |t| seen.push(t)).await;
assert!(matches!(outcome, StreamOutcome::Stale));
assert!(!saw);
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 (_outcome, saw) = consume_with_watchdog(&mut s, Duration::from_secs(5), |t| seen.push(t)).await;
assert!(saw);
assert_eq!(seen.len(), 2);
}
}

@ -1,6 +1,27 @@
use btleplug::platform::Adapter;
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;
/// Trainer control state, restored after a reconnect.
#[derive(Default)]
pub struct TrainerState {
pub power: Option<i16>, // watts
pub cadence: Option<u16>, // rpm (raw, as entered by user)
pub running: bool,
}
pub struct State {
pub adapter: Arc<Adapter>
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<()>>>,
/// Trainer control state, restored after a reconnect.
pub trainer_state: Mutex<TrainerState>,
}

@ -0,0 +1,237 @@
use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Serialize)]
pub(crate) enum Telemetry {
HeartRate { bpm: u16 },
Cadence { rpm: f32 },
Power { watts: i16 },
Battery { percent: u8 },
}
/// Сырые данные CSC: кумулятивные счётчики шатуна.
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct CscRaw {
pub crank_revs: u16,
pub crank_time: u16,
}
/// Heart Rate Measurement (0x2A37): флаг + значение (uint8 или uint16).
pub(crate) fn parse_hr(bytes: &[u8]) -> Option<Telemetry> {
let flags = *bytes.first()?;
if flags & 0x01 != 0 {
let bpm = u16::from_le_bytes([*bytes.get(1)?, *bytes.get(2)?]);
Some(Telemetry::HeartRate { bpm })
} else {
Some(Telemetry::HeartRate { bpm: *bytes.get(1)? as u16 })
}
}
/// Cycling Power Measurement (0x2A63): мгновенная мощность sint16 (LE).
pub(crate) fn parse_power(bytes: &[u8]) -> Option<Telemetry> {
if bytes.len() < 4 {
return None;
}
let watts = i16::from_le_bytes([bytes[2], bytes[3]]);
Some(Telemetry::Power { watts })
}
/// 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> {
if bytes.len() < 2 {
return Vec::new();
}
let flags = u16::from_le_bytes([bytes[0], bytes[1]]);
let mut out = Vec::new();
// Byte offset of instantaneous cadence (after the 2-byte flags).
let cadence_off = 2
+ if flags & 0x0100 != 0 { 2 } else { 0 } // instantaneous speed
+ if flags & 0x0002 != 0 { 2 } else { 0 }; // average speed
// Byte offset of instantaneous power.
let power_off = cadence_off
+ if flags & 0x0008 != 0 { 2 } else { 0 } // instantaneous cadence
+ if flags & 0x0010 != 0 { 2 } else { 0 } // average cadence
+ if flags & 0x0004 != 0 { 3 } else { 0 } // total distance (uint24)
+ if flags & 0x0080 != 0 { 2 } else { 0 }; // resistance level
if flags & 0x0008 != 0 && cadence_off + 2 <= bytes.len() {
let raw = u16::from_le_bytes([bytes[cadence_off], bytes[cadence_off + 1]]);
out.push(Telemetry::Cadence { rpm: raw as f32 / 2.0 });
}
if flags & 0x0020 != 0 && power_off + 2 <= bytes.len() {
let raw = u16::from_le_bytes([bytes[power_off], bytes[power_off + 1]]);
out.push(Telemetry::Power { watts: raw as i16 });
}
out
}
/// Battery Level (0x2A19): один байт 0..=100.
pub(crate) fn parse_battery(bytes: &[u8]) -> Option<Telemetry> {
let percent = *bytes.first()?;
(percent <= 100).then_some(Telemetry::Battery { percent })
}
/// CSC Measurement (0x2A5B): извлекаем кумулятивные счётчики шатуна.
pub(crate) fn parse_csc(bytes: &[u8]) -> Option<CscRaw> {
let flags = *bytes.first()?;
if flags & 0x02 == 0 {
return None; // нет данных шатуна
}
let mut idx = 1;
if flags & 0x01 != 0 {
idx += 6; // пропускаем данные колеса (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 })
}
/// Превращает кумулятивные счётчики CSC в обороты в минуту.
pub(crate) struct CscAccumulator {
last_revs: Option<u16>,
last_time: Option<u16>,
}
impl CscAccumulator {
pub(crate) fn new() -> Self {
Self { last_revs: None, last_time: None }
}
/// Возвращает rpm или None (нужно минимум два измерения).
pub(crate) fn push(&mut self, raw: CscRaw) -> Option<f32> {
let (Some(prev_revs), Some(prev_time)) = (self.last_revs, self.last_time) else {
self.last_revs = Some(raw.crank_revs);
self.last_time = Some(raw.crank_time);
return None;
};
self.last_revs = Some(raw.crank_revs);
self.last_time = Some(raw.crank_time);
let d_revs = raw.crank_revs.wrapping_sub(prev_revs) as f32;
let d_time = raw.crank_time.wrapping_sub(prev_time) as f32; // 1/1024 с
if d_time == 0.0 {
return None;
}
Some(d_revs * 60.0 * 1024.0 / d_time)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_hr_8bit() {
// flags=0x00 (8-бит), значение 120
assert_eq!(parse_hr(&[0x00, 0x78]), Some(Telemetry::HeartRate { bpm: 120 }));
}
#[test]
fn test_parse_hr_16bit() {
// flags=0x01 (16-бит), значение 180 = 0x00B4
assert_eq!(parse_hr(&[0x01, 0xB4, 0x00]), Some(Telemetry::HeartRate { bpm: 180 }));
}
#[test]
fn test_parse_hr_empty() {
assert_eq!(parse_hr(&[]), None);
}
#[test]
fn test_parse_hr_missing_value() {
assert_eq!(parse_hr(&[0x00]), None); // 8-бит, но значения нет
}
#[test]
fn test_parse_power() {
// flags=0x0000, мощность 200 = 0x00C8 (LE)
assert_eq!(parse_power(&[0x00, 0x00, 0xC8, 0x00]), Some(Telemetry::Power { watts: 200 }));
}
#[test]
fn test_parse_power_negative() {
// -200 = 0xFF38 (sint16 LE)
assert_eq!(parse_power(&[0x00, 0x00, 0x38, 0xFF]), Some(Telemetry::Power { watts: -200 }));
}
#[test]
fn test_parse_power_too_short() {
assert_eq!(parse_power(&[0x00, 0x00, 0xC8]), None);
}
#[test]
fn test_indoor_bike_cadence_and_power() {
// flags: bit 3 (cadence) + bit 5 (power) = 0x0028
// cadence uint16 = 180 (0.5 rpm) => 90 rpm; power sint16 = 250 W
let bytes = [0x28, 0x00, 0xB4, 0x00, 0xFA, 0x00];
assert_eq!(
parse_indoor_bike_data(&bytes),
vec![
Telemetry::Cadence { rpm: 90.0 },
Telemetry::Power { watts: 250 },
]
);
}
#[test]
fn test_indoor_bike_too_short() {
assert!(parse_indoor_bike_data(&[0x28]).is_empty());
assert!(parse_indoor_bike_data(&[]).is_empty());
}
#[test]
fn test_parse_battery() {
assert_eq!(parse_battery(&[100]), Some(Telemetry::Battery { percent: 100 }));
assert_eq!(parse_battery(&[0]), Some(Telemetry::Battery { percent: 0 }));
assert_eq!(parse_battery(&[101]), None); // вне диапазона
assert_eq!(parse_battery(&[]), None);
}
#[test]
fn test_parse_csc_crank_only() {
// flags=0x02 (только шатун), revs=5, time=256
let bytes = [0x02, 0x05, 0x00, 0x00, 0x01];
assert_eq!(parse_csc(&bytes), Some(CscRaw { crank_revs: 5, crank_time: 256 }));
}
#[test]
fn test_parse_csc_with_wheel() {
// flags=0x03 (колесо+шатун), колесо 6 байт, затем revs=5, time=256
let bytes = [0x03, 0, 0, 0, 0, 0, 0, 0x05, 0x00, 0x00, 0x01];
assert_eq!(parse_csc(&bytes), Some(CscRaw { crank_revs: 5, crank_time: 256 }));
}
#[test]
fn test_parse_csc_no_crank() {
// flags=0x01 (только колесо) — данных шатуна нет
let bytes = [0x01, 0, 0, 0, 0, 0, 0];
assert_eq!(parse_csc(&bytes), None);
}
#[test]
fn test_accumulator_needs_two_samples() {
let mut acc = CscAccumulator::new();
assert_eq!(acc.push(CscRaw { crank_revs: 0, crank_time: 0 }), None);
}
#[test]
fn test_accumulator_computes_rpm() {
let mut acc = CscAccumulator::new();
acc.push(CscRaw { crank_revs: 0, crank_time: 0 });
// 1 оборот за 1024 тика (1 с) = 60 rpm
assert_eq!(acc.push(CscRaw { crank_revs: 1, crank_time: 1024 }), Some(60.0));
}
#[test]
fn test_accumulator_zero_time_returns_none() {
let mut acc = CscAccumulator::new();
acc.push(CscRaw { crank_revs: 0, crank_time: 0 });
assert_eq!(acc.push(CscRaw { crank_revs: 5, crank_time: 0 }), None);
}
}

@ -1,8 +0,0 @@
:root {
--jet: hsla(0, 0%, 21%, 1);
--gunmetal: hsla(216, 28%, 20%, 1);
--cadet-gray: hsla(212, 8%, 58%, 1);
--nyanza: hsla(114, 100%, 93%, 1);
--rojo: hsla(357, 73%, 50%, 1);
--basewhite: #f6f6f6;
}

Binary file not shown.

@ -1,20 +1,75 @@
<!doctype html>
<html lang="en">
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="stylesheet" href="styles.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri App</title>
<script type="module" src="/main.js" defer></script>
<link rel="stylesheet" href="styles.css" />
<title>ridein</title>
<script type="module" src="main.js" defer></script>
</head>
<body>
<div class="screen" id="connect">
<h1>подключение датчиков</h1>
<div class="device-selection" id="hr-monitor-selection"><h2>чсс</h2></div>
<div class="device-selection" id="cadence-sensor-selection"><h2>каденс</h2></div>
<div class="device-selection" id="trainer-selection"><h2>станок</h2></div>
<button>начать</button>
</div>
<!-- ЭКРАН ПОДКЛЮЧЕНИЯ -->
<main class="screen" id="connect">
<header class="screen-header">
<h1 class="title">ridein</h1>
<p class="subtitle">датчики</p>
</header>
<section class="devices">
<section class="device-group">
<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" 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,7 +1,243 @@
const { once } = window.__TAURI__.event;
const { once, listen } = window.__TAURI__.event;
const { invoke } = window.__TAURI__.core;
once("devices-loaded", (devices) => {
console.log("Devices are loaded", devices);
const connectScreen = document.querySelector("#connect");
const workoutScreen = document.querySelector("#workout");
const startBtn = document.querySelector("#start-btn");
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" },
};
const deviceCards = new Map(); // device_id -> li
const selected = {}; // category -> { btn, card } подключённого датчика
const pendingStatus = {}; // category -> status (пришёл до установки selected)
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text != null) node.textContent = text;
return node;
}
// --- Батарея ---
function batteryIcon(percent) {
const low = percent < 20;
const wrap = el("span", "battery" + (low ? " battery--low" : ""));
wrap.title = `${percent}%`;
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");
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 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) {
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;
}
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 (pendingStatus[category]) {
applyStatus(selected[category], category, pendingStatus[category]);
delete pendingStatus[category];
}
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;
});
console.log("hello from main.js");
// --- Телеметрия ---
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 ?? []);
});
listen("battery", (event) => {
const { device_id, percent } = event.payload;
const card = deviceCards.get(device_id);
if (!card) return;
const head = card.querySelector(".device__head");
head.querySelector(".battery")?.remove();
head.append(batteryIcon(percent));
});
listen("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);
});
function applyStatus(sel, category, status) {
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;
}
}
listen("connection", (event) => {
const { category, status } = event.payload;
const sel = selected[category];
if (!sel) {
pendingStatus[category] = status;
return;
}
applyStatus(sel, category, status);
});
// --- Таймер ---
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 powerRaw = document.querySelector("#power-input").value.trim();
const cadenceRaw = document.querySelector("#cadence-input").value.trim();
if (powerRaw === "" || cadenceRaw === "") return;
const watts = Number(powerRaw);
const rpm = Number(cadenceRaw);
if (!Number.isFinite(watts) || !Number.isFinite(rpm)) return;
if (watts < 0 || rpm < 0 || !Number.isInteger(watts) || !Number.isInteger(rpm)) return;
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,

@ -1,14 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="stylesheet" href="styles.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri App</title>
<script type="module" src="/init.js" defer></script>
</head>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="styles.css" />
<title>ridein</title>
<script type="module" src="init.js" defer></script>
</head>
<body class="screen splashscreen">
<h1>поиск перефирии</h1>
</body>
<body class="screen splashscreen">
<p class="splash-label">поиск датчиков</p>
</body>
</html>

@ -1,58 +1,369 @@
@import "./reset.css";
@import "./colors.css";
@import "./tokens.css";
:root {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
font-size: 16px;
line-height: 24px;
font-weight: 400;
/* ============================================================
База
============================================================ */
color: var(--basewhite);
background-color: var(--jet);
html,
body {
height: 100%;
}
font-synthesis: none;
text-rendering: optimizeLegibility;
body {
font-family: var(--font-sans);
font-size: var(--text-base);
line-height: var(--leading-normal);
font-weight: var(--fw-regular);
color: var(--c-text);
background-color: var(--c-bg);
overflow: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
text-rendering: optimizeLegibility;
}
body,
screen {
overflow: hidden;
/* атрибут hidden должен перебивать display: grid у .screen */
[hidden] {
display: none !important;
}
/* ============================================================
Экраны
============================================================ */
.screen {
align-content: center;
min-height: 100vh;
display: grid;
height: 100vh;
justify-content: center;
width: 100vw;
}
/* --- Сплэш --- */
.splashscreen {
place-items: center;
}
.splash-label {
color: var(--c-text-muted);
font-size: var(--text-sm);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
/* --- Подключение датчиков --- */
#connect {
gap: 2em;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
padding: 2em;
grid-template-rows: auto 1fr auto;
gap: var(--space-4);
padding: var(--space-4);
width: 100%;
max-width: var(--container-max);
margin-inline: auto;
}
.screen-header {
display: grid;
gap: var(--space-1);
}
.title {
font-size: var(--text-2xl);
font-weight: var(--fw-semibold);
line-height: var(--leading-tight);
letter-spacing: var(--tracking-tight);
}
.subtitle {
color: var(--c-text-muted);
font-size: var(--text-sm);
}
/* --- Сетка групп датчиков --- */
.devices {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
gap: var(--space-4);
align-content: start;
}
.device-group {
display: grid;
gap: var(--space-3);
align-content: start;
padding: var(--space-4);
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius-md);
}
.device-group__title {
font-size: var(--text-xs);
font-weight: var(--fw-medium);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--c-text-muted);
}
.device-list {
display: grid;
gap: var(--space-2);
}
/* --- Карточка датчика --- */
.device {
display: grid;
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);
}
.device__meta {
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);
}
#connect h1 {
grid-column: 1/-1;
.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);
color: var(--c-text-faint);
font-size: var(--text-sm);
background: transparent;
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);
font-size: var(--text-base);
font-weight: var(--fw-medium);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
padding: var(--space-3) var(--space-6);
border-radius: var(--radius-full);
cursor: pointer;
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: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;
}
#connect button {
grid-column: 1/-1;
justify-self: center;
align-self: start;
/* ============================================================
Экран упражнения
============================================================ */
#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;
}
.device-selection {
border: 1px solid var(--basewhite);
border-radius: 2%;
.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;
justify-content: center;
padding: 0.5em;
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);
}

@ -0,0 +1,73 @@
/* ============================================================
ridein — дизайн-токены (монохромная система)
Только функции, поддерживаемые WebKitGTK (webview Tauri):
HSL-цвета, CSS-переменные, grid/flex, clamp/rem. Без oklch,
color-mix(), :has() и прочего слишком свежего.
============================================================ */
@font-face {
font-family: "Inter";
src: url("./fonts/InterVariable.woff2") format("woff2");
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
:root {
/* --- Цвет: монохромная шкала (нулевая насыщенность) --- */
--c-bg: hsl(0 0% 4%); /* фон страницы */
--c-surface: hsl(0 0% 8%); /* карточка/панель */
--c-surface-2: hsl(0 0% 12%); /* приподнятый элемент */
--c-border: hsl(0 0% 18%); /* тонкая линия */
--c-border-strong: hsl(0 0% 30%);
--c-text: hsl(0 0% 93%); /* основной текст */
--c-text-muted: hsl(0 0% 58%); /* вторичный */
--c-text-faint: hsl(0 0% 40%); /* едва заметный */
--c-accent: hsl(0 0% 93%); /* акцент (инверсия) */
--c-accent-contrast: hsl(0 0% 4%);
/* --- Отступы (база 4px) --- */
--space-1: 0.25rem; /* 4px */
--space-2: 0.5rem; /* 8px */
--space-3: 0.75rem; /* 12px */
--space-4: 1rem; /* 16px */
--space-5: 1.5rem; /* 24px */
--space-6: 2rem; /* 32px */
--space-8: 3rem; /* 48px */
--space-10: 4rem; /* 64px */
/* --- Типографика --- */
--font-sans: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", monospace;
--text-xs: 0.75rem; /* 12px */
--text-sm: 0.875rem; /* 14px */
--text-base: 1rem; /* 16px */
--text-lg: 1.25rem; /* 20px */
--text-xl: 1.5rem; /* 24px */
--text-2xl: 2rem; /* 32px */
--text-3xl: 2.5rem; /* 40px */
--leading-tight: 1.1;
--leading-normal: 1.5;
--fw-regular: 400;
--fw-medium: 500;
--fw-semibold: 600;
--fw-bold: 700;
--tracking-tight: -0.02em;
--tracking-wide: 0.08em;
/* --- Радиусы --- */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-full: 9999px;
/* --- Разное --- */
--container-max: 72rem;
--ease: cubic-bezier(0.4, 0, 0.2, 1);
--duration-fast: 120ms;
--duration-base: 200ms;
}
Loading…
Cancel
Save