feat: device scanning
This commit is contained in:
11
components.d.ts
vendored
11
components.d.ts
vendored
@@ -8,9 +8,18 @@ export {}
|
|||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
DrawerContainer: typeof import('./src/components/DrawerContainer.vue')['default']
|
DrawerContainer: typeof import('./src/components/DrawerContainer.vue')['default']
|
||||||
Greet: typeof import('./src/components/Greet.vue')['default']
|
PageContainer: typeof import('./src/components/PageContainer.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
SignalIndicator: typeof import('./src/components/SignalIndicator.vue')['default']
|
||||||
|
SystemUiconsSignalFull: typeof import('./src/components/icons/SystemUiconsSignalFull.vue')['default']
|
||||||
|
SystemUiconsSignalLow: typeof import('./src/components/icons/SystemUiconsSignalLow.vue')['default']
|
||||||
|
SystemUiconsSignalMedium: typeof import('./src/components/icons/SystemUiconsSignalMedium.vue')['default']
|
||||||
|
TablerBluetooth: typeof import('./src/components/icons/TablerBluetooth.vue')['default']
|
||||||
|
TablerBluetoothConnected: typeof import('./src/components/icons/TablerBluetoothConnected.vue')['default']
|
||||||
|
TablerBluetoothOff: typeof import('./src/components/icons/TablerBluetoothOff.vue')['default']
|
||||||
|
TablerBluetoothX: typeof import('./src/components/icons/TablerBluetoothX.vue')['default']
|
||||||
TablerDeviceWatch: typeof import('./src/components/icons/TablerDeviceWatch.vue')['default']
|
TablerDeviceWatch: typeof import('./src/components/icons/TablerDeviceWatch.vue')['default']
|
||||||
|
TablerReload: typeof import('./src/components/icons/TablerReload.vue')['default']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "heartbeat-cat",
|
"name": "heartbeat-cat",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^1",
|
"@tauri-apps/api": "^1",
|
||||||
|
"pinia": "^2.1.7",
|
||||||
"vue": "^3.3.4",
|
"vue": "^3.3.4",
|
||||||
"vue-router": "^4.4.0"
|
"vue-router": "^4.4.0"
|
||||||
},
|
},
|
||||||
|
|||||||
38
pnpm-lock.yaml
generated
38
pnpm-lock.yaml
generated
@@ -11,6 +11,9 @@ importers:
|
|||||||
'@tauri-apps/api':
|
'@tauri-apps/api':
|
||||||
specifier: ^1
|
specifier: ^1
|
||||||
version: 1.6.0
|
version: 1.6.0
|
||||||
|
pinia:
|
||||||
|
specifier: ^2.1.7
|
||||||
|
version: 2.1.7(typescript@5.5.3)(vue@3.4.31(typescript@5.5.3))
|
||||||
vue:
|
vue:
|
||||||
specifier: ^3.3.4
|
specifier: ^3.3.4
|
||||||
version: 3.4.31(typescript@5.5.3)
|
version: 3.4.31(typescript@5.5.3)
|
||||||
@@ -798,6 +801,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
|
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
pinia@2.1.7:
|
||||||
|
resolution: {integrity: sha512-+C2AHFtcFqjPih0zpYuvof37SFxMQ7OEG2zV9jRI12i9BOy3YQVAHwdKtyyc8pDcDyIc33WCIsZaCFWU7WWxGQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@vue/composition-api': ^1.4.0
|
||||||
|
typescript: '>=4.4.4'
|
||||||
|
vue: ^2.6.14 || ^3.3.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@vue/composition-api':
|
||||||
|
optional: true
|
||||||
|
typescript:
|
||||||
|
optional: true
|
||||||
|
|
||||||
pirates@4.0.6:
|
pirates@4.0.6:
|
||||||
resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==}
|
resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
@@ -1011,6 +1026,17 @@ packages:
|
|||||||
vscode-uri@3.0.8:
|
vscode-uri@3.0.8:
|
||||||
resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==}
|
resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==}
|
||||||
|
|
||||||
|
vue-demi@0.14.8:
|
||||||
|
resolution: {integrity: sha512-Uuqnk9YE9SsWeReYqK2alDI5YzciATE0r2SkA6iMAtuXvNTMNACJLJEXNXaEy94ECuBe4Sk6RzRU80kjdbIo1Q==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@vue/composition-api': ^1.0.0-rc.1
|
||||||
|
vue: ^3.0.0-0 || ^2.6.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@vue/composition-api':
|
||||||
|
optional: true
|
||||||
|
|
||||||
vue-router@4.4.0:
|
vue-router@4.4.0:
|
||||||
resolution: {integrity: sha512-HB+t2p611aIZraV2aPSRNXf0Z/oLZFrlygJm+sZbdJaW6lcFqEDQwnzUBXn+DApw+/QzDU/I9TeWx9izEjTmsA==}
|
resolution: {integrity: sha512-HB+t2p611aIZraV2aPSRNXf0Z/oLZFrlygJm+sZbdJaW6lcFqEDQwnzUBXn+DApw+/QzDU/I9TeWx9izEjTmsA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1676,6 +1702,14 @@ snapshots:
|
|||||||
|
|
||||||
pify@2.3.0: {}
|
pify@2.3.0: {}
|
||||||
|
|
||||||
|
pinia@2.1.7(typescript@5.5.3)(vue@3.4.31(typescript@5.5.3)):
|
||||||
|
dependencies:
|
||||||
|
'@vue/devtools-api': 6.6.3
|
||||||
|
vue: 3.4.31(typescript@5.5.3)
|
||||||
|
vue-demi: 0.14.8(vue@3.4.31(typescript@5.5.3))
|
||||||
|
optionalDependencies:
|
||||||
|
typescript: 5.5.3
|
||||||
|
|
||||||
pirates@4.0.6: {}
|
pirates@4.0.6: {}
|
||||||
|
|
||||||
pkg-types@1.1.3:
|
pkg-types@1.1.3:
|
||||||
@@ -1907,6 +1941,10 @@ snapshots:
|
|||||||
|
|
||||||
vscode-uri@3.0.8: {}
|
vscode-uri@3.0.8: {}
|
||||||
|
|
||||||
|
vue-demi@0.14.8(vue@3.4.31(typescript@5.5.3)):
|
||||||
|
dependencies:
|
||||||
|
vue: 3.4.31(typescript@5.5.3)
|
||||||
|
|
||||||
vue-router@4.4.0(vue@3.4.31(typescript@5.5.3)):
|
vue-router@4.4.0(vue@3.4.31(typescript@5.5.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vue/devtools-api': 6.6.3
|
'@vue/devtools-api': 6.6.3
|
||||||
|
|||||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -1319,7 +1319,7 @@ checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heartbeat-cat"
|
name = "heartbeat-cat"
|
||||||
version = "0.0.0"
|
version = "0.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"btleplug",
|
"btleplug",
|
||||||
"futures",
|
"futures",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "heartbeat-cat"
|
name = "heartbeat-cat"
|
||||||
version = "0.0.0"
|
version = "0.0.1"
|
||||||
description = "A Tauri App"
|
description = "Catch your heartbeats"
|
||||||
authors = ["you"]
|
authors = ["TimothyYin"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ use btleplug::api::CentralEvent::{
|
|||||||
DeviceConnected, DeviceDisconnected, DeviceDiscovered, DeviceUpdated,
|
DeviceConnected, DeviceDisconnected, DeviceDiscovered, DeviceUpdated,
|
||||||
ManufacturerDataAdvertisement, ServiceDataAdvertisement, ServicesAdvertisement,
|
ManufacturerDataAdvertisement, ServiceDataAdvertisement, ServicesAdvertisement,
|
||||||
};
|
};
|
||||||
use btleplug::api::{Central, Manager as _, Peripheral as _, ScanFilter};
|
use btleplug::api::{BDAddr, Central, Manager as _, Peripheral as _, ScanFilter};
|
||||||
use btleplug::platform::{Adapter, Manager as BtleManager, Peripheral};
|
use btleplug::platform::{Adapter, Manager as BtleManager, Peripheral, PeripheralId};
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
|
use std::error::Error;
|
||||||
|
use std::sync::Arc;
|
||||||
use tauri::{AppHandle, Manager, State};
|
use tauri::{AppHandle, Manager, State};
|
||||||
use tokio;
|
use tokio;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
@@ -16,33 +18,128 @@ use tokio::sync::Mutex;
|
|||||||
#[derive(serde::Serialize, Clone)]
|
#[derive(serde::Serialize, Clone)]
|
||||||
struct BleDevice {
|
struct BleDevice {
|
||||||
name: String,
|
name: String,
|
||||||
address: String,
|
address: BDAddr,
|
||||||
|
rssi: i16,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct BleConnection<'a> {
|
struct BleConnection {
|
||||||
central: Mutex<Option<Adapter>>,
|
_is_events_registered: Arc<Mutex<bool>>,
|
||||||
peripheral: &'a Mutex<Option<Peripheral>>,
|
central: Arc<Mutex<Option<Adapter>>>,
|
||||||
scan_devices: Mutex<Vec<BleDevice>>,
|
peripheral: Arc<Mutex<Option<Peripheral>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
impl BleConnection {
|
||||||
async fn request_central_events<'a>(
|
fn new(central: Adapter) -> Self {
|
||||||
app_handle: AppHandle,
|
Self {
|
||||||
connection: State<'a, BleConnection<'a>>,
|
_is_events_registered: Arc::new(Mutex::new(false)),
|
||||||
) -> Result<bool, String> {
|
central: Arc::new(Mutex::new(Some(central))),
|
||||||
let central = connection.central.lock().await;
|
peripheral: Arc::new(Mutex::new(None)),
|
||||||
let peripheral = connection.peripheral.lock().await;
|
}
|
||||||
let central = central.as_ref().unwrap();
|
}
|
||||||
let central = central.clone();
|
|
||||||
|
|
||||||
let mut event_stream = central.events().await.unwrap();
|
async fn set_peripheral(&self, peripheral: Option<Peripheral>) {
|
||||||
|
let mut p = self.peripheral.lock().await;
|
||||||
|
*p = peripheral;
|
||||||
|
}
|
||||||
|
|
||||||
tauri::async_runtime::spawn(async move {
|
pub async fn start_scan(&self) -> Result<(), String> {
|
||||||
|
let central = self.central.lock().await;
|
||||||
|
if let Err(e) = central
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.start_scan(ScanFilter { services: vec![] })
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return Err(e.to_string());
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn stop_scan(&self) -> Result<(), String> {
|
||||||
|
let central = self.central.lock().await;
|
||||||
|
if let Err(e) = central.as_ref().unwrap().stop_scan().await {
|
||||||
|
return Err(e.to_string());
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn is_connected(&self) -> bool {
|
||||||
|
let peripheral = self.peripheral.lock().await;
|
||||||
|
peripheral.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn connect(&self, address: BDAddr, app: &AppHandle) -> Result<(), Box<dyn Error>> {
|
||||||
|
self.stop_scan().await.unwrap();
|
||||||
|
let central = self.central.lock().await;
|
||||||
|
let peripheral = central
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.peripheral(&PeripheralId::from(address))
|
||||||
|
.await?;
|
||||||
|
peripheral.connect().await?;
|
||||||
|
peripheral.discover_services().await?;
|
||||||
|
// 如果 peripheral.services() 不包含 0x180D 服务,则返回错误
|
||||||
|
if !peripheral
|
||||||
|
.services()
|
||||||
|
.iter()
|
||||||
|
.any(|s| s.uuid == uuid_from_u16(0x180D))
|
||||||
|
{
|
||||||
|
return Err("Peripheral does not have the required service".into());
|
||||||
|
}
|
||||||
|
self.set_peripheral(Some(peripheral)).await;
|
||||||
|
|
||||||
|
let peripheral = self.peripheral.lock().await;
|
||||||
|
let device = BleDevice {
|
||||||
|
name: peripheral
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.properties()
|
||||||
|
.await?
|
||||||
|
.unwrap()
|
||||||
|
.local_name
|
||||||
|
.unwrap_or("Unknown".to_string()),
|
||||||
|
address: peripheral.as_ref().unwrap().address(),
|
||||||
|
rssi: peripheral
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.properties()
|
||||||
|
.await?
|
||||||
|
.unwrap()
|
||||||
|
.rssi
|
||||||
|
.unwrap(),
|
||||||
|
};
|
||||||
|
app.emit_all("device-connected", device).unwrap();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn disconnect(&self) -> Result<(), Box<dyn Error>> {
|
||||||
|
let mut peripheral = self.peripheral.lock().await;
|
||||||
|
peripheral.as_ref().unwrap().disconnect().await?;
|
||||||
|
*peripheral = None;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn register_central_events(&self, app: &AppHandle) {
|
||||||
|
let central = self.central.lock().await;
|
||||||
|
let central_clone = central.clone(); // Clone the central variable
|
||||||
|
let app_handle = app.clone(); // Clone the AppHandle to move into the tokio::spawn closure
|
||||||
|
let mut event_stream = central.as_ref().unwrap().events().await.unwrap();
|
||||||
|
|
||||||
|
let mut self_clone = self.clone(); // Clone the BleConnection to move into the tokio::spawn closure
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
while let Some(event) = event_stream.next().await {
|
while let Some(event) = event_stream.next().await {
|
||||||
if let DeviceDiscovered(_) | DeviceUpdated(_) = event.clone() {
|
match event {
|
||||||
let mut devices = vec![];
|
DeviceDiscovered(peripheral) | DeviceUpdated(peripheral) => {
|
||||||
for p in central.peripherals().await.unwrap() {
|
let p = central_clone
|
||||||
devices.push(BleDevice {
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.peripheral(&peripheral)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let device = BleDevice {
|
||||||
name: p
|
name: p
|
||||||
.properties()
|
.properties()
|
||||||
.await
|
.await
|
||||||
@@ -50,143 +147,120 @@ async fn request_central_events<'a>(
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.local_name
|
.local_name
|
||||||
.unwrap_or("Unknown".to_string()),
|
.unwrap_or("Unknown".to_string()),
|
||||||
address: p.address().to_string(),
|
address: p.address(),
|
||||||
});
|
rssi: p.properties().await.unwrap().unwrap().rssi.unwrap(),
|
||||||
}
|
};
|
||||||
println!("Devices: {:?}", serde_json::to_string(&devices).unwrap());
|
|
||||||
app_handle
|
app_handle
|
||||||
.emit_all("scan-list-update", serde_json::to_string(&devices).unwrap())
|
.emit_all("device-discovered", Some(device))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
if let DeviceConnected(_) = event.clone() {
|
DeviceDisconnected(_) => {
|
||||||
let peripheral = peripheral.as_ref().unwrap().clone();
|
// 在这里引用 self
|
||||||
app_handle
|
let mut peripheral = self_clone.peripheral.lock().await;
|
||||||
.emit_all(
|
*peripheral = None;
|
||||||
"device-connected",
|
}
|
||||||
serde_json::to_string(&BleDevice {
|
_ => {}
|
||||||
name: peripheral
|
|
||||||
.properties()
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.unwrap()
|
|
||||||
.local_name
|
|
||||||
.unwrap_or("Unknown".to_string()),
|
|
||||||
address: peripheral.address().to_string(),
|
|
||||||
})
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
// implements a Clone
|
||||||
|
pub fn clone(&self) -> Self {
|
||||||
|
Self {
|
||||||
|
_is_events_registered: self._is_events_registered.clone(),
|
||||||
|
central: self.central.clone(),
|
||||||
|
peripheral: self.peripheral.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn register_central_events<'a>(
|
||||||
|
app_handle: AppHandle,
|
||||||
|
connection: State<'a, BleConnection>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
if *connection._is_events_registered.lock().await {
|
||||||
|
return Ok(false);
|
||||||
|
} else {
|
||||||
|
connection.register_central_events(&app_handle).await;
|
||||||
|
*connection._is_events_registered.lock().await = true;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn start_scan(connection: State<'_, BleConnection>) -> Result<bool, String> {
|
async fn start_scan(connection: State<'_, BleConnection>) -> Result<bool, String> {
|
||||||
let central = connection.central.lock().await;
|
let err = connection.start_scan().await;
|
||||||
let central = central.as_ref().unwrap();
|
if let Err(e) = err {
|
||||||
central
|
return Err(e.to_string());
|
||||||
.start_scan(ScanFilter::default())
|
} else {
|
||||||
.await
|
|
||||||
.unwrap_or_else(|_| {
|
|
||||||
println!("Failed to start scan");
|
|
||||||
});
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn stop_scan(connection: State<'_, BleConnection>) -> Result<bool, String> {
|
async fn stop_scan(connection: State<'_, BleConnection>) -> Result<bool, String> {
|
||||||
let central = connection.central.lock().await;
|
let err = connection.stop_scan().await;
|
||||||
let central = central.as_ref().unwrap();
|
if let Err(e) = err {
|
||||||
central.stop_scan().await.unwrap_or_else(|_| {
|
return Err(e.to_string());
|
||||||
println!("Failed to stop scan");
|
} else {
|
||||||
});
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn connect(
|
async fn is_connected(connection: State<'_, BleConnection>) -> Result<bool, String> {
|
||||||
address: String,
|
let status = connection.is_connected().await;
|
||||||
connection: State<'_, BleConnection>,
|
Ok(status)
|
||||||
app_handle: AppHandle,
|
}
|
||||||
) -> Result<bool, String> {
|
|
||||||
let central = connection.central.lock().await;
|
|
||||||
let central = central.as_ref().unwrap();
|
|
||||||
|
|
||||||
let peripheral = central
|
#[tauri::command]
|
||||||
.peripherals()
|
async fn get_connected_device(connection: State<'_, BleConnection>) -> Result<BleDevice, String> {
|
||||||
.await
|
let peripheral = connection.peripheral.lock().await;
|
||||||
|
let device = BleDevice {
|
||||||
|
name: peripheral
|
||||||
|
.as_ref()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.into_iter()
|
|
||||||
.find(|p| p.address().to_string() == address)
|
|
||||||
.unwrap()
|
|
||||||
.clone();
|
|
||||||
peripheral.connect().await.unwrap();
|
|
||||||
|
|
||||||
*connection.peripheral.lock().await = Some(peripheral.clone());
|
|
||||||
|
|
||||||
peripheral.discover_services().await.unwrap_or_else(|_| {
|
|
||||||
println!("Failed to discover services");
|
|
||||||
});
|
|
||||||
let service = peripheral
|
|
||||||
.services()
|
|
||||||
.into_iter()
|
|
||||||
.find(|s| s.uuid == uuid_from_u16(0x180D))
|
|
||||||
.unwrap();
|
|
||||||
let characteristic = service
|
|
||||||
.characteristics
|
|
||||||
.into_iter()
|
|
||||||
.find(|c| c.uuid == uuid_from_u16(0x2A37))
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
peripheral
|
|
||||||
.subscribe(&characteristic)
|
|
||||||
.await
|
|
||||||
.unwrap_or_else(|_| {
|
|
||||||
println!("Failed to subscribe to characteristic");
|
|
||||||
});
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut notification_stream = peripheral.notifications().await.unwrap();
|
|
||||||
while let Some(notification) = notification_stream.next().await {
|
|
||||||
if notification.uuid == uuid_from_u16(0x2A37) {
|
|
||||||
app_handle
|
|
||||||
.emit_all("heart-rate", notification.value[1])
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
async fn disconnect(connection: State<'_, BleConnection>) -> Result<bool, String> {
|
|
||||||
let connection = connection.peripheral.lock().await;
|
|
||||||
let connection = connection.as_ref().unwrap();
|
|
||||||
|
|
||||||
connection.disconnect().await.unwrap();
|
|
||||||
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
async fn get_connected_device(connection: State<'_, BleConnection>) -> Result<String, String> {
|
|
||||||
let connection = connection.peripheral.lock().await;
|
|
||||||
let connection = connection.as_ref().unwrap();
|
|
||||||
|
|
||||||
Ok(serde_json::to_string(&BleDevice {
|
|
||||||
name: connection
|
|
||||||
.properties()
|
.properties()
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.local_name
|
.local_name
|
||||||
.unwrap_or("Unknown".to_string()),
|
.unwrap_or("Unknown".to_string()),
|
||||||
address: connection.address().to_string(),
|
address: peripheral.as_ref().unwrap().address(),
|
||||||
})
|
rssi: peripheral
|
||||||
.unwrap())
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.properties()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.rssi
|
||||||
|
.unwrap(),
|
||||||
|
};
|
||||||
|
Ok(device)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn connect(
|
||||||
|
address: BDAddr,
|
||||||
|
connection: State<'_, BleConnection>,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
if let Err(e) = connection.connect(address, &app_handle).await {
|
||||||
|
Err(e.to_string())
|
||||||
|
} else {
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn disconnect(connection: State<'_, BleConnection>) -> Result<bool, String> {
|
||||||
|
if let Err(e) = connection.disconnect().await {
|
||||||
|
Err(e.to_string())
|
||||||
|
} else {
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -197,19 +271,16 @@ async fn main() {
|
|||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.next()
|
.nth(0)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.manage(BleConnection {
|
.manage(BleConnection::new(central))
|
||||||
central: Mutex::new(Some(central)),
|
|
||||||
peripheral: Default::default(),
|
|
||||||
scan_devices: Default::default(),
|
|
||||||
})
|
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
request_central_events,
|
register_central_events,
|
||||||
start_scan,
|
start_scan,
|
||||||
stop_scan,
|
stop_scan,
|
||||||
|
is_connected,
|
||||||
connect,
|
connect,
|
||||||
disconnect,
|
disconnect,
|
||||||
get_connected_device
|
get_connected_device
|
||||||
|
|||||||
33
src/App.vue
33
src/App.vue
@@ -1,10 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { invoke } from "@tauri-apps/api/tauri";
|
import { invoke } from "@tauri-apps/api/tauri";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { Device, useBrcatStore } from "./stores";
|
||||||
|
|
||||||
invoke("request_central_events").then(res => {
|
const store = useBrcatStore();
|
||||||
console.log('central event listening', res);
|
|
||||||
})
|
invoke("register_central_events");
|
||||||
|
|
||||||
listen("heart-rate", (hr) => {
|
listen("heart-rate", (hr) => {
|
||||||
console.log('Heart Rate', hr);
|
console.log('Heart Rate', hr);
|
||||||
@@ -14,8 +15,13 @@ listen("scan-list-update", (event) => {
|
|||||||
console.log('scan-list-update', event.payload);
|
console.log('scan-list-update', event.payload);
|
||||||
})
|
})
|
||||||
|
|
||||||
|
listen("device-discovered", (event) => {
|
||||||
|
const device = event.payload as Device
|
||||||
|
store.pushDevice(device);
|
||||||
|
})
|
||||||
|
|
||||||
listen("device-connected", (event) => {
|
listen("device-connected", (event) => {
|
||||||
console.log('device-connected', event);
|
console.log('device-connected', event.payload);
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -38,6 +44,7 @@ listen("device-connected", (event) => {
|
|||||||
@import './assets/css/style.css';
|
@import './assets/css/style.css';
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
--primary-color: #F25E86;
|
||||||
--app-background: #f8f8f8;
|
--app-background: #f8f8f8;
|
||||||
|
|
||||||
--title-bar-background: #e4e4e4;
|
--title-bar-background: #e4e4e4;
|
||||||
@@ -51,6 +58,24 @@ listen("device-connected", (event) => {
|
|||||||
--content-background: var(--app-background);
|
--content-background: var(--app-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
--bar-width: 8px;
|
||||||
|
width: var(--bar-width);
|
||||||
|
height: var(--bar-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
--bar-color: rgba(0, 0, 0, .2);
|
||||||
|
background-color: var(--bar-color);
|
||||||
|
border-radius: 20px;
|
||||||
|
background-clip: content-box;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
/* Scale */
|
/* Scale */
|
||||||
.scale-enter-active,
|
.scale-enter-active,
|
||||||
.scale-leave-active {
|
.scale-leave-active {
|
||||||
|
|||||||
@@ -18,3 +18,103 @@
|
|||||||
@apply select-text;
|
@apply select-text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--app-background) linear-gradient(90deg, #f25e8500 0%, #f25e851c 50%, #f25e851c 100%);
|
||||||
|
@apply border-b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header .title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header .actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header .actions .additional {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgb(150, 150, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
.additional>* {
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header .actions .additional .accent {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #F25E86;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header .actions>*:first-child {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header .actions>* {
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
padding: 6px 12px;
|
||||||
|
min-width: 30px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #fff;
|
||||||
|
background-color: #F25E86;
|
||||||
|
-webkit-border-radius: 4px;
|
||||||
|
-moz-border-radius: 4px;
|
||||||
|
-ms-border-radius: 4px;
|
||||||
|
-o-border-radius: 4px;
|
||||||
|
box-shadow: 0 0 6px 1px rgb(0 0 0 / 20%);
|
||||||
|
transition: all .3s ease;
|
||||||
|
-webkit-transition: all .3s ease;
|
||||||
|
-moz-transition: all .3s ease;
|
||||||
|
-ms-transition: all .3s ease;
|
||||||
|
-o-transition: all .3s ease;
|
||||||
|
@apply text-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.outline {
|
||||||
|
background-color: transparent;
|
||||||
|
color: #F25E86;
|
||||||
|
border: 1px solid #F25E86;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
box-shadow: 2px 2px 6px 1px rgb(0 0 0 / 20%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
box-shadow: 2px 2px 6px 1px rgb(0 0 0 / 20%), 2px 2px 6px 1px rgb(0 0 0 / 20%) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
box-shadow: none;
|
||||||
|
background-color: rgba(0, 0, 0, 0.08);
|
||||||
|
color: #8f8f8f;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.outline:disabled {
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import { useBrcatStore } from '../stores';
|
||||||
|
|
||||||
|
const store = useBrcatStore();
|
||||||
|
|
||||||
const navList = ref([
|
const navList = ref([
|
||||||
{ title: '设备连接', path: '/' },
|
{ title: '设备连接', path: '/' },
|
||||||
@@ -14,13 +17,16 @@ const navList = ref([
|
|||||||
<div class="drawer">
|
<div class="drawer">
|
||||||
<div class="side">
|
<div class="side">
|
||||||
<div class="nav">
|
<div class="nav">
|
||||||
<router-link v-for="item in navList" :key="item.path" :to="item.path" class="nav-item"
|
<router-link v-for="item in navList" :key="item.path" :to="item.path" class="nav-item" active-class="active">
|
||||||
active-class="active">
|
|
||||||
<span>{{ item.title }}</span>
|
<span>{{ item.title }}</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
<div class="status">
|
<div class="status">
|
||||||
status
|
<TablerBluetoothConnected v-if="store.is_connected" class="icon text-sm block -mt-0.5 text-emerald-500" />
|
||||||
|
<TablerBluetooth v-else class="icon text-sm block -mt-0.5 text-neutral-400" />
|
||||||
|
<span class="text" :title="store.is_connected ? store.connected_device?.name : undefined">
|
||||||
|
{{ store.is_connected ? store.connected_device?.name : '未连接' }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
@@ -37,14 +43,14 @@ const navList = ref([
|
|||||||
|
|
||||||
.side {
|
.side {
|
||||||
background-color: var(--drawer-bar-background);
|
background-color: var(--drawer-bar-background);
|
||||||
@apply w-[120px] flex flex-col;
|
@apply w-[120px] flex flex-col overflow-hidden;
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
@apply flex-1 flex flex-col;
|
@apply flex-1 flex flex-col;
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
background-color: var(--drawer-bar-item-background);
|
background-color: var(--drawer-bar-item-background);
|
||||||
@apply flex justify-center items-center px-1 py-3.5 text-xs text-neutral-500 font-semibold transition-colors duration-150;
|
@apply flex justify-center items-center px-1 h-11 text-xs text-neutral-500 font-semibold transition-colors duration-150;
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
/* background-color: var(--drawer-bar-item-active-background); */
|
/* background-color: var(--drawer-bar-item-active-background); */
|
||||||
@@ -52,6 +58,14 @@ const navList = ref([
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
@apply w-full h-8 bg-neutral-300 flex items-center text-neutral-900 px-2 text-2xs whitespace-nowrap flex-nowrap;
|
||||||
|
|
||||||
|
.text {
|
||||||
|
@apply flex-1 overflow-hidden text-ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, onBeforeUnmount } from "vue";
|
|
||||||
import { invoke } from "@tauri-apps/api/tauri";
|
|
||||||
|
|
||||||
const address = ref("");
|
|
||||||
const device_info = ref('');
|
|
||||||
|
|
||||||
async function connect() {
|
|
||||||
invoke("connect", { address: address.value }).then(res => {
|
|
||||||
console.log(res);
|
|
||||||
}).catch(err => {
|
|
||||||
console.error(err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function disconnect() {
|
|
||||||
invoke("disconnect").then(res => {
|
|
||||||
console.log(res);
|
|
||||||
}).catch(err => {
|
|
||||||
console.error(err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function scan() {
|
|
||||||
invoke("start_scan").then(res => {
|
|
||||||
console.log(JSON.parse(res as string));
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function device() {
|
|
||||||
invoke("get_connected_device").then(res => {
|
|
||||||
device_info.value = res as string;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<form class="row" @submit.prevent="connect">
|
|
||||||
<input v-model="address" placeholder="Enter a address..." />
|
|
||||||
<button type="submit">Connect</button>
|
|
||||||
<button @click="disconnect" type="button">Discon</button>
|
|
||||||
<button @click="scan" type="button">Scan</button>
|
|
||||||
<button @click="device" type="button">Device</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<pre>{{ device_info }}</pre>
|
|
||||||
</template>
|
|
||||||
37
src/components/PageContainer.vue
Normal file
37
src/components/PageContainer.vue
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
defineProps({
|
||||||
|
title: String,
|
||||||
|
subtitle: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
contentClass: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-full">
|
||||||
|
<header
|
||||||
|
class="w-full h-11 px-4 flex justify-between items-center bg-gradient-to-r from-pink-50 to-pink-100 border-b">
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<h1 class="font-semibold text-neutral-900" :class="{ 'text-xs': !!subtitle, 'text-base': !subtitle }">
|
||||||
|
{{ title }}
|
||||||
|
</h1>
|
||||||
|
<p v-if="subtitle" class="text-2xs text-neutral-500">
|
||||||
|
{{ subtitle }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div :class="`flex-1 overflow-auto relative ${contentClass}`">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
27
src/components/SignalIndicator.vue
Normal file
27
src/components/SignalIndicator.vue
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
rssi: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const strength = computed(() => {
|
||||||
|
if (props.rssi >= -50) return 5;
|
||||||
|
else if (props.rssi >= -60) return 4;
|
||||||
|
else if (props.rssi >= -70) return 3;
|
||||||
|
else if (props.rssi >= -80) return 2;
|
||||||
|
else return 1;
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center gap-[3px]">
|
||||||
|
<span v-for="i in 5" :key="i" class="w-1 h-2 rounded-full"
|
||||||
|
:class="{ 'bg-emerald-500': i <= strength, 'bg-neutral-200': i > strength }"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
10
src/components/icons/SystemUiconsSignalFull.vue
Normal file
10
src/components/icons/SystemUiconsSignalFull.vue
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
|
||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 21 21"><path fill="currentColor" fillRule="evenodd" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M5.5 16.5v-3a1 1 0 1 1 2 0v3a1 1 0 0 1-2 0m4 0v-6a1 1 0 1 1 2 0v6a1 1 0 0 1-2 0m4 0v-9a1 1 0 1 1 2 0v9a1 1 0 0 1-2 0"></path></svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'SystemUiconsSignalFull'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
10
src/components/icons/SystemUiconsSignalLow.vue
Normal file
10
src/components/icons/SystemUiconsSignalLow.vue
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
|
||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 21 21"><g fill="none" fillRule="evenodd" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path fill="currentColor" d="M5.5 16.5v-3a1 1 0 1 1 2 0v3a1 1 0 0 1-2 0"></path><path d="M9.5 16.5v-6a1 1 0 1 1 2 0v6a1 1 0 0 1-2 0m4 0v-9a1 1 0 1 1 2 0v9a1 1 0 0 1-2 0"></path></g></svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'SystemUiconsSignalLow'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
10
src/components/icons/SystemUiconsSignalMedium.vue
Normal file
10
src/components/icons/SystemUiconsSignalMedium.vue
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
|
||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 21 21"><g fill="none" fillRule="evenodd" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path fill="currentColor" d="M5.5 16.5v-3a1 1 0 1 1 2 0v3a1 1 0 0 1-2 0m4 0v-6a1 1 0 1 1 2 0v6a1 1 0 0 1-2 0"></path><path d="M13.5 16.5v-9a1 1 0 1 1 2 0v9a1 1 0 0 1-2 0"></path></g></svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'SystemUiconsSignalMedium'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
10
src/components/icons/TablerBluetooth.vue
Normal file
10
src/components/icons/TablerBluetooth.vue
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
|
||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m7 8l10 8l-5 4V4l5 4l-10 8"></path></svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'TablerBluetooth'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
10
src/components/icons/TablerBluetoothConnected.vue
Normal file
10
src/components/icons/TablerBluetoothConnected.vue
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
|
||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m7 8l10 8l-5 4V4l5 4l-10 8m-3-4h1m13 0h1"></path></svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'TablerBluetoothConnected'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
10
src/components/icons/TablerBluetoothOff.vue
Normal file
10
src/components/icons/TablerBluetoothOff.vue
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
|
||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m3 3l18 18m-4.562-4.55L12 20v-8m0-4V4l5 4l-2.776 2.22m-2.222 1.779l-5 4"></path></svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'TablerBluetoothOff'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
10
src/components/icons/TablerBluetoothX.vue
Normal file
10
src/components/icons/TablerBluetoothX.vue
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
|
||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m7 8l10 8l-5 4V4l1 .802m0 6.396L7 16m9-10l4 4m0-4l-4 4"></path></svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'TablerBluetoothX'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
10
src/components/icons/TablerReload.vue
Normal file
10
src/components/icons/TablerReload.vue
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
|
||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M19.933 13.041a8 8 0 1 1-9.925-8.788c3.899-1 7.935 1.007 9.425 4.747"></path><path d="M20 4v5h-5"></path></g></svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'TablerReload'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createApp } from "vue";
|
import { createApp } from "vue";
|
||||||
import { createRouter, createWebHistory } from "vue-router";
|
import { createRouter, createWebHistory } from "vue-router";
|
||||||
|
import { createPinia } from "pinia";
|
||||||
|
|
||||||
import App from "./App.vue";
|
import App from "./App.vue";
|
||||||
|
|
||||||
@@ -7,7 +8,10 @@ const routes = [
|
|||||||
{ path: "/", component: () => import("./pages/index.vue") },
|
{ path: "/", component: () => import("./pages/index.vue") },
|
||||||
{ path: "/charts", component: () => import("./pages/charts.vue") },
|
{ path: "/charts", component: () => import("./pages/charts.vue") },
|
||||||
{ path: "/widgets", component: () => import("./pages/widgets.vue") },
|
{ path: "/widgets", component: () => import("./pages/widgets.vue") },
|
||||||
{ path: "/streaming-plugins", component: () => import("./pages/streaming-plugins.vue") },
|
{
|
||||||
|
path: "/streaming-plugins",
|
||||||
|
component: () => import("./pages/streaming-plugins.vue"),
|
||||||
|
},
|
||||||
{ path: "/settings", component: () => import("./pages/settings.vue") },
|
{ path: "/settings", component: () => import("./pages/settings.vue") },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -16,4 +20,5 @@ const router = createRouter({
|
|||||||
routes,
|
routes,
|
||||||
});
|
});
|
||||||
|
|
||||||
createApp(App).use(router).mount("#app");
|
const pinia = createPinia();
|
||||||
|
createApp(App).use(router).use(pinia).mount("#app");
|
||||||
|
|||||||
@@ -1,12 +1,73 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { invoke } from '@tauri-apps/api/tauri';
|
||||||
|
import { useBrcatStore } from '../stores';
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
const store = useBrcatStore();
|
||||||
|
const is_connecting = ref(false);
|
||||||
|
|
||||||
|
async function connect(address: String) {
|
||||||
|
is_connecting.value = true;
|
||||||
|
invoke('connect', { address })
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
}).finally(() => {
|
||||||
|
is_connecting.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.startScan();
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<PageContainer title="设备连接" content-class="">
|
||||||
<Greet />
|
<template #actions>
|
||||||
|
<div class="flex">
|
||||||
|
<button class="text-[var(--primary-color)]"
|
||||||
|
@click="() => store.is_scanning ? store.stopScan() : store.startScan()">
|
||||||
|
<TablerReload :class="{ 'animate-spin': store.is_scanning }" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<div v-if="store.is_connected" class="w-full h-full flex flex-col gap-4 justify-center items-center bg-white">
|
||||||
|
<TablerBluetoothConnected class="icon text-5xl text-emerald-500" />
|
||||||
|
<span class="flex flex-col items-center">
|
||||||
|
<span class="text-base font-semibold">{{ store.connected_device?.name }}</span>
|
||||||
|
<span class="text-sm font-semibold text-neutral-400">已连接到设备</span>
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button class="btn outline" @click="invoke('disconnect')">断开连接</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-4" v-else>
|
||||||
|
<ul class="flex flex-col gap-2">
|
||||||
|
<li class="item w-full px-3 py-3 flex justify-between items-center bg-white rounded"
|
||||||
|
v-for="device in store.scanning_devices.filter(d => d.name !== 'Unknown')" :key="device.address">
|
||||||
|
<div class="h-full">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="flex items-center gap-1 text-sm leading-none">
|
||||||
|
{{ device.name }}
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1 text-2xs text-neutral-400">
|
||||||
|
<SignalIndicator :rssi="device.rssi" />
|
||||||
|
{{ device.address }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="h-full flex items-center">
|
||||||
|
<button class="btn outline" :disabled="store.is_connected" @click="connect(device.address)">连接</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.item {
|
||||||
|
border: 1px solid rgb(220, 220, 220);
|
||||||
|
box-shadow: 2px 2px 6px -2px rgba(200, 200, 200, 0.385);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
71
src/stores/index.ts
Normal file
71
src/stores/index.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { ref, watchEffect } from "vue";
|
||||||
|
import { invoke } from "@tauri-apps/api/tauri";
|
||||||
|
|
||||||
|
export interface Device {
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
rssi: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBrcatStore = defineStore("brcat", () => {
|
||||||
|
const scanning_devices = ref<Device[]>([]);
|
||||||
|
|
||||||
|
const is_connected = ref(false);
|
||||||
|
const connected_device = ref<Device | null>(null);
|
||||||
|
|
||||||
|
const is_scanning = ref(false);
|
||||||
|
|
||||||
|
setInterval(async () => {
|
||||||
|
is_connected.value = await invoke("is_connected");
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
watchEffect(async () => {
|
||||||
|
scanning_devices.value = [];
|
||||||
|
if (is_connected.value) {
|
||||||
|
connected_device.value = await invoke("get_connected_device");
|
||||||
|
} else {
|
||||||
|
connected_device.value = null;
|
||||||
|
is_scanning.value = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
startScan();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
stopScan();
|
||||||
|
});
|
||||||
|
|
||||||
|
function pushDevice(device: Device) {
|
||||||
|
if (scanning_devices.value.some((d) => d.address === device.address)) {
|
||||||
|
scanning_devices.value = scanning_devices.value.map((d) =>
|
||||||
|
d.address === device.address ? device : d
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
scanning_devices.value.push(device);
|
||||||
|
}
|
||||||
|
// scanning_devices.value = scanning_devices.value.sort(
|
||||||
|
// (a, b) => b.rssi - a.rssi
|
||||||
|
// );
|
||||||
|
}
|
||||||
|
|
||||||
|
function startScan() {
|
||||||
|
invoke("start_scan");
|
||||||
|
is_scanning.value = true;
|
||||||
|
console.log("start scan");
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopScan() {
|
||||||
|
invoke("stop_scan");
|
||||||
|
is_scanning.value = false;
|
||||||
|
console.log("stop scan");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
is_connected,
|
||||||
|
is_scanning,
|
||||||
|
connected_device,
|
||||||
|
scanning_devices,
|
||||||
|
pushDevice,
|
||||||
|
startScan,
|
||||||
|
stopScan,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
export default {
|
|
||||||
content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
|
|
||||||
theme: {
|
|
||||||
extend: {},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
};
|
|
||||||
18
tailwind.config.ts
Normal file
18
tailwind.config.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
export default <Config>{
|
||||||
|
content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontSize: {
|
||||||
|
"2xs": [
|
||||||
|
"0.625rem",
|
||||||
|
{
|
||||||
|
lineHeight: "0.625rem",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user