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' {
export interface GlobalComponents {
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']
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']
TablerReload: typeof import('./src/components/icons/TablerReload.vue')['default']
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "heartbeat-cat",
"private": true,
"version": "0.0.0",
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
@@ -11,6 +11,7 @@
},
"dependencies": {
"@tauri-apps/api": "^1",
"pinia": "^2.1.7",
"vue": "^3.3.4",
"vue-router": "^4.4.0"
},

38
pnpm-lock.yaml generated
View File

@@ -11,6 +11,9 @@ importers:
'@tauri-apps/api':
specifier: ^1
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:
specifier: ^3.3.4
version: 3.4.31(typescript@5.5.3)
@@ -798,6 +801,18 @@ packages:
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
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:
resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==}
engines: {node: '>= 6'}
@@ -1011,6 +1026,17 @@ packages:
vscode-uri@3.0.8:
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:
resolution: {integrity: sha512-HB+t2p611aIZraV2aPSRNXf0Z/oLZFrlygJm+sZbdJaW6lcFqEDQwnzUBXn+DApw+/QzDU/I9TeWx9izEjTmsA==}
peerDependencies:
@@ -1676,6 +1702,14 @@ snapshots:
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: {}
pkg-types@1.1.3:
@@ -1907,6 +1941,10 @@ snapshots:
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)):
dependencies:
'@vue/devtools-api': 6.6.3

2
src-tauri/Cargo.lock generated
View File

