From 548a517391b650b902321c6e3e6cd404ead85707 Mon Sep 17 00:00:00 2001 From: lambda Date: Sun, 25 Oct 2026 19:56:57 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D1=84=D0=B0=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D0=B3=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=BB=D0=B3=D0=BE=D0=B3=D0=BE=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D1=80=D1=8B=D0=B2=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/ble/error.rs | 18 +++-- src-tauri/src/ble/mod.rs | 1 + src-tauri/src/ble/scanner.rs | 118 +++++++++++++++++------------- src-tauri/src/device/device.rs | 111 ---------------------------- src-tauri/src/device/mod.rs | 113 +++++++++++++++++++++++++++- src-tauri/src/device_repo/mod.rs | 2 +- src-tauri/src/device_repo/repo.rs | 17 ++--- src-tauri/src/lib.rs | 43 ++++++++--- 10 files changed, 234 insertions(+), 191 deletions(-) delete mode 100644 src-tauri/src/device/device.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d12fe54..bf1e879 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3197,6 +3197,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-opener", + "thiserror 1.0.69", "tokio", "uuid", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 10be68a..fed8d08 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,6 +26,7 @@ btleplug = "0.11.8" tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } hex = "0.4" uuid = "1.16.0" +thiserror = "1" [profile.dev] incremental = true # Compile your binary in smaller steps. diff --git a/src-tauri/src/ble/error.rs b/src-tauri/src/ble/error.rs index 6ee1ef8..d7be963 100644 --- a/src-tauri/src/ble/error.rs +++ b/src-tauri/src/ble/error.rs @@ -1,10 +1,14 @@ -#[derive(Debug)] +use thiserror::Error; + +/// Ошибки BLE-слоя. В отличие от прежней заглушки сохраняют источник +/// (`btleplug::Error`), чтобы при отладке было видно, что именно пошло не так. +#[derive(Debug, Error)] pub enum BleError { - SystemBleError -} + /// Не удалось запустить сканирование. + #[error("не удалось запустить сканирование BLE: {0}")] + ScanFailed(btleplug::Error), -impl From for BleError { - fn from(_: btleplug::Error) -> Self { - BleError::SystemBleError - } + /// Не удалось получить список найденных устройств. + #[error("не удалось получить список BLE-устройств: {0}")] + PeripheralsFailed(btleplug::Error), } diff --git a/src-tauri/src/ble/mod.rs b/src-tauri/src/ble/mod.rs index 3edc455..ad8f3fa 100644 --- a/src-tauri/src/ble/mod.rs +++ b/src-tauri/src/ble/mod.rs @@ -2,5 +2,6 @@ mod error; mod utils; mod scanner; +pub use error::BleError; pub use scanner::scan_devices; pub use utils::normalize_uuid; diff --git a/src-tauri/src/ble/scanner.rs b/src-tauri/src/ble/scanner.rs index c613da9..ad8309f 100644 --- a/src-tauri/src/ble/scanner.rs +++ b/src-tauri/src/ble/scanner.rs @@ -4,75 +4,95 @@ use std::time::Duration; use tokio::time; use crate::ble::error::BleError; use crate::device::Device; -use hex; -use uuid::{Uuid}; +use uuid::Uuid; +/// Сервисы, по которым ищем датчики. Только целевые (HR / CSC / FTMS / мощность). +/// Battery (0x180F) и Device Info (0x180A) убраны: их рекламирует почти любое +/// BLE-устройство, из-за чего скан подхватывал много лишнего и подключался к нему. static TARGET_UUIDS: &[Uuid] = &[ uuid_from_u16(0x180D), // Heart Rate uuid_from_u16(0x1816), // Cycling Speed and Cadence uuid_from_u16(0x1826), // Fitness Machine (FTMS) - uuid_from_u16(0x180F), // Battery Level - uuid_from_u16(0x180A), // Device info - uuid_from_u16(0x1818), // Power meter + uuid_from_u16(0x1818), // Cycling Power ]; +const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +const DISCOVER_TIMEOUT: Duration = Duration::from_secs(5); +const DISCONNECT_TIMEOUT: Duration = Duration::from_secs(2); + pub async fn scan_devices(adapter: &Adapter, scan_seconds: u64) -> Result, BleError> { - let target_vec: Vec = TARGET_UUIDS.to_vec(); + adapter + .start_scan(ScanFilter { services: TARGET_UUIDS.to_vec() }) + .await + .map_err(BleError::ScanFailed)?; - adapter.start_scan(ScanFilter { services: target_vec.clone() }).await?; time::sleep(Duration::from_secs(scan_seconds)).await; + let peripherals = adapter.peripherals().await.map_err(BleError::PeripheralsFailed)?; + let mut devices = Vec::new(); + for peripheral in peripherals { + // Свойства берём из рекламы; если устройство уже пропало — пропускаем. + let Some(props) = peripheral.properties().await.unwrap_or(None) else { + continue; + }; - for peripheral in adapter.peripherals().await? { - if let Some(props) = peripheral.properties().await? { - let svc_match = props.services.iter().any(|s| TARGET_UUIDS.iter().any(|t| t == s)); - if !svc_match { continue; } + let svc_match = props.services.iter().any(|s| TARGET_UUIDS.contains(s)); + if !svc_match { + continue; + } - let manuf = props.manufacturer_data.iter().next().map(|(_,v)| hex::encode(v)); + // Для классификации по типу нужны характеристики — подключаемся, читаем, + // отключаемся (best-effort: при сбое устройство всё равно попадёт в список, + // просто без характеристик). + let characteristics = discover_characteristics(&peripheral).await; - let mut characteristics: Vec = Vec::new(); + let manuf = props.manufacturer_data.iter().next().map(|(_, v)| hex::encode(v)); - let is_connected = peripheral.is_connected().await.unwrap_or(false); - if !is_connected { - let _ = tokio::time::timeout(Duration::from_secs(5), peripheral.connect()).await; - } + devices.push(Device { + id: peripheral.id().to_string(), + address: props.address.to_string(), + display_name: props.local_name, + rssi: props.rssi, + service_uuids: props.services.iter().map(|u| u.to_string()).collect(), + manufacturer_data: manuf, + metadata: None, + characteristics, + }); + } - let _ = tokio::time::timeout(Duration::from_secs(5), peripheral.discover_services()).await; - - let chars = peripheral.characteristics(); - if !chars.is_empty() { - for ch in chars.iter() { - let entry = format!("{}::{}", ch.service_uuid, ch.uuid); - characteristics.push(entry); - } - } else { - for svc in peripheral.services().iter() { - for ch in svc.characteristics.iter() { - let entry = format!("{}::{}", svc.uuid, ch.uuid); - characteristics.push(entry); - } - } - } + adapter.stop_scan().await.ok(); + Ok(devices) +} - if !is_connected { - let _ = tokio::time::timeout(Duration::from_secs(2), peripheral.disconnect()).await; - } +/// Подключается к устройству, читает характеристики и отключается. +/// Возвращает список характеристик в формате «service::characteristic». +/// Best-effort: неудачи connect/discover игнорируются. +async fn discover_characteristics(peripheral: &impl Peripheral) -> Vec { + let was_connected = peripheral.is_connected().await.unwrap_or(false); + + if !was_connected { + let _ = time::timeout(CONNECT_TIMEOUT, peripheral.connect()).await; + } + let _ = time::timeout(DISCOVER_TIMEOUT, peripheral.discover_services()).await; - devices.push(Device { - id: peripheral.id().to_string(), - address: props.address.to_string(), - display_name: props.local_name, - rssi: props.rssi, - service_uuids: props.services.iter().map(|u| u.to_string()).collect(), - manufacturer_data: manuf, - metadata: None, - characteristics, - }); + let mut characteristics = Vec::new(); + let chars = peripheral.characteristics(); + if !chars.is_empty() { + for ch in chars.iter() { + characteristics.push(format!("{}::{}", ch.service_uuid, ch.uuid)); + } + } else { + for svc in peripheral.services().iter() { + for ch in svc.characteristics.iter() { + characteristics.push(format!("{}::{}", svc.uuid, ch.uuid)); + } } } - adapter.stop_scan().await.ok(); - println!("{:?}", devices); - Ok(devices) + if !was_connected { + let _ = time::timeout(DISCONNECT_TIMEOUT, peripheral.disconnect()).await; + } + + characteristics } diff --git a/src-tauri/src/device/device.rs b/src-tauri/src/device/device.rs deleted file mode 100644 index 24051a8..0000000 --- a/src-tauri/src/device/device.rs +++ /dev/null @@ -1,111 +0,0 @@ -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -use crate::ble::normalize_uuid; - -#[derive(Debug, Serialize, Clone, Deserialize)] -pub struct Device { - pub id: String, - pub address: String, - pub display_name: Option, - pub rssi: Option, - pub service_uuids: Vec, - pub characteristics: Vec, - pub manufacturer_data: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub metadata: Option, -} - -impl Device {} - -#[derive(Debug, PartialEq, Eq, Clone)] -pub enum DeviceType { - HrSensor, - CadSensor, - Trainer -} - -pub fn get_device_types(device: &Device) -> Vec { - let hr_services = ["180d"]; - let hr_characteristics = ["2a37"]; - - let csc_services = ["1816"]; - let csc_characteristics = ["2a5b"]; - - // trainer / cycling power / fitness machine / indoor bike - let trainer_services = ["1818", "1826"]; - let trainer_characteristics = ["2a63", "2ad2", "2acc"]; // 2AD2 = Indoor Bike Data, 2ACC = Fitness Machine Characteristics, 2A63 = Cycling Power Measurement - - // любые 2ADx характеристики (indoor bike related) — учитывать как признак каденса доступного в тренажёре - let indoor_bike_chars_prefix = "2ad"; // covers 2ad2,2ad5,2ad6,2ad8,2ad9,2ada etc. - - // Нормализуем все UUIDs - let mut norms = Vec::with_capacity(device.service_uuids.len() + device.characteristics.len()); - for u in device.service_uuids.iter().chain(device.characteristics.iter()) { - // Для характеристик, которые приходят в виде "service::characteristic", извлечём часть после "::" - let part = if let Some(idx) = u.find("::") { - &u[idx + 2..] - } else { - u.as_str() - }; - norms.push(normalize_uuid(part)); - } - - let contains_any = |candidates: &[&str]| -> bool { - for cand in candidates { - let cand = cand.to_ascii_lowercase(); - if norms.iter().any(|n| n == &cand || n.ends_with(&cand)) { - return true; - } - } - false - }; - - let mut out = Vec::new(); - - // Heart rate - if contains_any(&hr_services) || contains_any(&hr_characteristics) { - out.push(DeviceType::HrSensor); - } - - // CSC / Cadence - if contains_any(&csc_services) || contains_any(&csc_characteristics) { - out.push(DeviceType::CadSensor); - } - - // Trainer/Cycling power/Indoor bike - let is_trainer = contains_any(&trainer_services) || contains_any(&trainer_characteristics); - if is_trainer { - out.push(DeviceType::Trainer); - } - - // Доп. логика: если найдены Indoor Bike Data (любые 2ADx characteristics), считаем, что устройство может отдавать cadence — - // добавляем CadSensor, даже если CSC (0x1816/0x2A5B) отсутствует. - let has_indoor_bike_char = norms.iter().any(|n| n.starts_with(indoor_bike_chars_prefix)); - if has_indoor_bike_char && !out.contains(&DeviceType::CadSensor) { - out.push(DeviceType::CadSensor); - } - - out -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_service_types_for_magene_speedcadence() { - let magene = Device { - id: "hci0/dev_F1_F2_7C_77_14_BE".into(), - address: "F1:F2:7C:77:14:BE".into(), - display_name: Some("10911-17".into()), - rssi: Some(-42), - service_uuids: vec!["00001816-0000-1000-8000-00805f9b34fb".into()], - characteristics: vec![], - manufacturer_data: Some("6b009f2a110300030072".into()), - metadata: None, - }; - - assert_eq!(get_device_types(&magene), [DeviceType::CadSensor]); - } -} diff --git a/src-tauri/src/device/mod.rs b/src-tauri/src/device/mod.rs index 4e30d8a..631f103 100644 --- a/src-tauri/src/device/mod.rs +++ b/src-tauri/src/device/mod.rs @@ -1,2 +1,111 @@ -pub mod device; -pub use device::Device; \ No newline at end of file +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::ble::normalize_uuid; + +#[derive(Debug, Serialize, Clone, Deserialize)] +pub struct Device { + pub id: String, + pub address: String, + pub display_name: Option, + pub rssi: Option, + pub service_uuids: Vec, + pub characteristics: Vec, + pub manufacturer_data: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, +} + +impl Device {} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum DeviceType { + HrSensor, + CadSensor, + Trainer, +} + +pub fn get_device_types(device: &Device) -> Vec { + let hr_services = ["180d"]; + let hr_characteristics = ["2a37"]; + + let csc_services = ["1816"]; + let csc_characteristics = ["2a5b"]; + + // trainer / cycling power / fitness machine / indoor bike + let trainer_services = ["1818", "1826"]; + let trainer_characteristics = ["2a63", "2ad2", "2acc"]; // 2AD2 = Indoor Bike Data, 2ACC = Fitness Machine Characteristics, 2A63 = Cycling Power Measurement + + // любые 2ADx характеристики (indoor bike related) — учитывать как признак каденса доступного в тренажёре + let indoor_bike_chars_prefix = "2ad"; // covers 2ad2,2ad5,2ad6,2ad8,2ad9,2ada etc. + + // Нормализуем все UUIDs + let mut norms = Vec::with_capacity(device.service_uuids.len() + device.characteristics.len()); + for u in device.service_uuids.iter().chain(device.characteristics.iter()) { + // Для характеристик, которые приходят в виде "service::characteristic", извлечём часть после "::" + let part = if let Some(idx) = u.find("::") { + &u[idx + 2..] + } else { + u.as_str() + }; + norms.push(normalize_uuid(part)); + } + + let contains_any = |candidates: &[&str]| -> bool { + for cand in candidates { + let cand = cand.to_ascii_lowercase(); + if norms.iter().any(|n| n == &cand || n.ends_with(&cand)) { + return true; + } + } + false + }; + + let mut out = Vec::new(); + + // Heart rate + if contains_any(&hr_services) || contains_any(&hr_characteristics) { + out.push(DeviceType::HrSensor); + } + + // CSC / Cadence + if contains_any(&csc_services) || contains_any(&csc_characteristics) { + out.push(DeviceType::CadSensor); + } + + // Trainer/Cycling power/Indoor bike + let is_trainer = contains_any(&trainer_services) || contains_any(&trainer_characteristics); + if is_trainer { + out.push(DeviceType::Trainer); + } + + // Доп. логика: если найдены Indoor Bike Data (любые 2ADx characteristics), считаем, что устройство может отдавать cadence — + // добавляем CadSensor, даже если CSC (0x1816/0x2A5B) отсутствует. + let has_indoor_bike_char = norms.iter().any(|n| n.starts_with(indoor_bike_chars_prefix)); + if has_indoor_bike_char && !out.contains(&DeviceType::CadSensor) { + out.push(DeviceType::CadSensor); + } + + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_service_types_for_magene_speedcadence() { + let magene = Device { + id: "hci0/dev_F1_F2_7C_77_14_BE".into(), + address: "F1:F2:7C:77:14:BE".into(), + display_name: Some("10911-17".into()), + rssi: Some(-42), + service_uuids: vec!["00001816-0000-1000-8000-00805f9b34fb".into()], + characteristics: vec![], + manufacturer_data: Some("6b009f2a110300030072".into()), + metadata: None, + }; + + assert_eq!(get_device_types(&magene), [DeviceType::CadSensor]); + } +} diff --git a/src-tauri/src/device_repo/mod.rs b/src-tauri/src/device_repo/mod.rs index 66d6016..d2a027e 100644 --- a/src-tauri/src/device_repo/mod.rs +++ b/src-tauri/src/device_repo/mod.rs @@ -1,3 +1,3 @@ mod repo; -pub use repo::repo_from_devices; +pub use repo::{repo_from_devices, Repo}; diff --git a/src-tauri/src/device_repo/repo.rs b/src-tauri/src/device_repo/repo.rs index 1c6848f..096bd34 100644 --- a/src-tauri/src/device_repo/repo.rs +++ b/src-tauri/src/device_repo/repo.rs @@ -1,5 +1,5 @@ use serde::{Deserialize, Serialize}; -use crate::{device::{device::{get_device_types, DeviceType}, Device}}; +use crate::device::{Device, DeviceType, get_device_types}; #[derive(Debug, Serialize, Deserialize, Clone)] pub struct Repo { @@ -8,7 +8,7 @@ pub struct Repo { #[serde(default)] cad_sensors: Vec, #[serde(default)] - trainers: Vec + trainers: Vec, } impl Repo { @@ -61,7 +61,7 @@ mod tests { use super::*; #[test] - fn test_service_types_for_magene_speedcadence() { + fn test_repo_classifies_magene_as_cadence_sensor() { let magene = Device { id: "hci0/dev_F1_F2_7C_77_14_BE".into(), address: "F1:F2:7C:77:14:BE".into(), @@ -73,13 +73,10 @@ mod tests { metadata: None, }; - let devices = vec![magene]; - let x = repo_from_devices(devices); + let repo = repo_from_devices(vec![magene]); - println!("{:?}", x); - - //assert_eq!(repo_from_devices(devices), Repo::new()); - - assert_eq!(2, 3); + assert_eq!(repo.cad_sensors.len(), 1); + assert!(repo.hr_sensors.is_empty()); + assert!(repo.trainers.is_empty()); } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e0f4e2e..42cee58 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,24 +5,45 @@ mod state; use std::sync::Arc; use tauri::{AppHandle, Emitter, Manager, async_runtime}; -use crate::{ble::scan_devices, device_repo::{repo_from_devices}}; +use crate::{ble::scan_devices, device_repo::{repo_from_devices, Repo}}; use crate::state::State; -use btleplug::api::{Manager as ApiManager}; +use btleplug::api::Manager as ApiManager; use btleplug::platform::Manager as BleManager; +/// Сколько секунд сканируем BLE на старте. +const SCAN_SECONDS: u64 = 5; + #[tauri::command] -async fn init(app: AppHandle) -> Result<(),()> { +async fn init(app: AppHandle) -> Result<(), String> { println!("init rideinn app"); - let splash = app.get_webview_window("splashscreen").unwrap(); - let main = app.get_webview_window("main").unwrap(); + + 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 { let state = app.state::(); let adapter = state.adapter.clone(); - let repo = repo_from_devices((scan_devices(&adapter, 5).await).unwrap_or_default()); + let devices = scan_devices(&adapter, SCAN_SECONDS).await?; + Ok(repo_from_devices(devices)) +} - splash.close().unwrap(); - main.show().unwrap(); +/// Этап 2: закрываем сплэш, показываем главное окно, отдаём список датчиков на фронт. +fn present_repo(app: &AppHandle, repo: Repo) -> Result<(), String> { + let splash = app + .get_webview_window("splashscreen") + .ok_or("окно splashscreen не найдено")?; + let main = app + .get_webview_window("main") + .ok_or("окно main не найдено")?; - app.emit("devices-loaded", repo).unwrap(); + splash.close().map_err(|e| format!("не удалось закрыть splashscreen: {e}"))?; + main.show().map_err(|e| format!("не удалось показать main: {e}"))?; + app.emit("devices-loaded", repo) + .map_err(|e| format!("не удалось отправить событие devices-loaded: {e}"))?; Ok(()) } @@ -36,8 +57,8 @@ pub fn run() { let adapters = manager.adapters().await?; let adapter = adapters.into_iter().next() .ok_or_else(|| Box::::from("No BLE adapter found"))?; - - app.manage(State { adapter: Arc::new(adapter) } ); + + app.manage(State { adapter: Arc::new(adapter) }); Ok::<(), Box>(()) }) })