/** * 艾灸椅 BLE 单例管理器 * * 使用说明: * import bleManager from '@/utils/ble' * * // 1. 初始化适配器 * await bleManager.init() * * // 2. 扫描并连接(按名称) * const device = await bleManager.scan({ namePrefix: 'AJY', timeout: 8000 }) * await bleManager.connect(device.deviceId) * * // 3. 订阅上报 * bleManager.on('report', data => console.log(data)) * bleManager.on('state', s => console.log('状态:', s)) * * // 4. 发送指令 * await bleManager.sendBasic({ power: 1, moxiState: 1, mode: 2, subMode: 1, temperature: 2, duration: 30 }) */ import EventEmitter from './EventEmitter.js' import logger from './logger.js' import { DEFAULT_CONFIG, BLE_STATE, BLE_ERROR, POWER, MOXI_STATE, MUTE, MODE, SUB_MODE, TEMPERATURE, CHAIR_ANGLE } from './constants.js' import { encodeBasic, encodeModeParam1, encodeModeParam2, encodeAcupoints, bufferToArrayBuffer, arrayBufferToU8, bytesToHex, FrameParser } from './protocol.js' class BleManager extends EventEmitter { constructor() { super() this.config = { ...DEFAULT_CONFIG } this._state = BLE_STATE.IDLE this._device = null // { deviceId, name, RSSI } this._serviceId = null this._writeCharId = null this._notifyCharId = null this._reconnectCount = 0 this._reconnectTimer = null this._parser = new FrameParser( (decoded, frame) => this._onFrame(decoded, frame), (err, frame) => logger.warn('frame parse error', err.message, bytesToHex(frame)) ) this._writeLock = Promise.resolve() // 写入串行化 this._bound = false // 是否已绑定系统监听 this._cancelScan = null // 取消残留扫描的回调 } // ============== 基础 ============== /** 获取单例 */ static getInstance() { if (!BleManager._instance) BleManager._instance = new BleManager() return BleManager._instance } /** 覆盖配置 */ configure(opt = {}) { this.config = { ...this.config, ...opt } logger.setEnabled(this.config.debug) } get state() { return this._state } get device() { return this._device } get isConnected() { return this._state === BLE_STATE.READY_COMM } _setState(s) { if (this._state === s) return this._state = s logger.info('state ->', s) this.emit('state', s) } // ============== 初始化 / 释放 ============== /** 打开蓝牙适配器 */ async init() { if (this._state !== BLE_STATE.IDLE && this._state !== BLE_STATE.DISCONNECTED) { console.log("蓝牙已经初始化不用重复初始化") return } try { await this._invoke(uni.openBluetoothAdapter, {}) } catch (e) { logger.error('openBluetoothAdapter fail', e) // errCode 10001 = 蓝牙未开启 const code = e && (e.errCode || e.code) throw this._err(code === 10001 ? BLE_ERROR.ADAPTER_OFF : BLE_ERROR.NOT_SUPPORT, e) } this._bindSystemListeners() this._setState(BLE_STATE.READY) console.log("蓝牙始化") } /** 彻底释放 */ async destroy() { this._clearReconnect() // 取消残留的扫描 if (this._cancelScan) { this._cancelScan(); this._cancelScan = null } try { await this.disconnect() } catch (_) {} try { await this._invoke(uni.closeBluetoothAdapter, {}) } catch (_) {} this._unbindSystemListeners() this._setState(BLE_STATE.IDLE) this.clear() } _bindSystemListeners() { if (this._bound) return uni.onBluetoothAdapterStateChange(this._onAdapterStateChange = res => { logger.info('adapterStateChange', res) this.emit('adapterState', res) if (!res.available) { this._handleDisconnected(BLE_ERROR.ADAPTER_OFF) } }) uni.onBLEConnectionStateChange(this._onConnStateChange = res => { logger.info('connectionStateChange', res) if (!res.connected && this._device && res.deviceId === this._device.deviceId) { this._handleDisconnected(BLE_ERROR.DISCONNECTED) } }) uni.onBLECharacteristicValueChange(this._onCharChange = res => { const u8 = arrayBufferToU8(res.value) logger.log('<=', bytesToHex(u8)) this._parser.feed(u8) }) this._bound = true } _unbindSystemListeners() { if (!this._bound) return try { uni.offBluetoothAdapterStateChange && uni.offBluetoothAdapterStateChange(this._onAdapterStateChange) } catch (_) {} try { uni.offBLEConnectionStateChange && uni.offBLEConnectionStateChange(this._onConnStateChange) } catch (_) {} try { uni.offBLECharacteristicValueChange && uni.offBLECharacteristicValueChange(this._onCharChange) } catch (_) {} this._bound = false } // ============== 扫描 ============== /** * 扫描设备 * @param {Object} opt * @param {string} [opt.namePrefix] 名称前缀过滤 * @param {string} [opt.deviceName] 精确匹配名 * @param {string[]} [opt.services] 服务UUID过滤(iOS需要) * @param {number} [opt.timeout] * @param {boolean} [opt.returnAll] 为 true 时返回所有匹配设备数组, 默认返回首个 * @returns {Promise} */ async scan(opt = {}) { // 取消上一次残留的扫描(如 search 页面未正确清理) if (this._cancelScan) { this._cancelScan(); this._cancelScan = null } await this._ensureReady() const { namePrefix = this.config.deviceNamePrefix, deviceName, services, timeout = this.config.scanTimeout, returnAll = false } = opt const devices = new Map() const matched = [] return new Promise(async (resolve, reject) => { let finished = false // 防重入:确保 finish 只执行一次 const onFound = res => { if (finished) return for (const d of res.devices) { if (devices.has(d.deviceId)) continue devices.set(d.deviceId, d) const name = d.name || d.localName || '' const hit = (deviceName && name === deviceName) || (namePrefix && name.startsWith(namePrefix)) || (!deviceName && !namePrefix) if (hit) { matched.push(d) this.emit('deviceFound', d) if (!returnAll) { finish(null, d); return } } } } const timer = setTimeout(() => { if (returnAll) finish(null, matched) else if (matched.length) finish(null, matched[0]) else finish(this._err(BLE_ERROR.SCAN_FAIL, { msg: '扫描超时,未发现目标设备' })) }, timeout) const finish = (err, data) => { if (finished) return // 防重入 finished = true this._cancelScan = null clearTimeout(timer) uni.offBluetoothDeviceFound && uni.offBluetoothDeviceFound(onFound) this._invoke(uni.stopBluetoothDevicesDiscovery, {}).catch(() => {}) this._setState(BLE_STATE.READY) err ? reject(err) : resolve(data) } // 暴露取消句柄,供 destroy() / 下次 scan() 调用 this._cancelScan = () => finish(this._err(BLE_ERROR.SCAN_FAIL, { msg: '扫描已取消' })) uni.onBluetoothDeviceFound(onFound) this._setState(BLE_STATE.SCANNING) try { await this._invoke(uni.startBluetoothDevicesDiscovery, { allowDuplicatesKey: false, interval: 0, services }) } catch (e) { finish(this._err(BLE_ERROR.SCAN_FAIL, e)) } }) } // ============== 连接 ============== /** * 连接指定设备 * @param {string} deviceId */ async connect(deviceId) { if (!deviceId) throw this._err(BLE_ERROR.CONNECT_FAIL, { msg: 'deviceId 不能为空' }) await this._ensureReady() if (this._state === BLE_STATE.CONNECTING) throw this._err(BLE_ERROR.BUSY) this._setState(BLE_STATE.CONNECTING) this._device = { deviceId, name: '' } try { await this._invoke(uni.createBLEConnection, { deviceId, timeout: this.config.connectTimeout }) this._setState(BLE_STATE.CONNECTED) // Android 提升 MTU (忽略失败) // #ifdef APP-PLUS if (uni.getSystemInfoSync().platform === 'android' && uni.setBLEMTU) { try { await this._invoke(uni.setBLEMTU, { deviceId, mtu: 185 }) } catch (_) {} } // #endif await this._discoverAndSubscribe(deviceId) this._setState(BLE_STATE.READY_COMM) this._reconnectCount = 0 this.emit('connected', this._device) } catch (e) { this._setState(BLE_STATE.DISCONNECTED) try { await this._invoke(uni.closeBLEConnection, { deviceId }) } catch (_) {} const code = e && (e.errCode || e.code) if (code === 10003 || code === -1) { throw this._err(BLE_ERROR.CONNECT_TIMEOUT, e) } throw this._err(BLE_ERROR.CONNECT_FAIL, e) } } /** 扫描 + 连接一步到位 */ async scanAndConnect(opt) { const device = await this.scan(opt) await this.connect(device.deviceId) this._device.name = device.name || device.localName || '' return this._device } /** 主动断开 */ async disconnect() { this._clearReconnect() if (!this._device) return const { deviceId } = this._device try { await this._invoke(uni.closeBLEConnection, { deviceId }) } catch (_) {} this._device = null this._setState(BLE_STATE.DISCONNECTED) this.emit('disconnected', { manual: true }) } /** 发现服务并订阅通知 */ async _discoverAndSubscribe(deviceId) { // 1) 服务列表 const svcRes = await this._invoke(uni.getBLEDeviceServices, { deviceId }) const services = svcRes.services || [] const targetSvc = services.find(s => this._uuidEq(s.uuid, this.config.serviceId)) || services.find(s => s.isPrimary) || services[0] if (!targetSvc) throw this._err(BLE_ERROR.SERVICE_NOT_FOUND) this._serviceId = targetSvc.uuid // 2) 特征值 const charRes = await this._invoke(uni.getBLEDeviceCharacteristics, { deviceId, serviceId: this._serviceId }) const chars = charRes.characteristics || [] const writeChar = chars.find(c => this._uuidEq(c.uuid, this.config.writeCharId)) || chars.find(c => c.properties && (c.properties.write || c.properties.writeNoResponse || c.properties.writeDefault)) const notifyChar = chars.find(c => this._uuidEq(c.uuid, this.config.notifyCharId)) || chars.find(c => c.properties && (c.properties.notify || c.properties.indicate)) if (!writeChar) throw this._err(BLE_ERROR.CHAR_NOT_FOUND, { msg: '未找到写特征' }) if (!notifyChar) throw this._err(BLE_ERROR.CHAR_NOT_FOUND, { msg: '未找到通知特征' }) this._writeCharId = writeChar.uuid this._notifyCharId = notifyChar.uuid // 3) 开启通知 await this._invoke(uni.notifyBLECharacteristicValueChange, { deviceId, serviceId: this._serviceId, characteristicId: this._notifyCharId, state: true }) } _uuidEq(a, b) { return String(a || '').toLowerCase() === String(b || '').toLowerCase() } // ============== 断连处理 & 自动重连 ============== _handleDisconnected(reason) { if (this._state === BLE_STATE.DISCONNECTED || this._state === BLE_STATE.IDLE) return const device = this._device this._setState(BLE_STATE.DISCONNECTED) this.emit('disconnected', { manual: false, reason }) this._parser.reset() // 尝试自动重连 this._attemptReconnect(device, reason) } _attemptReconnect(device, reason) { if (!this.config.autoReconnect || !device || this._reconnectCount >= this.config.maxReconnect) { if (this._reconnectCount >= this.config.maxReconnect) { this.emit('reconnectFailed') } return } this._reconnectCount++ const delay = Math.min(1000 * this._reconnectCount, 5000) logger.warn(`reconnect in ${delay}ms (${this._reconnectCount}/${this.config.maxReconnect})`) this.emit('reconnecting', { count: this._reconnectCount, max: this.config.maxReconnect }) this._reconnectTimer = setTimeout(async () => { try { await this.init() await this.connect(device.deviceId) this.emit('reconnected', this._device) } catch (e) { logger.error('reconnect fail', e) this._attemptReconnect(device, reason) } }, delay) } _clearReconnect() { if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null } this._reconnectCount = 0 } // ============== 收包 ============== _onFrame(decoded, frame) { // 结构化: { funcCode, payload, parsed } this.emit('frame', { ...decoded, raw: frame }) if (decoded.parsed) this.emit('report', decoded.parsed) // 分类事件, 便于精细订阅 if (decoded.parsed && decoded.parsed.type) { this.emit(`report:${decoded.parsed.type}`, decoded.parsed) } } // ============== 发送 ============== /** 低层: 写入任意字节 */ async writeRaw(u8) { if (!this.isConnected) throw this._err(BLE_ERROR.DISCONNECTED) const payload = bufferToArrayBuffer(u8) // 串行写入, 避免并发写同一特征 this._writeLock = this._writeLock.then(async () => { logger.log('=>', bytesToHex(u8)) try { await this._invoke(uni.writeBLECharacteristicValue, { deviceId: this._device.deviceId, serviceId: this._serviceId, characteristicId: this._writeCharId, value: payload }) } catch (e) { throw this._err(BLE_ERROR.WRITE_FAIL, e) } }) return this._writeLock } /** * 发送任意帧 (不等应用层回复, 只等底层 ack) * @param {Uint8Array} frame */ send(frame) { return this.writeRaw(frame) } // ============== 高层业务接口 ============== /** 下发基本功能指令 (0x01) */ sendBasic(opt) { return this.writeRaw(encodeBasic(opt)) } /** 下发模式参数 1 (步骤 1-7) */ sendModeParam1(opt) { return this.writeRaw(encodeModeParam1(opt)) } /** 下发模式参数 2 (步骤 8-14) */ sendModeParam2(opt) { return this.writeRaw(encodeModeParam2(opt)) } /** 下发穴位坐标 (每包 2 个穴位, 可传任意数量, 内部自动分包) */ async sendAcupoints(points = []) { for (let i = 0; i < points.length; i += 2) { const pair = points.slice(i, i + 2) await this.writeRaw(encodeAcupoints(pair)) } } // ---- 常用快捷方法 ---- powerOn() { return this.sendBasic({ power: POWER.ON }) } powerOff() { return this.sendBasic({ power: POWER.OFF }) } startMoxi(opt = {}) { return this.sendBasic({ power: POWER.ON, moxiState: MOXI_STATE.START, ...opt }) } pauseMoxi() { return this.sendBasic({ moxiState: MOXI_STATE.PAUSE }) } stopMoxi() { return this.sendBasic({ moxiState: MOXI_STATE.DONE }) } setMute(on) { return this.sendBasic({ mute: on ? MUTE.ON : MUTE.OFF }) } setTemperature(v) { return this.sendBasic({ temperature: v }) } setChairAngle(v) { return this.sendBasic({ angle: v }) } // ============== 工具 ============== /** 封装 uni 回调为 Promise */ _invoke(apiFn, params) { return new Promise((resolve, reject) => { apiFn({ ...params, success: resolve, fail: reject, complete: () => {} }) }) } async _ensureReady() { if (this._state === BLE_STATE.IDLE) await this.init() // 二次校验 try { const res = await this._invoke(uni.getBluetoothAdapterState, {}) if (!res.available) throw this._err(BLE_ERROR.ADAPTER_OFF) } catch (e) { if (e && e.code && String(e.code).startsWith('BLE_')) throw e throw this._err(BLE_ERROR.ADAPTER_OFF, e) } } _err(code, origin) { const err = new Error(code) err.code = code if (origin) err.origin = origin return err } } export default BleManager