@@ -1319,7 +1319,7 @@ checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "heartbeat-cat"
version = "0.0.0"
version = "0.0.1"
dependencies = [
"btleplug",
"futures",

View File

@@ -1,8 +1,8 @@
[package]
name = "heartbeat-cat"
version = "0.0.0"
description = "A Tauri App"
authors = ["you"]
version = "0.0.1"
description = "Catch your heartbeats"
authors = ["TimothyYin"]
edition = "2021"
# 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,
ManufacturerDataAdvertisement, ServiceDataAdvertisement, ServicesAdvertisement,
};
use btleplug::api::{Central, Manager as _, Peripheral as _, ScanFilter};
use btleplug::platform::{Adapter, Manager as BtleManager, Peripheral};
use btleplug::api::{BDAddr, Central, Manager as _, Peripheral as _, ScanFilter};
use btleplug::platform::{Adapter, Manager as BtleManager, Peripheral, PeripheralId};
use futures::StreamExt;
use std::error::Error;
use std::sync::Arc;
use tauri::{AppHandle, Manager, State};
use tokio;
use tokio::sync::Mutex;
@@ -16,33 +18,128 @@ use tokio::sync::Mutex;
#[derive(serde::Serialize, Clone)]
struct BleDevice {
name: String,
address: String,
address: BDAddr,
rssi: i16,
}
struct BleConnection<'a> {
central: Mutex<Option<Adapter>>,
peripheral: &'a Mutex<Option<Peripheral>>,
scan_devices: Mutex<Vec<BleDevice>>,
struct BleConnection {
_is_events_registered: Arc<Mutex<bool>>,
central: Arc<Mutex<Option<Adapter>>>,
peripheral: Arc<Mutex<Option<Peripheral>>>,
}
#[tauri::command]
async fn request_central_events<'a>(
app_handle: AppHandle,
connection: State<'a, BleConnection<'a>>,
) -> Result<bool, String> {
let central = connection.central.lock().await;
let peripheral = connection.peripheral.lock().await;
let central = central.as_ref().unwrap();
let central = central.clone();
impl BleConnection {
fn new(central: Adapter) -> Self {
Self {
_is_events_registered: Arc::new(Mutex::new(false)),
central: Arc::new(Mutex::new(Some(central))),
peripheral: Arc::new(Mutex::new(None)),
}
}
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 {
if let DeviceDiscovered(_) | DeviceUpdated(_) = event.clone() {
let mut devices = vec![];
for p in central.peripherals().await.unwrap() {
devices.push(BleDevice {
match event {
DeviceDiscovered(peripheral) | DeviceUpdated(peripheral) => {
let p = central_clone
.as_ref()
.unwrap()
.peripheral(&peripheral)
.await
.unwrap();
let device = BleDevice {
name: p
.properties()
.await
@@ -50,143 +147,120 @@ async fn request_central_events<'a>(
.unwrap()
.local_name
.unwrap_or("Unknown".to_string()),
address: p.address().to_string(),
});
}
println!("Devices: {:?}", serde_json::to_string(&devices).unwrap());
address: p.address(),
rssi: p.properties().await.unwrap().unwrap().rssi.unwrap(),
};
app_handle
.emit_all("scan-list-update", serde_json::to_string(&devices).unwrap())
.emit_all("device-discovered", Some(device))
.unwrap();
}
if let DeviceConnected(_) = event.clone() {
let peripheral = peripheral.as_ref().unwrap().clone();
app_handle
.emit_all(
"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();
DeviceDisconnected(_) => {
// 在这里引用 self
let mut peripheral = self_clone.peripheral.lock().await;
*peripheral = None;
}
_ => {}
}
}
});
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]
async fn start_scan(connection: State<'_, BleConnection>) -> Result<bool, String> {
let central = connection.central.lock().await;
let central = central.as_ref().unwrap();
central
.start_scan(ScanFilter::default())
.await
.unwrap_or_else(|_| {
println!("Failed to start scan");
});
let err = connection.start_scan().await;
if let Err(e) = err {
return Err(e.to_string());
} else {
Ok(true)
}
}
#[tauri::command]
async fn stop_scan(connection: State<'_, BleConnection>) -> Result<bool, String> {
let central = connection.central.lock().await;
let central = central.as_ref().unwrap();
central.stop_scan().await.unwrap_or_else(|_| {
println!("Failed to stop scan");
});
let err = connection.stop_scan().await;
if let Err(e) = err {
return Err(e.to_string());
} else {
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]
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)
async fn is_connected(connection: State<'_, BleConnection>) -> Result<bool, String> {
let status = connection.is_connected().await;
Ok(status)
}
#[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
async fn get_connected_device(connection: State<'_, BleConnection>) -> Result<BleDevice, String> {
let peripheral = connection.peripheral.lock().await;
let device = BleDevice {
name: peripheral
.as_ref()
.unwrap()
.properties()
.await
.unwrap()
.unwrap()
.local_name
.unwrap_or("Unknown".to_string()),
address: connection.address().to_string(),
})
.unwrap())
address: peripheral.as_ref().unwrap().address(),
rssi: peripheral
.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]
@@ -197,19 +271,16 @@ async fn main() {
.await
.unwrap()
.into_iter()
.next()
.nth(0)
.unwrap();
tauri::Builder::default()
.manage(BleConnection {
central: Mutex::new(Some(central)),
peripheral: Default::default(),
scan_devices: Default::default(),
})
.manage(BleConnection::new(central))
.invoke_handler(tauri::generate_handler![
request_central_events,
register_central_events,
start_scan,
stop_scan,
is_connected,
connect,
disconnect,
get_connected_device

View File

@@ -1,10 +1,11 @@
<script setup lang="ts">
import { invoke } from "@tauri-apps/api/tauri";
import { listen } from "@tauri-apps/api/event";
import { Device, useBrcatStore } from "./stores";
invoke("request_central_events").then(res => {
console.log('central event listening', res);
})
const store = useBrcatStore();
invoke("register_central_events");
listen("heart-rate", (hr) => {
console.log('Heart Rate', hr);
@@ -14,8 +15,13 @@ listen("scan-list-update", (event) => {
console.log('scan-list-update', event.payload);
})
listen("device-discovered", (event) => {
const device = event.payload as Device
store.pushDevice(device);
})
listen("device-connected", (event) => {
console.log('device-connected', event);
console.log('device-connected', event.payload);
})
</script>
@@ -38,6 +44,7 @@ listen("device-connected", (event) => {
@import './assets/css/style.css';
:root {
--primary-color: #F25E86;
--app-background: #f8f8f8;
--title-bar-background: #e4e4e4;
@@ -51,6 +58,24 @@ listen("device-connected", (event) => {
--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-enter-active,
.scale-leave-active {

View File

@@ -18,3 +18,103 @@
@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>
import { ref } from 'vue'
import { useBrcatStore } from '../stores';
const store = useBrcatStore();
const navList = ref([
{ title: '设备连接', path: '/' },
@@ -14,13 +17,16 @@ const navList = ref([
<div class="drawer">
<div class="side">
<div class="nav">
<router-link v-for="item in navList" :key="item.path" :to="item.path" class="nav-item"
active-class="active">
<router-link v-for="item in navList" :key="item.path" :to="item.path" class="nav-item" active-class="active">
<span>{{ item.title }}</span>
</router-link>
</div>
<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 class="content">
@@ -37,14 +43,14 @@ const navList = ref([
.side {
background-color: var(--drawer-bar-background);
@apply w-[120px] flex flex-col;
@apply w-[120px] flex flex-col overflow-hidden;
.nav {
@apply flex-1 flex flex-col;
.nav-item {
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 {
/* 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 {

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 { createRouter, createWebHistory } from "vue-router";
import { createPinia } from "pinia";
import App from "./App.vue";
@@ -7,7 +8,10 @@ const routes = [
{ path: "/", component: () => import("./pages/index.vue") },
{ path: "/charts", component: () => import("./pages/charts.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") },
];
@@ -16,4 +20,5 @@ const router = createRouter({
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>
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>
<template>
<div>
<Greet />
<PageContainer title="设备连接" content-class="">
<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>
</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>
.item {
border: 1px solid rgb(220, 220, 220);
box-shadow: 2px 2px 6px -2px rgba(200, 200, 200, 0.385);
}
</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: [],
};