Рефакторинг после долгого перерыва

pull/2/head
lambda 1 week ago
parent 6020a7c777
commit 548a517391
No known key found for this signature in database
GPG Key ID: CF45A8EE158BBCB6

@ -3197,6 +3197,7 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-opener",
"thiserror 1.0.69",
"tokio",
"uuid",
]

@ -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.

@ -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<btleplug::Error> for BleError {
fn from(_: btleplug::Error) -> Self {
BleError::SystemBleError
}
/// Не удалось получить список найденных устройств.
#[error("не удалось получить список BLE-устройств: {0}")]
PeripheralsFailed(btleplug::Error),
}

@ -2,5 +2,6 @@ mod error;
mod utils;
mod scanner;
pub use error::BleError;
pub use scanner::scan_devices;
pub use utils::normalize_uuid;

@ -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<Vec<Device>, BleError> {
let target_vec: Vec<Uuid> = 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<String> = 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<String> {
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
}

@ -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<String>,
pub rssi: Option<i16>,
pub service_uuids: Vec<String>,
pub characteristics: Vec<String>,
pub manufacturer_data: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
}
impl Device {}
#[derive(Debug, PartialEq, Eq, Clone)]
pub enum DeviceType {
HrSensor,
CadSensor,
Trainer
}
pub fn get_device_types(device: &Device) -> Vec<DeviceType> {
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]);
}
}

@ -1,2 +1,111 @@
pub mod device;
pub use device::Device;
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<String>,
pub rssi: Option<i16>,
pub service_uuids: Vec<String>,
pub characteristics: Vec<String>,
pub manufacturer_data: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
}
impl Device {}
#[derive(Debug, PartialEq, Eq, Clone)]
pub enum DeviceType {
HrSensor,
CadSensor,
Trainer,
}
pub fn get_device_types(device: &Device) -> Vec<DeviceType> {
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]);
}
}

@ -1,3 +1,3 @@
mod repo;
pub use repo::repo_from_devices;
pub use repo::{repo_from_devices, Repo};

@ -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<Device>,
#[serde(default)]
trainers: Vec<Device>
trainers: Vec<Device>,
}
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());
}
}

@ -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<Repo, ble::BleError> {
let state = app.state::<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(())
}
@ -37,7 +58,7 @@ pub fn run() {
let adapter = adapters.into_iter().next()
.ok_or_else(|| Box::<dyn std::error::Error>::from("No BLE adapter found"))?;
app.manage(State { adapter: Arc::new(adapter) } );
app.manage(State { adapter: Arc::new(adapter) });
Ok::<(), Box<dyn std::error::Error>>(())
})
})

Loading…
Cancel
Save