Compare commits

..

No commits in common. 'd8049e89c317cff6a1eb73537a10d43fcf1189d3' and 'b187811649fe69101ae22914fc69862d1f54c12a' have entirely different histories.

58
src-tauri/Cargo.lock generated

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

@ -27,8 +27,6 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
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.

@ -11,28 +11,4 @@ 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),
}

@ -1,126 +0,0 @@
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(())
}
}
#[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,11 +1,7 @@
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;

@ -1,124 +0,0 @@
use std::pin::Pin;
use async_trait::async_trait;
use btleplug::api::{bleuuid::uuid_from_u16, Central, Peripheral as _, ValueNotification, WriteType};
use btleplug::platform::{Adapter, Peripheral};
use futures::{future, Stream, StreamExt};
use uuid::Uuid;
use crate::ble::BleError;
use crate::commands::{encode_command, Command};
use crate::device::Device;
use crate::telemetry::{parse_csc, parse_hr, 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>;
}
const HR_MEASUREMENT: Uuid = uuid_from_u16(0x2A37);
const CSC_MEASUREMENT: Uuid = uuid_from_u16(0x2A5B);
const POWER_MEASUREMENT: Uuid = uuid_from_u16(0x2A63);
/// Реальный источник данных через btleplug.
pub(crate) struct BleSource {
adapter: Adapter,
}
impl BleSource {
pub(crate) fn new(adapter: Adapter) -> Self {
Self { adapter }
}
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()))
}
}
#[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];
for ch in peripheral.characteristics().iter() {
if targets.contains(&ch.uuid) {
peripheral.subscribe(ch).await.map_err(BleError::SubscribeFailed)?;
}
}
let notifications = peripheral
.notifications()
.await
.map_err(BleError::NotificationsFailed)?;
let mut acc = CscAccumulator::new();
let stream = notifications.filter_map(move |n| future::ready(parse_notification(n, &mut acc)));
Ok(Box::pin(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 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) -> Option<Telemetry> {
if n.uuid == HR_MEASUREMENT {
parse_hr(&n.value)
} else if n.uuid == CSC_MEASUREMENT {
parse_csc(&n.value)
.and_then(|raw| acc.push(raw))
.map(|rpm| Telemetry::Cadence { rpm })
} else if n.uuid == POWER_MEASUREMENT {
parse_power(&n.value)
} else {
None
}
}

@ -7,36 +7,3 @@ 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"
);
}
}

@ -1,110 +0,0 @@
#[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
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::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)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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_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,23 +93,6 @@ 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 {
@ -125,101 +108,4 @@ 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,20 +60,6 @@ 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 {
@ -93,42 +79,4 @@ 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,15 +2,10 @@ mod device;
mod ble;
mod device_repo;
mod state;
mod telemetry;
mod commands;
use std::sync::Arc;
use tauri::{AppHandle, Emitter, Manager, async_runtime};
use crate::ble::{BleSource, MockSource, SensorSource};
use crate::device_repo::{repo_from_devices, Repo};
use crate::{ble::scan_devices, device_repo::{repo_from_devices, Repo}};
use crate::state::State;
use btleplug::api::Manager as ApiManager;
use btleplug::platform::Manager as BleManager;
@ -20,16 +15,19 @@ const SCAN_SECONDS: u64 = 5;
#[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 source = state.source.clone();
let devices = source.scan(SCAN_SECONDS).await?;
let adapter = state.adapter.clone();
let devices = scan_devices(&adapter, SCAN_SECONDS).await?;
Ok(repo_from_devices(devices))
}
@ -55,17 +53,12 @@ pub fn run() {
tauri::Builder::default()
.setup(|app| {
async_runtime::block_on(async move {
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 { source });
app.manage(State { adapter: Arc::new(adapter) });
Ok::<(), Box<dyn std::error::Error>>(())
})
})

@ -1,7 +1,6 @@
use btleplug::platform::Adapter;
use std::sync::Arc;
use crate::ble::SensorSource;
pub struct State {
pub source: Arc<dyn SensorSource>,
pub adapter: Arc<Adapter>
}

@ -1,184 +0,0 @@
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 })
}
/// 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_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);
}
}
Loading…
Cancel
Save