diff --git a/manifest.config.ts b/manifest.config.ts
index b07a012..275045b 100644
--- a/manifest.config.ts
+++ b/manifest.config.ts
@@ -39,7 +39,12 @@ export default defineManifestConfig({
'',
'',
'',
- '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
'',
'',
'',
@@ -48,7 +53,7 @@ export default defineManifestConfig({
'',
'',
'',
- ],
+ ] as any,
},
/* ios打包配置 */
ios: {},
diff --git a/src/auto-imports.d.ts b/src/auto-imports.d.ts
index ebdb95e..808a544 100644
--- a/src/auto-imports.d.ts
+++ b/src/auto-imports.d.ts
@@ -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
readonly asyncComputed: UnwrapRef
readonly autoResetRef: UnwrapRef
+ readonly bluetooth: UnwrapRef
readonly closeDatabase: UnwrapRef
readonly computed: UnwrapRef
readonly computedAsync: UnwrapRef
diff --git a/src/components/bluetooth/connect.vue b/src/components/bluetooth/connect.vue
index 8aa8a9c..8a24c8e 100644
--- a/src/components/bluetooth/connect.vue
+++ b/src/components/bluetooth/connect.vue
@@ -1,33 +1,212 @@
@@ -43,12 +222,17 @@ function handleRefresh() {
蓝牙开关
-
+
-
+
@@ -57,13 +241,22 @@ function handleRefresh() {
设备名称
-
+
+
+ {{ bluetoothEnabled ? '点击右上角刷新扫描附近设备' : '请先开启蓝牙' }}
+
+
-
+
{{ 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)"
>
连接
@@ -85,7 +280,7 @@ function handleRefresh() {
type="danger"
size="small"
round
- @tap="handleDisconnect(index)"
+ @tap="handleDisconnect(device)"
>
断开
@@ -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); }
}
diff --git a/src/utils/bluetooth.ts b/src/utils/bluetooth.ts
new file mode 100644
index 0000000..c6337e6
--- /dev/null
+++ b/src/utils/bluetooth.ts
@@ -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
+}
+
+/** 扫描参数 */
+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(),
+ connection: null as ActiveConnection | null,
+}
+
+const listeners = {
+ data: new Set(),
+ connection: new Set(),
+ found: new Set(),
+}
+
+let deviceFoundBound = false
+let connectionStateBound = false
+let characteristicValueBound = false
+let scanTimer: ReturnType | null = null
+
+// ======================== 内部工具 ========================
+
+function delay(ms: number) {
+ return new Promise(resolve => setTimeout(resolve, ms))
+}
+
+function normalizeId(id: string) {
+ return id.replace(/[:-]/g, '').toUpperCase()
+}
+
+function promisify(
+ api: (options: any) => void,
+ options: Record = {},
+): Promise {
+ 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 {
+ 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 {
+ 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 {
+ clearScanTimer()
+ state.scanning = false
+ state.devices.clear()
+ state.connection = null
+ try {
+ await promisify(uni.closeBluetoothAdapter)
+ }
+ finally {
+ state.opened = false
+ }
+ },
+
+ /** 蓝牙是否可用(已开启) */
+ async isEnabled(): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ clearScanTimer()
+ if (!state.scanning) {
+ try {
+ await promisify(uni.stopBluetoothDevicesDiscovery)
+ }
+ catch {}
+ return
+ }
+ state.scanning = false
+ try {
+ await promisify(uni.stopBluetoothDevicesDiscovery)
+ }
+ catch {}
+ },
+
+ /** 获取已发现的设备列表 */
+ async getDevices(): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ await this.write(stringToArrayBuffer(text), options)
+ },
+
+ /** 发送十六进制字符串,如 '01 0A FF' */
+ async sendHex(hex: string, options?: WriteOptions): Promise {
+ await this.write(hexToArrayBuffer(hex), options)
+ },
+
+ // ---------- 蓝牙接收数据 ----------
+
+ /**
+ * 启用指定特征值的 notify(若已 setupConnection 通常不必再调)
+ */
+ async enableNotify(params?: {
+ deviceId?: string
+ serviceId?: string
+ characteristicId?: string
+ }): Promise {
+ 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