feat 配置蓝牙模块
This commit is contained in:
@@ -39,7 +39,12 @@ export default defineManifestConfig({
|
|||||||
'<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>',
|
'<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>',
|
||||||
'<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>',
|
'<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>',
|
||||||
'<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>',
|
'<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>',
|
||||||
'<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" android:usesPermissionFlags="neverForLocation"/>',
|
'<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES"/>',
|
||||||
|
'<uses-permission android:name="android.permission.BLUETOOTH"/>',
|
||||||
|
'<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>',
|
||||||
|
'<uses-permission android:name="android.permission.BLUETOOTH_SCAN"/>',
|
||||||
|
'<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>',
|
||||||
|
'<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE"/>',
|
||||||
'<uses-feature android:name="android.hardware.camera.autofocus"/>',
|
'<uses-feature android:name="android.hardware.camera.autofocus"/>',
|
||||||
'<uses-permission android:name="android.permission.CAMERA"/>',
|
'<uses-permission android:name="android.permission.CAMERA"/>',
|
||||||
'<uses-permission android:name="android.permission.GET_ACCOUNTS"/>',
|
'<uses-permission android:name="android.permission.GET_ACCOUNTS"/>',
|
||||||
@@ -48,7 +53,7 @@ export default defineManifestConfig({
|
|||||||
'<uses-permission android:name="android.permission.FLASHLIGHT"/>',
|
'<uses-permission android:name="android.permission.FLASHLIGHT"/>',
|
||||||
'<uses-feature android:name="android.hardware.camera"/>',
|
'<uses-feature android:name="android.hardware.camera"/>',
|
||||||
'<uses-permission android:name="android.permission.WRITE_SETTINGS"/>',
|
'<uses-permission android:name="android.permission.WRITE_SETTINGS"/>',
|
||||||
],
|
] as any,
|
||||||
},
|
},
|
||||||
/* ios打包配置 */
|
/* ios打包配置 */
|
||||||
ios: {},
|
ios: {},
|
||||||
|
|||||||
5
src/auto-imports.d.ts
vendored
5
src/auto-imports.d.ts
vendored
@@ -9,6 +9,7 @@ declare global {
|
|||||||
const EffectScope: typeof import('vue')['EffectScope']
|
const EffectScope: typeof import('vue')['EffectScope']
|
||||||
const asyncComputed: typeof import('@vueuse/core')['asyncComputed']
|
const asyncComputed: typeof import('@vueuse/core')['asyncComputed']
|
||||||
const autoResetRef: typeof import('@vueuse/core')['autoResetRef']
|
const autoResetRef: typeof import('@vueuse/core')['autoResetRef']
|
||||||
|
const bluetooth: typeof import('./utils/bluetooth')['default']
|
||||||
const closeDatabase: typeof import('./utils/sqlite')['closeDatabase']
|
const closeDatabase: typeof import('./utils/sqlite')['closeDatabase']
|
||||||
const computed: typeof import('vue')['computed']
|
const computed: typeof import('vue')['computed']
|
||||||
const computedAsync: typeof import('@vueuse/core')['computedAsync']
|
const computedAsync: typeof import('@vueuse/core')['computedAsync']
|
||||||
@@ -337,6 +338,9 @@ declare global {
|
|||||||
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||||
import('vue')
|
import('vue')
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
export type { BluetoothDevice, ScanOptions, ScanForDeviceOptions, WriteOptions, BluetoothReceiveData, BluetoothConnectionState, DataCallback, ConnectionStateCallback, DeviceFoundCallback } from './utils/bluetooth'
|
||||||
|
import('./utils/bluetooth')
|
||||||
|
// @ts-ignore
|
||||||
export type { SystemInfo } from './utils/device'
|
export type { SystemInfo } from './utils/device'
|
||||||
import('./utils/device')
|
import('./utils/device')
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -352,6 +356,7 @@ declare module 'vue' {
|
|||||||
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
|
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
|
||||||
readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']>
|
readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']>
|
||||||
readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']>
|
readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']>
|
||||||
|
readonly bluetooth: UnwrapRef<typeof import('./utils/bluetooth')['default']>
|
||||||
readonly closeDatabase: UnwrapRef<typeof import('./utils/sqlite')['closeDatabase']>
|
readonly closeDatabase: UnwrapRef<typeof import('./utils/sqlite')['closeDatabase']>
|
||||||
readonly computed: UnwrapRef<typeof import('vue')['computed']>
|
readonly computed: UnwrapRef<typeof import('vue')['computed']>
|
||||||
readonly computedAsync: UnwrapRef<typeof import('@vueuse/core')['computedAsync']>
|
readonly computedAsync: UnwrapRef<typeof import('@vueuse/core')['computedAsync']>
|
||||||
|
|||||||
@@ -1,33 +1,212 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import type { BluetoothDevice } from '@/utils/bluetooth'
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import bluetooth from '@/utils/bluetooth'
|
||||||
|
|
||||||
const bluetoothEnabled = ref(true)
|
interface DeviceItem {
|
||||||
|
name: string
|
||||||
|
mac: string
|
||||||
|
deviceId: string
|
||||||
|
rssi: number
|
||||||
|
connected: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const bluetoothEnabled = ref(false)
|
||||||
const searchValue = ref('')
|
const searchValue = ref('')
|
||||||
|
const scanning = ref(false)
|
||||||
|
const connectingId = ref('')
|
||||||
|
const deviceList = ref<DeviceItem[]>([])
|
||||||
|
|
||||||
const deviceList = ref([
|
let offAdapter: (() => void) | null = null
|
||||||
{
|
let offConnection: (() => void) | null = null
|
||||||
name: 'XS-65H',
|
let offFound: (() => void) | null = null
|
||||||
mac: 'MACC:AA:BB:CC:DD:01',
|
|
||||||
connected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'XS-65H',
|
|
||||||
mac: 'MACC:AA:BB:CC:DD:01',
|
|
||||||
connected: true,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
function handleConnect(index: number) {
|
const filteredList = computed(() => {
|
||||||
deviceList.value[index].connected = true
|
const keyword = searchValue.value.trim().toLowerCase()
|
||||||
|
if (!keyword)
|
||||||
|
return deviceList.value
|
||||||
|
return deviceList.value.filter(item =>
|
||||||
|
item.name.toLowerCase().includes(keyword)
|
||||||
|
|| item.mac.toLowerCase().includes(keyword),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function upsertDevice(device: BluetoothDevice, connected = false) {
|
||||||
|
const name = device.name || device.localName || '未知设备'
|
||||||
|
const index = deviceList.value.findIndex(item => item.deviceId === device.deviceId)
|
||||||
|
const next: DeviceItem = {
|
||||||
|
name,
|
||||||
|
mac: device.deviceId,
|
||||||
|
deviceId: device.deviceId,
|
||||||
|
rssi: device.RSSI,
|
||||||
|
connected: connected || deviceList.value[index]?.connected || false,
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDisconnect(index: number) {
|
if (index >= 0)
|
||||||
deviceList.value[index].connected = false
|
deviceList.value[index] = { ...deviceList.value[index], ...next, connected: deviceList.value[index].connected || connected }
|
||||||
|
else
|
||||||
|
deviceList.value.push(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRefresh() {
|
function syncConnectedFlag(deviceId: string, connected: boolean) {
|
||||||
console.log('刷新设备列表')
|
deviceList.value = deviceList.value.map(item => ({
|
||||||
|
...item,
|
||||||
|
connected: item.deviceId === deviceId ? connected : (connected ? false : item.connected),
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshAdapterState() {
|
||||||
|
try {
|
||||||
|
bluetoothEnabled.value = await bluetooth.isEnabled()
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
bluetoothEnabled.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBluetoothToggle(event: { detail: { value: boolean } }) {
|
||||||
|
const wantEnable = !!event.detail.value
|
||||||
|
try {
|
||||||
|
if (wantEnable) {
|
||||||
|
await bluetooth.turnOn()
|
||||||
|
bluetoothEnabled.value = true
|
||||||
|
uni.showToast({ title: '蓝牙已开启', icon: 'none' })
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await bluetooth.turnOff()
|
||||||
|
bluetoothEnabled.value = false
|
||||||
|
deviceList.value = []
|
||||||
|
uni.showToast({ title: '蓝牙已关闭', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
bluetoothEnabled.value = !wantEnable
|
||||||
|
const message = err instanceof Error ? err.message : '蓝牙开关失败'
|
||||||
|
uni.showToast({ title: message, icon: 'none', duration: 3000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRefresh() {
|
||||||
|
if (scanning.value)
|
||||||
|
return
|
||||||
|
|
||||||
|
if (!bluetoothEnabled.value) {
|
||||||
|
uni.showToast({ title: '请先开启蓝牙', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scanning.value = true
|
||||||
|
try {
|
||||||
|
// 有关键词时优先搜特定设备;否则全量扫描
|
||||||
|
const keyword = searchValue.value.trim()
|
||||||
|
if (keyword) {
|
||||||
|
try {
|
||||||
|
const device = await bluetooth.scanForDevice({ name: keyword, timeout: 12000 })
|
||||||
|
upsertDevice(device)
|
||||||
|
uni.showToast({ title: `已找到 ${device.name || device.deviceId}`, icon: 'none' })
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// 特定搜索失败时退回全量扫描,再本地过滤
|
||||||
|
const devices = await bluetooth.startScan({ timeout: 10000 })
|
||||||
|
deviceList.value = devices.map(item => ({
|
||||||
|
name: item.name || item.localName || '未知设备',
|
||||||
|
mac: item.deviceId,
|
||||||
|
deviceId: item.deviceId,
|
||||||
|
rssi: item.RSSI,
|
||||||
|
connected: bluetooth.getConnection()?.deviceId === item.deviceId,
|
||||||
|
}))
|
||||||
|
if (!filteredList.value.length)
|
||||||
|
uni.showToast({ title: '未找到匹配设备', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const devices = await bluetooth.startScan({ timeout: 10000 })
|
||||||
|
const connectedId = bluetooth.getConnection()?.deviceId
|
||||||
|
deviceList.value = devices.map(item => ({
|
||||||
|
name: item.name || item.localName || '未知设备',
|
||||||
|
mac: item.deviceId,
|
||||||
|
deviceId: item.deviceId,
|
||||||
|
rssi: item.RSSI,
|
||||||
|
connected: connectedId === item.deviceId,
|
||||||
|
}))
|
||||||
|
if (!devices.length)
|
||||||
|
uni.showToast({ title: '未发现蓝牙设备', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '扫描失败'
|
||||||
|
console.error('蓝牙扫描失败:', err)
|
||||||
|
uni.showToast({ title: message, icon: 'none', duration: 3000 })
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
scanning.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConnect(device: DeviceItem) {
|
||||||
|
if (connectingId.value || scanning.value)
|
||||||
|
return
|
||||||
|
|
||||||
|
connectingId.value = device.deviceId
|
||||||
|
try {
|
||||||
|
await bluetooth.setupConnection(device.deviceId)
|
||||||
|
syncConnectedFlag(device.deviceId, true)
|
||||||
|
uni.showToast({ title: '连接成功', icon: 'success' })
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '连接失败'
|
||||||
|
console.error('蓝牙连接失败:', err)
|
||||||
|
uni.showToast({ title: message, icon: 'none', duration: 3000 })
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
connectingId.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDisconnect(device: DeviceItem) {
|
||||||
|
try {
|
||||||
|
await bluetooth.disconnect(device.deviceId)
|
||||||
|
syncConnectedFlag(device.deviceId, false)
|
||||||
|
uni.showToast({ title: '已断开', icon: 'none' })
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '断开失败'
|
||||||
|
uni.showToast({ title: message, icon: 'none', duration: 3000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
offAdapter = bluetooth.onAdapterStateChange((available) => {
|
||||||
|
bluetoothEnabled.value = available
|
||||||
|
if (!available)
|
||||||
|
deviceList.value = []
|
||||||
|
})
|
||||||
|
|
||||||
|
offConnection = bluetooth.onConnectionStateChange((state) => {
|
||||||
|
syncConnectedFlag(state.deviceId, state.connected)
|
||||||
|
})
|
||||||
|
|
||||||
|
offFound = bluetooth.onDeviceFound((device) => {
|
||||||
|
if (scanning.value)
|
||||||
|
upsertDevice(device)
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await bluetooth.open()
|
||||||
|
await refreshAdapterState()
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
bluetoothEnabled.value = false
|
||||||
|
console.warn('蓝牙初始化:', err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
offAdapter?.()
|
||||||
|
offConnection?.()
|
||||||
|
offFound?.()
|
||||||
|
bluetooth.stopScan().catch(() => undefined)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -43,12 +222,17 @@ function handleRefresh() {
|
|||||||
<text class="switch-label">
|
<text class="switch-label">
|
||||||
蓝牙开关
|
蓝牙开关
|
||||||
</text>
|
</text>
|
||||||
<switch :checked="bluetoothEnabled" color="#4685FD" @change="bluetoothEnabled = !bluetoothEnabled" />
|
<switch :checked="bluetoothEnabled" color="#4685FD" @change="handleBluetoothToggle" />
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 搜索框 -->
|
<!-- 搜索框 -->
|
||||||
<view class="search-box">
|
<view class="search-box">
|
||||||
<wd-search v-model="searchValue" placeholder="搜索蓝牙设备" hide-cancel placeholder-left />
|
<wd-search
|
||||||
|
v-model="searchValue"
|
||||||
|
placeholder="搜索蓝牙设备"
|
||||||
|
hide-cancel
|
||||||
|
placeholder-left
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 设备列表标题 -->
|
<!-- 设备列表标题 -->
|
||||||
@@ -57,13 +241,22 @@ function handleRefresh() {
|
|||||||
设备名称
|
设备名称
|
||||||
</text>
|
</text>
|
||||||
<view class="refresh-btn" @tap="handleRefresh">
|
<view class="refresh-btn" @tap="handleRefresh">
|
||||||
<wd-icon name="refresh" size="20px" color="#999999" />
|
<wd-icon
|
||||||
|
name="refresh"
|
||||||
|
size="20px"
|
||||||
|
color="#999999"
|
||||||
|
:class="{ spinning: scanning }"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view v-if="!filteredList.length && !scanning" class="empty-tip">
|
||||||
|
{{ bluetoothEnabled ? '点击右上角刷新扫描附近设备' : '请先开启蓝牙' }}
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- 设备列表 -->
|
<!-- 设备列表 -->
|
||||||
<view class="device-list">
|
<view class="device-list">
|
||||||
<view v-for="(device, index) in deviceList" :key="index" class="device-item">
|
<view v-for="device in filteredList" :key="device.deviceId" class="device-item">
|
||||||
<view class="device-info">
|
<view class="device-info">
|
||||||
<text class="device-name">
|
<text class="device-name">
|
||||||
{{ device.name }}
|
{{ device.name }}
|
||||||
@@ -76,7 +269,9 @@ function handleRefresh() {
|
|||||||
v-if="!device.connected"
|
v-if="!device.connected"
|
||||||
size="small"
|
size="small"
|
||||||
round
|
round
|
||||||
@tap="handleConnect(index)"
|
:loading="connectingId === device.deviceId"
|
||||||
|
:disabled="!!connectingId && connectingId !== device.deviceId"
|
||||||
|
@tap="handleConnect(device)"
|
||||||
>
|
>
|
||||||
连接
|
连接
|
||||||
</wd-button>
|
</wd-button>
|
||||||
@@ -85,7 +280,7 @@ function handleRefresh() {
|
|||||||
type="danger"
|
type="danger"
|
||||||
size="small"
|
size="small"
|
||||||
round
|
round
|
||||||
@tap="handleDisconnect(index)"
|
@tap="handleDisconnect(device)"
|
||||||
>
|
>
|
||||||
断开
|
断开
|
||||||
</wd-button>
|
</wd-button>
|
||||||
@@ -150,6 +345,13 @@ function handleRefresh() {
|
|||||||
padding: 4px;
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.empty-tip {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #999999;
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
.device-list {
|
.device-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -168,6 +370,9 @@ function handleRefresh() {
|
|||||||
.device-info {
|
.device-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
padding-right: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-name {
|
.device-name {
|
||||||
@@ -180,5 +385,15 @@ function handleRefresh() {
|
|||||||
.device-mac {
|
.device-mac {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #999999;
|
color: #999999;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinning {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
873
src/utils/bluetooth.ts
Normal file
873
src/utils/bluetooth.ts
Normal file
@@ -0,0 +1,873 @@
|
|||||||
|
/**
|
||||||
|
* 蓝牙工具类 (uni-app BLE)
|
||||||
|
* 封装低功耗蓝牙开关、搜索、连接与收发
|
||||||
|
*
|
||||||
|
* ======================== 使用说明 ========================
|
||||||
|
*
|
||||||
|
* import bluetooth from '@/utils/bluetooth'
|
||||||
|
*
|
||||||
|
* // ---------- 蓝牙开关 ----------
|
||||||
|
* // 初始化蓝牙模块(使用前必须调用)
|
||||||
|
* await bluetooth.open()
|
||||||
|
*
|
||||||
|
* // 关闭蓝牙模块
|
||||||
|
* await bluetooth.close()
|
||||||
|
*
|
||||||
|
* // 检查蓝牙是否可用 / 是否已开启
|
||||||
|
* const enabled = await bluetooth.isEnabled()
|
||||||
|
*
|
||||||
|
* // 打开系统蓝牙(Android App,部分机型可能需用户手动开)
|
||||||
|
* await bluetooth.turnOn()
|
||||||
|
*
|
||||||
|
* // 关闭系统蓝牙(Android App)
|
||||||
|
* await bluetooth.turnOff()
|
||||||
|
*
|
||||||
|
* // 打开系统蓝牙设置页
|
||||||
|
* bluetooth.openSettings()
|
||||||
|
*
|
||||||
|
* // ---------- 蓝牙搜索 ----------
|
||||||
|
* // 扫描附近设备(默认 10 秒)
|
||||||
|
* const devices = await bluetooth.startScan({ timeout: 10000 })
|
||||||
|
*
|
||||||
|
* // 停止扫描
|
||||||
|
* await bluetooth.stopScan()
|
||||||
|
*
|
||||||
|
* // 获取已发现设备列表
|
||||||
|
* const list = await bluetooth.getDevices()
|
||||||
|
*
|
||||||
|
* // ---------- 蓝牙特定设备搜索 ----------
|
||||||
|
* // 按名称模糊匹配(找到即停)
|
||||||
|
* const device = await bluetooth.scanForDevice({ name: 'XS-65H', timeout: 15000 })
|
||||||
|
*
|
||||||
|
* // 按 deviceId / MAC 精确匹配
|
||||||
|
* const device2 = await bluetooth.scanForDevice({ deviceId: 'AA:BB:CC:DD:EE:FF' })
|
||||||
|
*
|
||||||
|
* // ---------- 连接 / 断开 ----------
|
||||||
|
* await bluetooth.connect(device.deviceId)
|
||||||
|
* await bluetooth.disconnect()
|
||||||
|
*
|
||||||
|
* // 自动发现可写 / 可通知特征值并启用 notify
|
||||||
|
* await bluetooth.setupConnection(device.deviceId)
|
||||||
|
*
|
||||||
|
* // ---------- 蓝牙传输数据 ----------
|
||||||
|
* // 发送字符串
|
||||||
|
* await bluetooth.send('hello')
|
||||||
|
*
|
||||||
|
* // 发送十六进制字符串
|
||||||
|
* await bluetooth.sendHex('01 02 0A FF')
|
||||||
|
*
|
||||||
|
* // 发送 ArrayBuffer
|
||||||
|
* await bluetooth.write(buffer)
|
||||||
|
*
|
||||||
|
* // ---------- 蓝牙接收数据 ----------
|
||||||
|
* // 监听收到的数据(返回取消函数)
|
||||||
|
* const off = bluetooth.onData((data) => {
|
||||||
|
* console.log(data.hex) // 十六进制
|
||||||
|
* console.log(data.text) // 文本
|
||||||
|
* console.log(data.buffer) // ArrayBuffer
|
||||||
|
* })
|
||||||
|
* off() // 取消监听
|
||||||
|
*
|
||||||
|
* // 监听连接状态
|
||||||
|
* const offState = bluetooth.onConnectionStateChange((state) => {
|
||||||
|
* console.log(state.connected, state.deviceId)
|
||||||
|
* })
|
||||||
|
* offState()
|
||||||
|
*
|
||||||
|
* // ---------- 工具方法 ----------
|
||||||
|
* bluetooth.arrayBufferToHex(buffer)
|
||||||
|
* bluetooth.hexToArrayBuffer('01020AFF')
|
||||||
|
* bluetooth.stringToArrayBuffer('hello')
|
||||||
|
* bluetooth.arrayBufferToString(buffer)
|
||||||
|
*
|
||||||
|
* ======================== 前置条件 ========================
|
||||||
|
*
|
||||||
|
* - 主要用于 App(Android / iOS)BLE
|
||||||
|
* - manifest 需开启 Bluetooth 模块,并配置权限:
|
||||||
|
* - android.permission.BLUETOOTH
|
||||||
|
* - android.permission.BLUETOOTH_ADMIN
|
||||||
|
* - android.permission.BLUETOOTH_SCAN
|
||||||
|
* - android.permission.BLUETOOTH_CONNECT
|
||||||
|
* - android.permission.ACCESS_FINE_LOCATION
|
||||||
|
* - Android 6+ 扫描通常需要定位权限,且系统定位开关需开启
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ======================== 类型定义 ========================
|
||||||
|
|
||||||
|
/** 蓝牙设备信息 */
|
||||||
|
export interface BluetoothDevice {
|
||||||
|
name: string
|
||||||
|
deviceId: string
|
||||||
|
RSSI: number
|
||||||
|
advertisData?: ArrayBuffer
|
||||||
|
advertisServiceUUIDs?: string[]
|
||||||
|
localName?: string
|
||||||
|
serviceData?: Record<string, ArrayBuffer>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 扫描参数 */
|
||||||
|
export interface ScanOptions {
|
||||||
|
/** 扫描超时 ms,默认 10000;传 0 表示不自动停止 */
|
||||||
|
timeout?: number
|
||||||
|
/** 是否允许重复上报同一设备,默认 false */
|
||||||
|
allowDuplicatesKey?: boolean
|
||||||
|
/** 按服务 UUID 过滤 */
|
||||||
|
services?: string[]
|
||||||
|
/** 功耗模式 */
|
||||||
|
powerLevel?: 'low' | 'medium' | 'high'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 特定设备搜索参数 */
|
||||||
|
export interface ScanForDeviceOptions extends ScanOptions {
|
||||||
|
/** 设备名称(包含匹配,忽略大小写) */
|
||||||
|
name?: string
|
||||||
|
/** 设备 ID / MAC(精确匹配,忽略大小写) */
|
||||||
|
deviceId?: string
|
||||||
|
/** 自定义匹配函数 */
|
||||||
|
match?: (device: BluetoothDevice) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写入参数 */
|
||||||
|
export interface WriteOptions {
|
||||||
|
deviceId?: string
|
||||||
|
serviceId?: string
|
||||||
|
characteristicId?: string
|
||||||
|
/** 单包最大字节,默认 20 */
|
||||||
|
chunkSize?: number
|
||||||
|
/** 分包间隔 ms,默认 50 */
|
||||||
|
chunkDelay?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 接收到的数据 */
|
||||||
|
export interface BluetoothReceiveData {
|
||||||
|
deviceId: string
|
||||||
|
serviceId: string
|
||||||
|
characteristicId: string
|
||||||
|
buffer: ArrayBuffer
|
||||||
|
hex: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 连接状态 */
|
||||||
|
export interface BluetoothConnectionState {
|
||||||
|
connected: boolean
|
||||||
|
deviceId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DataCallback = (data: BluetoothReceiveData) => void
|
||||||
|
export type ConnectionStateCallback = (state: BluetoothConnectionState) => void
|
||||||
|
export type DeviceFoundCallback = (device: BluetoothDevice, devices: BluetoothDevice[]) => void
|
||||||
|
|
||||||
|
// ======================== 内部状态 ========================
|
||||||
|
|
||||||
|
interface ActiveConnection {
|
||||||
|
deviceId: string
|
||||||
|
serviceId: string
|
||||||
|
writeCharacteristicId: string
|
||||||
|
notifyCharacteristicId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
opened: false,
|
||||||
|
scanning: false,
|
||||||
|
devices: new Map<string, BluetoothDevice>(),
|
||||||
|
connection: null as ActiveConnection | null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const listeners = {
|
||||||
|
data: new Set<DataCallback>(),
|
||||||
|
connection: new Set<ConnectionStateCallback>(),
|
||||||
|
found: new Set<DeviceFoundCallback>(),
|
||||||
|
}
|
||||||
|
|
||||||
|
let deviceFoundBound = false
|
||||||
|
let connectionStateBound = false
|
||||||
|
let characteristicValueBound = false
|
||||||
|
let scanTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
// ======================== 内部工具 ========================
|
||||||
|
|
||||||
|
function delay(ms: number) {
|
||||||
|
return new Promise<void>(resolve => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeId(id: string) {
|
||||||
|
return id.replace(/[:-]/g, '').toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function promisify<T = unknown>(
|
||||||
|
api: (options: any) => void,
|
||||||
|
options: Record<string, any> = {},
|
||||||
|
): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
api({
|
||||||
|
...options,
|
||||||
|
success: (res: T) => resolve(res),
|
||||||
|
fail: (err: any) => {
|
||||||
|
const msg = err?.errMsg || err?.message || JSON.stringify(err)
|
||||||
|
const code = err?.errCode ?? err?.code
|
||||||
|
reject(new Error(code != null ? `[${code}] ${msg}` : msg))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDevice(raw: any): BluetoothDevice {
|
||||||
|
return {
|
||||||
|
name: raw.name || raw.localName || '',
|
||||||
|
deviceId: raw.deviceId || '',
|
||||||
|
RSSI: raw.RSSI ?? 0,
|
||||||
|
advertisData: raw.advertisData,
|
||||||
|
advertisServiceUUIDs: raw.advertisServiceUUIDs,
|
||||||
|
localName: raw.localName,
|
||||||
|
serviceData: raw.serviceData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDeviceFoundListener() {
|
||||||
|
if (deviceFoundBound)
|
||||||
|
return
|
||||||
|
deviceFoundBound = true
|
||||||
|
uni.onBluetoothDeviceFound((res) => {
|
||||||
|
const list = res.devices || []
|
||||||
|
for (const raw of list) {
|
||||||
|
const device = mapDevice(raw)
|
||||||
|
if (!device.deviceId)
|
||||||
|
continue
|
||||||
|
state.devices.set(device.deviceId, device)
|
||||||
|
for (const cb of listeners.found)
|
||||||
|
cb(device, Array.from(state.devices.values()))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureConnectionStateListener() {
|
||||||
|
if (connectionStateBound)
|
||||||
|
return
|
||||||
|
connectionStateBound = true
|
||||||
|
uni.onBLEConnectionStateChange((res) => {
|
||||||
|
const connected = !!res.connected
|
||||||
|
if (!connected && state.connection?.deviceId === res.deviceId)
|
||||||
|
state.connection = null
|
||||||
|
|
||||||
|
for (const cb of listeners.connection) {
|
||||||
|
cb({
|
||||||
|
connected,
|
||||||
|
deviceId: res.deviceId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureCharacteristicValueListener() {
|
||||||
|
if (characteristicValueBound)
|
||||||
|
return
|
||||||
|
characteristicValueBound = true
|
||||||
|
uni.onBLECharacteristicValueChange((res) => {
|
||||||
|
const buffer = res.value as ArrayBuffer
|
||||||
|
const payload: BluetoothReceiveData = {
|
||||||
|
deviceId: res.deviceId,
|
||||||
|
serviceId: res.serviceId,
|
||||||
|
characteristicId: res.characteristicId,
|
||||||
|
buffer,
|
||||||
|
hex: arrayBufferToHex(buffer),
|
||||||
|
text: arrayBufferToString(buffer),
|
||||||
|
}
|
||||||
|
for (const cb of listeners.data)
|
||||||
|
cb(payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearScanTimer() {
|
||||||
|
if (scanTimer) {
|
||||||
|
clearTimeout(scanTimer)
|
||||||
|
scanTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAndroidBluetoothAdapter() {
|
||||||
|
const BluetoothAdapter = (plus.android as any).importClass('android.bluetooth.BluetoothAdapter')
|
||||||
|
return BluetoothAdapter.getDefaultAdapter()
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestBluetoothPermissions(): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
if (typeof plus === 'undefined' || !(plus as any).android) {
|
||||||
|
resolve(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
;(plus.android as any).requestPermissions(
|
||||||
|
[
|
||||||
|
'android.permission.ACCESS_FINE_LOCATION',
|
||||||
|
'android.permission.ACCESS_COARSE_LOCATION',
|
||||||
|
'android.permission.BLUETOOTH_SCAN',
|
||||||
|
'android.permission.BLUETOOTH_CONNECT',
|
||||||
|
],
|
||||||
|
(result: { granted?: string[] }) => {
|
||||||
|
const granted = result?.granted || []
|
||||||
|
const ok = granted.some(p =>
|
||||||
|
p.includes('LOCATION')
|
||||||
|
|| p.includes('BLUETOOTH_SCAN')
|
||||||
|
|| p.includes('BLUETOOTH_CONNECT'),
|
||||||
|
)
|
||||||
|
resolve(ok || granted.length > 0)
|
||||||
|
},
|
||||||
|
() => resolve(false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
resolve(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================== 编解码工具(模块级,供对象内外复用) ========================
|
||||||
|
|
||||||
|
function arrayBufferToHex(buffer: ArrayBuffer): string {
|
||||||
|
const bytes = new Uint8Array(buffer)
|
||||||
|
return Array.from(bytes)
|
||||||
|
.map(b => b.toString(16).padStart(2, '0').toUpperCase())
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToArrayBuffer(hex: string): ArrayBuffer {
|
||||||
|
const clean = hex.replace(/[\s,:_-]/g, '')
|
||||||
|
if (clean.length % 2 !== 0)
|
||||||
|
throw new Error('十六进制字符串长度必须为偶数')
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(clean.length / 2)
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
const byte = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16)
|
||||||
|
if (Number.isNaN(byte))
|
||||||
|
throw new Error(`无效十六进制: ${clean.slice(i * 2, i * 2 + 2)}`)
|
||||||
|
bytes[i] = byte
|
||||||
|
}
|
||||||
|
return bytes.buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringToArrayBuffer(text: string): ArrayBuffer {
|
||||||
|
const bytes = new Uint8Array(text.length)
|
||||||
|
for (let i = 0; i < text.length; i++)
|
||||||
|
bytes[i] = text.charCodeAt(i) & 0xFF
|
||||||
|
return bytes.buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayBufferToString(buffer: ArrayBuffer): string {
|
||||||
|
const bytes = new Uint8Array(buffer)
|
||||||
|
let text = ''
|
||||||
|
for (let i = 0; i < bytes.length; i++)
|
||||||
|
text += String.fromCharCode(bytes[i])
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================== 蓝牙工具 ========================
|
||||||
|
|
||||||
|
const bluetooth = {
|
||||||
|
/** ArrayBuffer → 十六进制字符串 */
|
||||||
|
arrayBufferToHex,
|
||||||
|
/** 十六进制字符串 → ArrayBuffer */
|
||||||
|
hexToArrayBuffer,
|
||||||
|
/** 字符串 → ArrayBuffer */
|
||||||
|
stringToArrayBuffer,
|
||||||
|
/** ArrayBuffer → 字符串 */
|
||||||
|
arrayBufferToString,
|
||||||
|
|
||||||
|
// ---------- 蓝牙开关 ----------
|
||||||
|
|
||||||
|
/** 初始化蓝牙适配器 */
|
||||||
|
async open(): Promise<void> {
|
||||||
|
await requestBluetoothPermissions()
|
||||||
|
try {
|
||||||
|
await promisify(uni.openBluetoothAdapter)
|
||||||
|
state.opened = true
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
// 10001: 蓝牙未开启,模块仍可用,可继续监听状态
|
||||||
|
if (String(err?.message || '').includes('10001')) {
|
||||||
|
state.opened = true
|
||||||
|
throw new Error('手机蓝牙未开启,请先打开蓝牙')
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
ensureConnectionStateListener()
|
||||||
|
ensureCharacteristicValueListener()
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 关闭蓝牙适配器 */
|
||||||
|
async close(): Promise<void> {
|
||||||
|
clearScanTimer()
|
||||||
|
state.scanning = false
|
||||||
|
state.devices.clear()
|
||||||
|
state.connection = null
|
||||||
|
try {
|
||||||
|
await promisify(uni.closeBluetoothAdapter)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
state.opened = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 蓝牙是否可用(已开启) */
|
||||||
|
async isEnabled(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
if (!state.opened) {
|
||||||
|
try {
|
||||||
|
await this.open()
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// open 失败时继续用 getBluetoothAdapterState 判断
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const res = await promisify<{ available: boolean, discovering: boolean }>(
|
||||||
|
uni.getBluetoothAdapterState,
|
||||||
|
)
|
||||||
|
return !!res.available
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
try {
|
||||||
|
if (typeof plus !== 'undefined' && (plus as any).android) {
|
||||||
|
const adapter = getAndroidBluetoothAdapter()
|
||||||
|
return !!(plus.android as any).invoke(adapter, 'isEnabled')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 打开系统蓝牙(Android App) */
|
||||||
|
async turnOn(): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (typeof plus !== 'undefined' && (plus as any).android) {
|
||||||
|
const adapter = getAndroidBluetoothAdapter()
|
||||||
|
if (!(plus.android as any).invoke(adapter, 'isEnabled'))
|
||||||
|
(plus.android as any).invoke(adapter, 'enable')
|
||||||
|
await delay(800)
|
||||||
|
await this.open().catch(() => undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await this.open()
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
this.openSettings()
|
||||||
|
throw new Error(`无法自动打开蓝牙,已跳转设置页: ${err}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 关闭系统蓝牙(Android App) */
|
||||||
|
async turnOff(): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (state.connection)
|
||||||
|
await this.disconnect().catch(() => undefined)
|
||||||
|
await this.close().catch(() => undefined)
|
||||||
|
|
||||||
|
if (typeof plus !== 'undefined' && (plus as any).android) {
|
||||||
|
const adapter = getAndroidBluetoothAdapter()
|
||||||
|
if ((plus.android as any).invoke(adapter, 'isEnabled'))
|
||||||
|
(plus.android as any).invoke(adapter, 'disable')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
throw new Error(`关闭蓝牙失败: ${err}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 打开系统蓝牙设置页 */
|
||||||
|
openSettings(): void {
|
||||||
|
try {
|
||||||
|
if (typeof plus !== 'undefined' && (plus as any).android) {
|
||||||
|
const main = (plus.android as any).runtimeMainActivity()
|
||||||
|
const Intent = (plus.android as any).importClass('android.content.Intent')
|
||||||
|
const Settings = (plus.android as any).importClass('android.provider.Settings')
|
||||||
|
const intent = new Intent(Settings.ACTION_BLUETOOTH_SETTINGS)
|
||||||
|
main.startActivity(intent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
uni.showToast({ title: '请在系统设置中打开蓝牙', icon: 'none' })
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- 蓝牙搜索 ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始扫描附近蓝牙设备
|
||||||
|
* @returns 超时后的设备列表(timeout=0 时立即返回当前已发现列表)
|
||||||
|
*/
|
||||||
|
async startScan(options: ScanOptions = {}): Promise<BluetoothDevice[]> {
|
||||||
|
if (!state.opened)
|
||||||
|
await this.open()
|
||||||
|
|
||||||
|
await requestBluetoothPermissions()
|
||||||
|
ensureDeviceFoundListener()
|
||||||
|
|
||||||
|
if (state.scanning)
|
||||||
|
await this.stopScan().catch(() => undefined)
|
||||||
|
|
||||||
|
state.devices.clear()
|
||||||
|
state.scanning = true
|
||||||
|
|
||||||
|
const {
|
||||||
|
timeout = 10000,
|
||||||
|
allowDuplicatesKey = false,
|
||||||
|
services,
|
||||||
|
powerLevel = 'medium',
|
||||||
|
} = options
|
||||||
|
|
||||||
|
await promisify(uni.startBluetoothDevicesDiscovery, {
|
||||||
|
allowDuplicatesKey,
|
||||||
|
services,
|
||||||
|
powerLevel,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!timeout || timeout <= 0)
|
||||||
|
return Array.from(state.devices.values())
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
clearScanTimer()
|
||||||
|
scanTimer = setTimeout(async () => {
|
||||||
|
await this.stopScan().catch(() => undefined)
|
||||||
|
resolve(Array.from(state.devices.values()))
|
||||||
|
}, timeout)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 停止扫描 */
|
||||||
|
async stopScan(): Promise<void> {
|
||||||
|
clearScanTimer()
|
||||||
|
if (!state.scanning) {
|
||||||
|
try {
|
||||||
|
await promisify(uni.stopBluetoothDevicesDiscovery)
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.scanning = false
|
||||||
|
try {
|
||||||
|
await promisify(uni.stopBluetoothDevicesDiscovery)
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 获取已发现的设备列表 */
|
||||||
|
async getDevices(): Promise<BluetoothDevice[]> {
|
||||||
|
try {
|
||||||
|
const res = await promisify<{ devices: any[] }>(uni.getBluetoothDevices)
|
||||||
|
const list = (res.devices || []).map(mapDevice)
|
||||||
|
for (const device of list)
|
||||||
|
state.devices.set(device.deviceId, device)
|
||||||
|
return Array.from(state.devices.values())
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return Array.from(state.devices.values())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 监听扫描过程中发现的设备 */
|
||||||
|
onDeviceFound(callback: DeviceFoundCallback): () => void {
|
||||||
|
ensureDeviceFoundListener()
|
||||||
|
listeners.found.add(callback)
|
||||||
|
return () => listeners.found.delete(callback)
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- 蓝牙特定设备搜索 ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索特定设备,找到即停止扫描
|
||||||
|
* 可通过 name / deviceId / match 指定目标
|
||||||
|
*/
|
||||||
|
async scanForDevice(options: ScanForDeviceOptions): Promise<BluetoothDevice> {
|
||||||
|
const { name, deviceId, match, timeout = 15000, ...scanOptions } = options
|
||||||
|
if (!name && !deviceId && !match)
|
||||||
|
throw new Error('请至少指定 name、deviceId 或 match')
|
||||||
|
|
||||||
|
const matched = (device: BluetoothDevice) => {
|
||||||
|
if (match)
|
||||||
|
return match(device)
|
||||||
|
if (deviceId && normalizeId(device.deviceId) === normalizeId(deviceId))
|
||||||
|
return true
|
||||||
|
if (name) {
|
||||||
|
const target = name.toLowerCase()
|
||||||
|
return (device.name || '').toLowerCase().includes(target)
|
||||||
|
|| (device.localName || '').toLowerCase().includes(target)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先看缓存
|
||||||
|
for (const device of state.devices.values()) {
|
||||||
|
if (matched(device))
|
||||||
|
return device
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.opened)
|
||||||
|
await this.open()
|
||||||
|
|
||||||
|
ensureDeviceFoundListener()
|
||||||
|
if (state.scanning)
|
||||||
|
await this.stopScan().catch(() => undefined)
|
||||||
|
|
||||||
|
state.devices.clear()
|
||||||
|
state.scanning = true
|
||||||
|
|
||||||
|
await promisify(uni.startBluetoothDevicesDiscovery, {
|
||||||
|
allowDuplicatesKey: scanOptions.allowDuplicatesKey ?? false,
|
||||||
|
services: scanOptions.services,
|
||||||
|
powerLevel: scanOptions.powerLevel ?? 'medium',
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let settled = false
|
||||||
|
|
||||||
|
const off = this.onDeviceFound((device) => {
|
||||||
|
if (settled || !matched(device))
|
||||||
|
return
|
||||||
|
settled = true
|
||||||
|
clearScanTimer()
|
||||||
|
off()
|
||||||
|
this.stopScan().finally(() => resolve(device))
|
||||||
|
})
|
||||||
|
|
||||||
|
clearScanTimer()
|
||||||
|
scanTimer = setTimeout(() => {
|
||||||
|
if (settled)
|
||||||
|
return
|
||||||
|
settled = true
|
||||||
|
off()
|
||||||
|
this.stopScan().finally(() => {
|
||||||
|
reject(new Error(`未找到目标蓝牙设备${name ? `: ${name}` : ''}`))
|
||||||
|
})
|
||||||
|
}, timeout)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- 连接 ----------
|
||||||
|
|
||||||
|
/** 连接设备 */
|
||||||
|
async connect(deviceId: string, timeout = 10000): Promise<void> {
|
||||||
|
if (!deviceId)
|
||||||
|
throw new Error('deviceId 不能为空')
|
||||||
|
if (!state.opened)
|
||||||
|
await this.open()
|
||||||
|
|
||||||
|
await this.stopScan().catch(() => undefined)
|
||||||
|
ensureConnectionStateListener()
|
||||||
|
|
||||||
|
await promisify(uni.createBLEConnection, {
|
||||||
|
deviceId,
|
||||||
|
timeout,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Android 上连接后需短暂等待再发现服务
|
||||||
|
await delay(800)
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 断开当前或指定设备 */
|
||||||
|
async disconnect(deviceId?: string): Promise<void> {
|
||||||
|
const id = deviceId || state.connection?.deviceId
|
||||||
|
if (!id)
|
||||||
|
return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await promisify(uni.closeBLEConnection, { deviceId: id })
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if (state.connection?.deviceId === id)
|
||||||
|
state.connection = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接并自动挑选可写 / 可通知特征值,启用 notify
|
||||||
|
* 之后可直接 send / onData
|
||||||
|
*/
|
||||||
|
async setupConnection(
|
||||||
|
deviceId: string,
|
||||||
|
options?: {
|
||||||
|
serviceId?: string
|
||||||
|
writeCharacteristicId?: string
|
||||||
|
notifyCharacteristicId?: string
|
||||||
|
timeout?: number
|
||||||
|
},
|
||||||
|
): Promise<ActiveConnection> {
|
||||||
|
await this.connect(deviceId, options?.timeout)
|
||||||
|
|
||||||
|
let serviceId = options?.serviceId || ''
|
||||||
|
let writeCharacteristicId = options?.writeCharacteristicId || ''
|
||||||
|
let notifyCharacteristicId = options?.notifyCharacteristicId || ''
|
||||||
|
|
||||||
|
if (!serviceId || !writeCharacteristicId || !notifyCharacteristicId) {
|
||||||
|
const servicesRes = await promisify<{ services: Array<{ uuid: string, isPrimary: boolean }> }>(
|
||||||
|
uni.getBLEDeviceServices,
|
||||||
|
{ deviceId },
|
||||||
|
)
|
||||||
|
const services = servicesRes.services || []
|
||||||
|
|
||||||
|
for (const service of services) {
|
||||||
|
const charsRes = await promisify<{
|
||||||
|
characteristics: Array<{
|
||||||
|
uuid: string
|
||||||
|
properties: {
|
||||||
|
read?: boolean
|
||||||
|
write?: boolean
|
||||||
|
writeNoResponse?: boolean
|
||||||
|
notify?: boolean
|
||||||
|
indicate?: boolean
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
}>(uni.getBLEDeviceCharacteristics, {
|
||||||
|
deviceId,
|
||||||
|
serviceId: service.uuid,
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const char of charsRes.characteristics || []) {
|
||||||
|
const props = char.properties || {}
|
||||||
|
if (!writeCharacteristicId && (props.write || props.writeNoResponse))
|
||||||
|
writeCharacteristicId = char.uuid
|
||||||
|
if (!notifyCharacteristicId && (props.notify || props.indicate))
|
||||||
|
notifyCharacteristicId = char.uuid
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((!serviceId && (writeCharacteristicId || notifyCharacteristicId))
|
||||||
|
|| (writeCharacteristicId && notifyCharacteristicId)) {
|
||||||
|
serviceId = service.uuid
|
||||||
|
if (writeCharacteristicId && notifyCharacteristicId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!serviceId)
|
||||||
|
throw new Error('未找到可用的蓝牙服务')
|
||||||
|
if (!writeCharacteristicId && !notifyCharacteristicId)
|
||||||
|
throw new Error('未找到可写或可通知的特征值')
|
||||||
|
|
||||||
|
// 没有单独 notify 特征时,尽量复用写特征
|
||||||
|
if (!notifyCharacteristicId)
|
||||||
|
notifyCharacteristicId = writeCharacteristicId
|
||||||
|
if (!writeCharacteristicId)
|
||||||
|
writeCharacteristicId = notifyCharacteristicId
|
||||||
|
|
||||||
|
if (notifyCharacteristicId) {
|
||||||
|
ensureCharacteristicValueListener()
|
||||||
|
await promisify(uni.notifyBLECharacteristicValueChange, {
|
||||||
|
deviceId,
|
||||||
|
serviceId,
|
||||||
|
characteristicId: notifyCharacteristicId,
|
||||||
|
state: true,
|
||||||
|
})
|
||||||
|
// 部分 Android 机型 notify 后需延迟再写
|
||||||
|
await delay(400)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.connection = {
|
||||||
|
deviceId,
|
||||||
|
serviceId,
|
||||||
|
writeCharacteristicId,
|
||||||
|
notifyCharacteristicId,
|
||||||
|
}
|
||||||
|
return { ...state.connection }
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 获取当前连接信息 */
|
||||||
|
getConnection(): ActiveConnection | null {
|
||||||
|
return state.connection ? { ...state.connection } : null
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- 蓝牙传输数据 ----------
|
||||||
|
|
||||||
|
/** 写入 ArrayBuffer(自动分包) */
|
||||||
|
async write(buffer: ArrayBuffer, options: WriteOptions = {}): Promise<void> {
|
||||||
|
const deviceId = options.deviceId || state.connection?.deviceId
|
||||||
|
const serviceId = options.serviceId || state.connection?.serviceId
|
||||||
|
const characteristicId = options.characteristicId || state.connection?.writeCharacteristicId
|
||||||
|
const chunkSize = options.chunkSize ?? 20
|
||||||
|
const chunkDelay = options.chunkDelay ?? 50
|
||||||
|
|
||||||
|
if (!deviceId || !serviceId || !characteristicId)
|
||||||
|
throw new Error('请先 setupConnection,或传入 deviceId / serviceId / characteristicId')
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(buffer)
|
||||||
|
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
||||||
|
const chunk = bytes.slice(offset, offset + chunkSize)
|
||||||
|
// 复制到新 buffer,避免底层引用问题
|
||||||
|
const value = chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength)
|
||||||
|
await promisify(uni.writeBLECharacteristicValue, {
|
||||||
|
deviceId,
|
||||||
|
serviceId,
|
||||||
|
characteristicId,
|
||||||
|
value,
|
||||||
|
})
|
||||||
|
if (offset + chunkSize < bytes.length)
|
||||||
|
await delay(chunkDelay)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 发送字符串 */
|
||||||
|
async send(text: string, options?: WriteOptions): Promise<void> {
|
||||||
|
await this.write(stringToArrayBuffer(text), options)
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 发送十六进制字符串,如 '01 0A FF' */
|
||||||
|
async sendHex(hex: string, options?: WriteOptions): Promise<void> {
|
||||||
|
await this.write(hexToArrayBuffer(hex), options)
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- 蓝牙接收数据 ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启用指定特征值的 notify(若已 setupConnection 通常不必再调)
|
||||||
|
*/
|
||||||
|
async enableNotify(params?: {
|
||||||
|
deviceId?: string
|
||||||
|
serviceId?: string
|
||||||
|
characteristicId?: string
|
||||||
|
}): Promise<void> {
|
||||||
|
const deviceId = params?.deviceId || state.connection?.deviceId
|
||||||
|
const serviceId = params?.serviceId || state.connection?.serviceId
|
||||||
|
const characteristicId = params?.characteristicId || state.connection?.notifyCharacteristicId
|
||||||
|
|
||||||
|
if (!deviceId || !serviceId || !characteristicId)
|
||||||
|
throw new Error('缺少 deviceId / serviceId / characteristicId')
|
||||||
|
|
||||||
|
ensureCharacteristicValueListener()
|
||||||
|
await promisify(uni.notifyBLECharacteristicValueChange, {
|
||||||
|
deviceId,
|
||||||
|
serviceId,
|
||||||
|
characteristicId,
|
||||||
|
state: true,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 监听接收数据,返回取消监听函数 */
|
||||||
|
onData(callback: DataCallback): () => void {
|
||||||
|
ensureCharacteristicValueListener()
|
||||||
|
listeners.data.add(callback)
|
||||||
|
return () => listeners.data.delete(callback)
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 监听连接状态变化 */
|
||||||
|
onConnectionStateChange(callback: ConnectionStateCallback): () => void {
|
||||||
|
ensureConnectionStateListener()
|
||||||
|
listeners.connection.add(callback)
|
||||||
|
return () => listeners.connection.delete(callback)
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 监听适配器开关状态 */
|
||||||
|
onAdapterStateChange(callback: (available: boolean, discovering: boolean) => void): () => void {
|
||||||
|
const handler = (res: { available: boolean, discovering: boolean }) => {
|
||||||
|
callback(!!res.available, !!res.discovering)
|
||||||
|
}
|
||||||
|
uni.onBluetoothAdapterStateChange(handler)
|
||||||
|
return () => {
|
||||||
|
// uni-app 无对应 off,置空回调避免业务继续执行
|
||||||
|
try {
|
||||||
|
uni.offBluetoothAdapterStateChange?.(handler as any)
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default bluetooth
|
||||||
Reference in New Issue
Block a user