feat 配置蓝牙模块

This commit is contained in:
Ironsp
2026-09-14 10:55:01 +08:00
parent 5f848e6c78
commit 7e863f00f1
4 changed files with 1126 additions and 28 deletions

View File

@@ -39,7 +39,12 @@ export default defineManifestConfig({
'<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_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-permission android:name="android.permission.CAMERA"/>',
'<uses-permission android:name="android.permission.GET_ACCOUNTS"/>',
@@ -48,7 +53,7 @@ export default defineManifestConfig({
'<uses-permission android:name="android.permission.FLASHLIGHT"/>',
'<uses-feature android:name="android.hardware.camera"/>',
'<uses-permission android:name="android.permission.WRITE_SETTINGS"/>',
],
] as any,
},
/* ios打包配置 */
ios: {},

View File

@@ -9,6 +9,7 @@ declare global {
const EffectScope: typeof import('vue')['EffectScope']
const asyncComputed: typeof import('@vueuse/core')['asyncComputed']
const autoResetRef: typeof import('@vueuse/core')['autoResetRef']
const bluetooth: typeof import('./utils/bluetooth')['default']
const closeDatabase: typeof import('./utils/sqlite')['closeDatabase']
const computed: typeof import('vue')['computed']
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'
import('vue')
// @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'
import('./utils/device')
// @ts-ignore
@@ -352,6 +356,7 @@ declare module 'vue' {
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']>
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 computed: UnwrapRef<typeof import('vue')['computed']>
readonly computedAsync: UnwrapRef<typeof import('@vueuse/core')['computedAsync']>

View File

@@ -1,33 +1,212 @@
<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 scanning = ref(false)
const connectingId = ref('')
const deviceList = ref<DeviceItem[]>([])
const deviceList = ref([
{
name: 'XS-65H',
mac: 'MACC:AA:BB:CC:DD:01',
connected: false,
},
{
name: 'XS-65H',
mac: 'MACC:AA:BB:CC:DD:01',
connected: true,
},
])
let offAdapter: (() => void) | null = null
let offConnection: (() => void) | null = null
let offFound: (() => void) | null = null
function handleConnect(index: number) {
deviceList.value[index].connected = true
const filteredList = computed(() => {
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,
}
if (index >= 0)
deviceList.value[index] = { ...deviceList.value[index], ...next, connected: deviceList.value[index].connected || connected }
else
deviceList.value.push(next)
}
function handleDisconnect(index: number) {
deviceList.value[index].connected = false
function syncConnectedFlag(deviceId: string, connected: boolean) {
deviceList.value = deviceList.value.map(item => ({
...item,
connected: item.deviceId === deviceId ? connected : (connected ? false : item.connected),
}))
}
function handleRefresh() {
console.log('刷新设备列表')
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>
<template>
@@ -43,12 +222,17 @@ function handleRefresh() {
<text class="switch-label">
蓝牙开关
</text>
<switch :checked="bluetoothEnabled" color="#4685FD" @change="bluetoothEnabled = !bluetoothEnabled" />
<switch :checked="bluetoothEnabled" color="#4685FD" @change="handleBluetoothToggle" />
</view>
<!-- 搜索框 -->
<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>
<!-- 设备列表标题 -->
@@ -57,13 +241,22 @@ function handleRefresh() {
设备名称
</text>
<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 v-if="!filteredList.length && !scanning" class="empty-tip">
{{ bluetoothEnabled ? '点击右上角刷新扫描附近设备' : '请先开启蓝牙' }}
</view>
<!-- 设备列表 -->
<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">
<text class="device-name">
{{ device.name }}
@@ -76,7 +269,9 @@ function handleRefresh() {
v-if="!device.connected"
size="small"
round
@tap="handleConnect(index)"
:loading="connectingId === device.deviceId"
:disabled="!!connectingId && connectingId !== device.deviceId"
@tap="handleConnect(device)"
>
连接
</wd-button>
@@ -85,7 +280,7 @@ function handleRefresh() {
type="danger"
size="small"
round
@tap="handleDisconnect(index)"
@tap="handleDisconnect(device)"
>
断开
</wd-button>
@@ -150,6 +345,13 @@ function handleRefresh() {
padding: 4px;
}
.empty-tip {
font-size: 14px;
color: #999999;
text-align: center;
padding: 16px 0;
}
.device-list {
display: flex;
flex-direction: column;
@@ -168,6 +370,9 @@ function handleRefresh() {
.device-info {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
padding-right: 12px;
}
.device-name {
@@ -180,5 +385,15 @@ function handleRefresh() {
.device-mac {
font-size: 12px;
color: #999999;
word-break: break-all;
}
.spinning {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>

873
src/utils/bluetooth.ts Normal file
View 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