commit
94cfe8f102
@ -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 error;
|
||||||
mod utils;
|
mod utils;
|
||||||
mod scanner;
|
mod scanner;
|
||||||
|
mod source;
|
||||||
|
mod mock;
|
||||||
|
|
||||||
pub use error::BleError;
|
pub use error::BleError;
|
||||||
pub use scanner::scan_devices;
|
pub use scanner::scan_devices;
|
||||||
pub use utils::normalize_uuid;
|
pub use utils::normalize_uuid;
|
||||||
|
pub(crate) use source::{BleSource, SensorSource};
|
||||||
|
pub(crate) use mock::MockSource;
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="stylesheet" href="styles.css" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Tauri App</title>
|
<link rel="stylesheet" href="styles.css" />
|
||||||
<script type="module" src="/main.js" defer></script>
|
<title>ridein</title>
|
||||||
|
<script type="module" src="main.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="screen" id="connect">
|
<!-- ЭКРАН ПОДКЛЮЧЕНИЯ -->
|
||||||
<h1>подключение датчиков</h1>
|
<main class="screen" id="connect">
|
||||||
<div class="device-selection" id="hr-monitor-selection"><h2>чсс</h2></div>
|
<header class="screen-header">
|
||||||
<div class="device-selection" id="cadence-sensor-selection"><h2>каденс</h2></div>
|
<h1 class="title">ridein</h1>
|
||||||
<div class="device-selection" id="trainer-selection"><h2>станок</h2></div>
|
<p class="subtitle">датчики</p>
|
||||||
<button>начать</button>
|
</header>
|
||||||
</div>
|
|
||||||
|
<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>
|
</body>
|
||||||
</html>
|
</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) => {
|
const connectScreen = document.querySelector("#connect");
|
||||||
console.log("Devices are loaded", devices);
|
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,14 +1,14 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="stylesheet" href="styles.css" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<link rel="stylesheet" href="styles.css" />
|
||||||
<title>Tauri App</title>
|
<title>ridein</title>
|
||||||
<script type="module" src="/init.js" defer></script>
|
<script type="module" src="init.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="screen splashscreen">
|
<body class="screen splashscreen">
|
||||||
<h1>поиск перефирии</h1>
|
<p class="splash-label">поиск датчиков</p>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Loading…
Reference in new issue