@ -0,0 +1,55 @@
|
||||
# dependency directories
|
||||
node_modules/
|
||||
|
||||
# Optional npm and yarn cache directory
|
||||
.npm/
|
||||
.yarn/
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
|
||||
# .vscode workspace settings file
|
||||
.vscode/settings.json
|
||||
.vscode/launch.json
|
||||
.vscode/tasks.json
|
||||
|
||||
# npm, yarn and bun lock files
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
bun.lockb
|
||||
|
||||
# rust compiled folders
|
||||
target/
|
||||
|
||||
# test video for streaming example
|
||||
streaming_example_test_video.mp4
|
||||
|
||||
# examples /gen directory
|
||||
/examples/**/src-tauri/gen/
|
||||
/bench/**/src-tauri/gen/
|
||||
|
||||
# logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# miscellaneous
|
||||
/.vs
|
||||
.DS_Store
|
||||
.Thumbs.db
|
||||
*.sublime*
|
||||
.idea
|
||||
debug.log
|
||||
TODO.md
|
||||
.aider*
|
||||
@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<title>Ridein</title>
|
||||
</head>
|
||||
<body>
|
||||
<button id="start">start</button>
|
||||
<button id="hr">hr</button>
|
||||
<script type="module" src="js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,48 +0,0 @@
|
||||
const GATT = Object.freeze({
|
||||
Battery: Object.freeze({
|
||||
service: 0x180f,
|
||||
level: "0x2A19", // Battery Level (Read / Notify)
|
||||
}),
|
||||
|
||||
HeartRate: Object.freeze({
|
||||
service: 0x180d,
|
||||
measurement: "0x2A37", // Heart Rate Measurement (Notify)
|
||||
bodySensorLocation: "0x2A38", // Body Sensor Location (Read)
|
||||
controlPoint: "0x2A39", // Heart Rate Control Point (Write)
|
||||
}),
|
||||
|
||||
Cadence: Object.freeze({
|
||||
service: "0x1816",
|
||||
measurement: "0x2A5B", // CSC Measurement (Notify)
|
||||
feature: "0x2A5C", // CSC Feature (Read)
|
||||
location: "0x2A5D", // Sensor Location (Read)
|
||||
}),
|
||||
|
||||
PowerMeter: Object.freeze({
|
||||
service: "0x1818",
|
||||
measurement: "0x2A63", // Cycling Power Measurement (Notify)
|
||||
feature: "0x2A64", // Cycling Power Feature (Read)
|
||||
controlPoint: "0x2A65", // Cycling Power Control Point (Write)
|
||||
location: "0x2A66", // Sensor Location (Read)
|
||||
}),
|
||||
|
||||
SmartTrainer: Object.freeze({
|
||||
service: "0x1826", // Fitness Machine Service
|
||||
status: "0x2AD9", // Fitness Machine Status (Notify)
|
||||
controlPoint: "0x2ACC", // Fitness Machine Control Point (Write/Indicate)
|
||||
feature: "0x2ADA", // Fitness Machine Feature (Read)
|
||||
indoorBikeData: "0x2ADB", // Indoor Bike Data (Notify)
|
||||
trainingStatus: "0x2ADC", // Training Status (Notify)
|
||||
supportedResistance: "0x2ADD", // Supported Resistance Level (Read)
|
||||
}),
|
||||
|
||||
DeviceInfo: Object.freeze({
|
||||
service: 0x180a, // Device Information Service
|
||||
manufacturerName: "0x2A29", // Manufacturer Name String (Read)
|
||||
modelNumber: "0x2A24", // Model Number String (Read)
|
||||
serialNumber: "0x2A25", // Serial Number String (Read)
|
||||
firmwareRevision: "0x2A26", // Firmware Revision String (Read)
|
||||
}),
|
||||
});
|
||||
|
||||
export { GATT };
|
||||
@ -1,17 +0,0 @@
|
||||
import { GATT } from "./gatt.js";
|
||||
|
||||
const hrSensorOptions = {
|
||||
filters: [{ services: [GATT.HeartRate.service] }],
|
||||
optionalServices: [GATT.DeviceInfo.service, GATT.Battery.service],
|
||||
};
|
||||
|
||||
const requestHRSensor = async () => {
|
||||
try {
|
||||
return await navigator.bluetooth.requestDevice(hrSensorOptions);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export { requestHRSensor };
|
||||
@ -1,15 +0,0 @@
|
||||
const CadenceQuality = Object.freeze({
|
||||
HIGH: "high",
|
||||
MEDIUM: "medium",
|
||||
LOW: "low",
|
||||
UNKNOWN: "unknown",
|
||||
});
|
||||
|
||||
function validateCadenceQuality(v) {
|
||||
const set = CadenceQuality;
|
||||
if (!v) return set.UNKNOWN;
|
||||
const vals = Object.values(set);
|
||||
return vals.includes(v) ? v : set.UNKNOWN;
|
||||
}
|
||||
|
||||
export { validateCadenceQuality, CadenceQuality };
|
||||
@ -1,34 +0,0 @@
|
||||
import { deepFreeze, isFiniteNumber } from "../../utils/index.js";
|
||||
import { CyclingPowerCapabilities } from "./cyclingPower.js";
|
||||
|
||||
export class Control {
|
||||
constructor({
|
||||
supportsERG = false,
|
||||
controlCharacteristic,
|
||||
maxPowerWatts,
|
||||
minPowerWatts,
|
||||
ergRampRateWattsPerSec,
|
||||
cyclingPower,
|
||||
} = {}) {
|
||||
this.supportsERG = Boolean(supportsERG);
|
||||
this.controlCharacteristic = controlCharacteristic;
|
||||
this.maxPowerWatts = isFiniteNumber(maxPowerWatts)
|
||||
? Number(maxPowerWatts)
|
||||
: undefined;
|
||||
this.minPowerWatts = isFiniteNumber(minPowerWatts)
|
||||
? Number(minPowerWatts)
|
||||
: undefined;
|
||||
this.ergRampRateWattsPerSec = isFiniteNumber(ergRampRateWattsPerSec)
|
||||
? Number(ergRampRateWattsPerSec)
|
||||
: undefined;
|
||||
this.cyclingPower =
|
||||
cyclingPower instanceof CyclingPowerCapabilities
|
||||
? cyclingPower
|
||||
: cyclingPower
|
||||
? new CyclingPowerCapabilities(cyclingPower)
|
||||
: undefined;
|
||||
deepFreeze(this);
|
||||
}
|
||||
|
||||
merge = (partial) => new Control(Object.assign({}, this, partial));
|
||||
}
|
||||
@ -1,35 +0,0 @@
|
||||
import { deepFreeze } from "../../utils/index.js";
|
||||
|
||||
export class Core {
|
||||
constructor({
|
||||
supportsBattery = false,
|
||||
supportsManufacturerData = false,
|
||||
gattServices,
|
||||
gattCharacteristics,
|
||||
manufacturer,
|
||||
model,
|
||||
firmwareVersion,
|
||||
serial,
|
||||
reconnectOnDisconnect = true,
|
||||
requiresPairing = false,
|
||||
} = {}) {
|
||||
this.supportsBattery = Boolean(supportsBattery);
|
||||
this.supportsManufacturerData = Boolean(supportsManufacturerData);
|
||||
this.gattServices = Array.isArray(gattServices)
|
||||
? gattServices.slice()
|
||||
: undefined;
|
||||
this.gattCharacteristics =
|
||||
gattCharacteristics && typeof gattCharacteristics === "object"
|
||||
? Object.assign({}, gattCharacteristics)
|
||||
: undefined;
|
||||
this.manufacturer = manufacturer;
|
||||
this.model = model;
|
||||
this.firmwareVersion = firmwareVersion;
|
||||
this.serial = serial;
|
||||
this.reconnectOnDisconnect = Boolean(reconnectOnDisconnect);
|
||||
this.requiresPairing = Boolean(requiresPairing);
|
||||
deepFreeze(this);
|
||||
}
|
||||
|
||||
merge = (partial) => new Core(Object.assign({}, this, partial));
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
import { Control } from "./control.js";
|
||||
import { Sensors } from "./sensors.js";
|
||||
import { Core } from "./core.js";
|
||||
|
||||
export { Control, Sensors, Core };
|
||||
@ -1,31 +0,0 @@
|
||||
import { deepFreeze, isFiniteNumber } from "../../utils/index.js";
|
||||
import { validateCadenceQuality } from "../cadence.js";
|
||||
|
||||
export class Sensors {
|
||||
constructor({
|
||||
supportsHeartRate = false,
|
||||
supportsCadence = false,
|
||||
supportsPower = false,
|
||||
hrSamplingHz,
|
||||
cadenceSamplingHz,
|
||||
powerSamplingHz,
|
||||
cadenceSourceQuality,
|
||||
} = {}) {
|
||||
this.supportsHeartRate = Boolean(supportsHeartRate);
|
||||
this.supportsCadence = Boolean(supportsCadence);
|
||||
this.supportsPower = Boolean(supportsPower);
|
||||
this.hrSamplingHz = isFiniteNumber(hrSamplingHz)
|
||||
? Number(hrSamplingHz)
|
||||
: undefined;
|
||||
this.cadenceSamplingHz = isFiniteNumber(cadenceSamplingHz)
|
||||
? Number(cadenceSamplingHz)
|
||||
: undefined;
|
||||
this.powerSamplingHz = isFiniteNumber(powerSamplingHz)
|
||||
? Number(powerSamplingHz)
|
||||
: undefined;
|
||||
this.cadenceSourceQuality = validateCadenceQuality(cadenceSourceQuality);
|
||||
deepFreeze(this);
|
||||
}
|
||||
|
||||
merge = (partial) => new Sensors(Object.assign({}, this, partial));
|
||||
}
|
||||
@ -1,68 +0,0 @@
|
||||
import { Control, Sensors, Core } from "./capabilities/index.js";
|
||||
import { deepFreeze } from "../utils/index.js";
|
||||
|
||||
const DeviceType = Object.freeze({
|
||||
HEART_RATE: "heart_rate",
|
||||
CADENCE: "cadence",
|
||||
TRAINER: "trainer",
|
||||
UNKNOWN: "unknown",
|
||||
});
|
||||
|
||||
class Device {
|
||||
constructor({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
core,
|
||||
sensors,
|
||||
control,
|
||||
connected = false,
|
||||
lastSeen,
|
||||
} = {}) {
|
||||
if (!id) throw new Error("Device requires id");
|
||||
this.id = id;
|
||||
this.name = name || id;
|
||||
this.type = type;
|
||||
this.core = core instanceof Core ? core : core ? new Core(core) : undefined;
|
||||
this.sensors =
|
||||
sensors instanceof Sensors
|
||||
? sensors
|
||||
: sensors
|
||||
? new Sensors(sensors)
|
||||
: undefined;
|
||||
this.control =
|
||||
control instanceof Control
|
||||
? control
|
||||
: control
|
||||
? new Control(control)
|
||||
: undefined;
|
||||
this.connected = Boolean(connected);
|
||||
this.lastSeen = lastSeen;
|
||||
deepFreeze(this);
|
||||
}
|
||||
|
||||
withCore = (partial) =>
|
||||
new Device(
|
||||
Object.assign({}, this, {
|
||||
core: this.core ? this.core.merge(partial) : new Core(partial),
|
||||
}),
|
||||
);
|
||||
withSensors = (partial) =>
|
||||
new Device(
|
||||
Object.assign({}, this, {
|
||||
sensors: this.sensors
|
||||
? this.sensors.merge(partial)
|
||||
: new Sensors(partial),
|
||||
}),
|
||||
);
|
||||
withControl = (partial) =>
|
||||
new Device(
|
||||
Object.assign({}, this, {
|
||||
control: this.control
|
||||
? this.control.merge(partial)
|
||||
: new Control(partial),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export { Device, Core };
|
||||
@ -1,5 +0,0 @@
|
||||
const connectHRSensor = () => {};
|
||||
const connectCadenceSensor = () => {};
|
||||
const connectTrainer = () => {};
|
||||
|
||||
export { connectHRSensor, connectCadenceSensor, connectTrainer };
|
||||
@ -1,14 +0,0 @@
|
||||
const deepFreeze = (obj) => {
|
||||
if (obj && typeof obj === "object" && !Object.isFrozen(obj)) {
|
||||
Object.getOwnPropertyNames(obj).forEach((prop) => {
|
||||
const value = obj[prop];
|
||||
if (value && typeof value === "object") deepFreeze(value);
|
||||
});
|
||||
Object.freeze(obj);
|
||||
}
|
||||
return obj;
|
||||
};
|
||||
|
||||
const isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v);
|
||||
|
||||
export { deepFreeze, isFiniteNumber };
|
||||
@ -1,2 +1,9 @@
|
||||
Minimalistic web application for running
|
||||
zwo workouts in browser.
|
||||
Minimalistic web application for running zwo workouts.
|
||||
|
||||
# Tauri + Vanilla
|
||||
|
||||
This template should help get you started developing with Tauri in vanilla HTML, CSS and Javascript.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
@ -0,0 +1,38 @@
|
||||
[package]
|
||||
name = "rideinn"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "rideinn_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
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"] }
|
||||
hex = "0.4"
|
||||
uuid = "1.16.0"
|
||||
|
||||
[profile.dev]
|
||||
incremental = true # Compile your binary in smaller steps.
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1 # Allows LLVM to perform better optimization.
|
||||
lto = true # Enables link-time-optimizations.
|
||||
opt-level = "s" # Prioritizes small binary size. Use `3` if you prefer speed.
|
||||
panic = "abort" # Higher performance by disabling panic handlers.
|
||||
strip = true # Ensures debug symbols are removed.
|
||||
@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 974 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 903 B |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 14 KiB |
@ -0,0 +1,10 @@
|
||||
#[derive(Debug)]
|
||||
pub enum BleError {
|
||||
SystemBleError
|
||||
}
|
||||
|
||||
impl From<btleplug::Error> for BleError {
|
||||
fn from(_: btleplug::Error) -> Self {
|
||||
BleError::SystemBleError
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
mod error;
|
||||
mod utils;
|
||||
mod scanner;
|
||||
|
||||
pub use scanner::scan_devices;
|
||||
pub use utils::normalize_uuid;
|
||||
@ -0,0 +1,81 @@
|
||||
use btleplug::api::{Central, Manager as _, Peripheral, ScanFilter, bleuuid::uuid_from_u16};
|
||||
use btleplug::platform::Manager;
|
||||
use std::time::Duration;
|
||||
use tokio::time;
|
||||
use crate::ble::error::BleError;
|
||||
use crate::device::Device;
|
||||
use hex;
|
||||
use uuid::{Uuid};
|
||||
|
||||
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
|
||||
];
|
||||
|
||||
pub async fn scan_devices(scan_seconds: u64) -> Result<Vec<Device>, BleError> {
|
||||
let manager = Manager::new().await?;
|
||||
let adapters = manager.adapters().await?;
|
||||
let central = adapters.into_iter().next().ok_or(BleError::SystemBleError)?;
|
||||
let target_vec: Vec<Uuid> = TARGET_UUIDS.to_vec();
|
||||
|
||||
central.start_scan(ScanFilter { services: target_vec.clone() }).await?;
|
||||
time::sleep(Duration::from_secs(scan_seconds)).await;
|
||||
|
||||
let mut devices = Vec::new();
|
||||
|
||||
for peripheral in central.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 manuf = props.manufacturer_data.iter().next().map(|(_,v)| hex::encode(v));
|
||||
|
||||
let mut characteristics: Vec<String> = Vec::new();
|
||||
|
||||
let is_connected = peripheral.is_connected().await.unwrap_or(false);
|
||||
if !is_connected {
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), peripheral.connect()).await;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !is_connected {
|
||||
let _ = tokio::time::timeout(Duration::from_secs(2), peripheral.disconnect()).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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
central.stop_scan().await.ok();
|
||||
println!("{:?}", devices);
|
||||
Ok(devices)
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
pub fn normalize_uuid(u: &str) -> String {
|
||||
let s = u.trim().to_ascii_lowercase();
|
||||
const BASE_SUFFIX: &str = "-0000-1000-8000-00805f9b34fb";
|
||||
if s.ends_with(BASE_SUFFIX) && s.len() >= BASE_SUFFIX.len() + 4 {
|
||||
let start = s.len() - BASE_SUFFIX.len() - 4;
|
||||
return s[start..start + 4].to_string();
|
||||
}
|
||||
s
|
||||
}
|
||||
@ -0,0 +1,90 @@
|
||||
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
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
pub mod device;
|
||||
pub use device::Device;
|
||||
@ -0,0 +1,14 @@
|
||||
#[derive(Debug)]
|
||||
pub enum RepoError {
|
||||
Creation,
|
||||
Empty,
|
||||
Serialization
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for RepoError {
|
||||
fn from(_: std::io::Error) -> Self { RepoError::Creation }
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for RepoError {
|
||||
fn from(_: serde_json::Error) -> Self { RepoError::Serialization }
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
mod repo;
|
||||
mod error;
|
||||
|
||||
pub use repo::{try_from_file, try_write, repo_from_devices, Repo};
|
||||
@ -0,0 +1,59 @@
|
||||
use std::{fs::{read_to_string, write}};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::{device::{device::{get_device_types, DeviceType}, Device}, device_repo::error::RepoError};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Repo {
|
||||
#[serde(default)]
|
||||
hr_sensors: Vec<Device>,
|
||||
#[serde(default)]
|
||||
cad_sensors: Vec<Device>,
|
||||
#[serde(default)]
|
||||
trainers: Vec<Device>
|
||||
}
|
||||
|
||||
impl Repo {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
hr_sensors: Vec::new(),
|
||||
cad_sensors: Vec::new(),
|
||||
trainers: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const STATIC_PATH: &str = "devices.json";
|
||||
|
||||
pub fn try_write(repo: &Repo) -> Result<Repo, RepoError> {
|
||||
let json = serde_json::to_string_pretty(repo)?;
|
||||
write(STATIC_PATH, json)?;
|
||||
Ok(repo.clone())
|
||||
}
|
||||
|
||||
pub fn try_from_file() -> Result<Repo, RepoError> {
|
||||
let contents = match read_to_string(STATIC_PATH) {
|
||||
Ok(s) if !s.trim().is_empty() => s,
|
||||
_ => return Err(RepoError::Empty),
|
||||
};
|
||||
|
||||
let repo: Repo = serde_json::from_str(&contents)?;
|
||||
Ok(repo)
|
||||
}
|
||||
|
||||
pub fn repo_from_devices(devices: Vec<Device>) -> Repo {
|
||||
let mut repo = Repo::new();
|
||||
|
||||
for dev in devices.into_iter() {
|
||||
let types = get_device_types(&dev);
|
||||
|
||||
for t in types {
|
||||
match t {
|
||||
DeviceType::HrSensor => repo.hr_sensors.push(dev.clone()),
|
||||
DeviceType::CadSensor => repo.cad_sensors.push(dev.clone()),
|
||||
DeviceType::Trainer => repo.trainers.push(dev.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repo
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
mod device;
|
||||
mod ble;
|
||||
mod device_repo;
|
||||
|
||||
use crate::{ble::scan_devices, device::Device, device_repo::{repo_from_devices, try_from_file, Repo}};
|
||||
|
||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||
#[tauri::command]
|
||||
async fn read_repo() -> Vec<Device> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn scan() -> Repo {
|
||||
match try_from_file() {
|
||||
Ok(repo) => repo,
|
||||
Err(_) => {
|
||||
let devices = (scan_devices(20).await).unwrap_or_default();
|
||||
repo_from_devices(devices)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.invoke_handler(tauri::generate_handler![read_repo, scan])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
rideinn_lib::run()
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "rideinn",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.cyclocrust.ridein",
|
||||
"build": {
|
||||
"frontendDist": "../src"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"title": "rideinn",
|
||||
"width": 800,
|
||||
"height": 600
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 995 B |
|
After Width: | Height: | Size: 2.5 KiB |
@ -0,0 +1,20 @@
|
||||
<!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="/main.js" defer></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main class="container">
|
||||
<h1>Welcome to RideInn</h1>
|
||||
<div class="row">
|
||||
<span id="msg">Подключаем устройства...</span>
|
||||
<button id="find">поиск</button>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,28 @@
|
||||
const { invoke } = window.__TAURI__.core;
|
||||
|
||||
let greetInputEl;
|
||||
let greetMsgEl;
|
||||
|
||||
async function readRepo() {
|
||||
return await invoke("read_repo");
|
||||
}
|
||||
|
||||
async function scan() {
|
||||
return await invoke("scan");
|
||||
}
|
||||
|
||||
window.addEventListener("DOMContentLoaded", async () => {
|
||||
const messageContainer = document.getElementById("msg");
|
||||
const findButton = document.getElementById("find");
|
||||
|
||||
const connectedDeviced = await readRepo();
|
||||
|
||||
if (connectedDeviced.length === 0) {
|
||||
messageContainer.innerText = "Ищем устройства...";
|
||||
}
|
||||
|
||||
findButton.addEventListener("click", async () => {
|
||||
const lol = await scan();
|
||||
console.log("devices", lol);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,112 @@
|
||||
.logo.vanilla:hover {
|
||||
filter: drop-shadow(0 0 2em #ffe21c);
|
||||
}
|
||||
:root {
|
||||
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 400;
|
||||
|
||||
color: #0f0f0f;
|
||||
background-color: #f6f6f6;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
margin: 0;
|
||||
padding-top: 10vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: 0.75s;
|
||||
}
|
||||
|
||||
.logo.tauri:hover {
|
||||
filter: drop-shadow(0 0 2em #24c8db);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
color: #0f0f0f;
|
||||
background-color: #ffffff;
|
||||
transition: border-color 0.25s;
|
||||
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
border-color: #396cd8;
|
||||
}
|
||||
button:active {
|
||||
border-color: #396cd8;
|
||||
background-color: #e8e8e8;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#greet-input {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
color: #f6f6f6;
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #24c8db;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
color: #ffffff;
|
||||
background-color: #0f0f0f98;
|
||||
}
|
||||
button:active {
|
||||
background-color: #0f0f0f69;
|
||||
}
|
||||
}
|
||||