feat: device scanning

This commit is contained in:
2024-07-16 21:14:19 +08:00
parent 1d213eae57
commit 09e2ab50bb
25 changed files with 725 additions and 223 deletions

11
components.d.ts vendored
View File

@@ -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']
} }
} }

View File

@@ -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
View File

@@ -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
View File

@@ -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",

View File

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

View File

@@ -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();
} }
} }
}); });
}
Ok(true) // 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)
}
}
#[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]
async fn connect(
address: String,
connection: State<'_, BleConnection>,
app_handle: AppHandle,
) -> Result<bool, String> {
let central = connection.central.lock().await;
let central = central.as_ref().unwrap();
let peripheral = central
.peripherals()
.await
.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] #[tauri::command]
async fn disconnect(connection: State<'_, BleConnection>) -> Result<bool, String> { async fn is_connected(connection: State<'_, BleConnection>) -> Result<bool, String> {
let connection = connection.peripheral.lock().await; let status = connection.is_connected().await;
let connection = connection.as_ref().unwrap(); Ok(status)
connection.disconnect().await.unwrap();
Ok(true)
} }
#[tauri::command] #[tauri::command]
async fn get_connected_device(connection: State<'_, BleConnection>) -> Result<String, String> { async fn get_connected_device(connection: State<'_, BleConnection>) -> Result<BleDevice, String> {
let connection = connection.peripheral.lock().await; let peripheral = connection.peripheral.lock().await;
let connection = connection.as_ref().unwrap(); let device = BleDevice {
name: peripheral
Ok(serde_json::to_string(&BleDevice { .as_ref()
name: connection .unwrap()
.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

View File

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

View File

@@ -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);
}

View File

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

View File

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

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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");

View File

@@ -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>
<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> </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
View 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,
};
});

View File

@@ -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
View 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: [],
};