Compare commits
No commits in common. 'b3ad6815adaa05c024a37993a35b95316fed370d' and '7df0a9a413f4f19512461ae7fe0500dfa048a9f7' have entirely different histories.
b3ad6815ad
...
7df0a9a413
@ -1,55 +0,0 @@
|
|||||||
# 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*
|
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
<!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>
|
||||||
|
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
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 };
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
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 };
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
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 };
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
import { Control } from "./control.js";
|
||||||
|
import { Sensors } from "./sensors.js";
|
||||||
|
import { Core } from "./core.js";
|
||||||
|
|
||||||
|
export { Control, Sensors, Core };
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
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 };
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
const connectHRSensor = () => {};
|
||||||
|
const connectCadenceSensor = () => {};
|
||||||
|
const connectTrainer = () => {};
|
||||||
|
|
||||||
|
export { connectHRSensor, connectCadenceSensor, connectTrainer };
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
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,9 +1,2 @@
|
|||||||
Minimalistic web application for running zwo workouts.
|
Minimalistic web application for running
|
||||||
|
zwo workouts in browser.
|
||||||
# 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)
|
|
||||||
|
|||||||
@ -1,7 +0,0 @@
|
|||||||
# Generated by Cargo
|
|
||||||
# will have compiled files and executables
|
|
||||||
/target/
|
|
||||||
|
|
||||||
# Generated by Tauri
|
|
||||||
# will have schema files for capabilities auto-completion
|
|
||||||
/gen/schemas
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
[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.
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
fn main() {
|
|
||||||
tauri_build::build()
|
|
||||||
}
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
|
||||||
"identifier": "default",
|
|
||||||
"description": "Capability for the main window",
|
|
||||||
"windows": ["main"],
|
|
||||||
"permissions": [
|
|
||||||
"core:default",
|
|
||||||
"opener:default"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 974 B |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 903 B |
|
Before Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 14 KiB |
@ -1,10 +0,0 @@
|
|||||||
#[derive(Debug)]
|
|
||||||
pub enum BleError {
|
|
||||||
SystemBleError
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<btleplug::Error> for BleError {
|
|
||||||
fn from(_: btleplug::Error) -> Self {
|
|
||||||
BleError::SystemBleError
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
mod error;
|
|
||||||
mod utils;
|
|
||||||
mod scanner;
|
|
||||||
|
|
||||||
pub use scanner::scan_devices;
|
|
||||||
pub use utils::normalize_uuid;
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,90 +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
|
|
||||||
}
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
pub mod device;
|
|
||||||
pub use device::Device;
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
#[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 }
|
|
||||||
}
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
mod repo;
|
|
||||||
mod error;
|
|
||||||
|
|
||||||
pub use repo::{try_from_file, try_write, repo_from_devices, Repo};
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
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");
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
|
||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
rideinn_lib::run()
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
{
|
|
||||||
"$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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 995 B |
|
Before Width: | Height: | Size: 2.5 KiB |
@ -1,20 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,112 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||