/** * 艾灸椅 BLE Pinia Store * 真正的应用级单例,跨页面共享蓝牙状态 * * 用法: * import { useBleStore } from '@/stores/ble' * const bleStore = useBleStore() * * // 扫描 * await bleStore.startScan({ timeout: 15000, returnAll: true }) * * // 连接 * await bleStore.connectDevice(deviceId) * * // 发送指令 * await bleStore.sendBasic({ power: 1, moxiState: 1 }) * * // 读取状态(自动响应式) * bleStore.linked / bleStore.searching / bleStore.deviceStatus */ import { defineStore } from 'pinia' import logger from '@/utils/ble/logger.js' import { DEFAULT_CONFIG, BLE_STATE, BLE_ERROR, POWER, MOXI_STATE, MUTE, MODE, SUB_MODE, TEMPERATURE, CHAIR_ANGLE } from '@/utils/ble/constants.js' import { encodeBasic, encodeModeParam1, encodeModeParam2, encodeAcupoints, bufferToArrayBuffer, arrayBufferToU8, bytesToHex, FrameParser } from '@/utils/ble/protocol.js' import { ensureBlePrerequisite, openLocationSettings } from '@/utils/ble/permission.js' // ========== 跨 nvue 页面共享内部变量(通过 globalData 确保单例) ========== function _getShared() { const app = getApp() if (!app.globalData) app.globalData = {} if (!app.globalData._bleInternal) { app.globalData._bleInternal = { instanceId: Date.now() + '_' + Math.random().toString(36).slice(2, 6), serviceId: null, writeCharId: null, notifyCharId: null, reconnectTimer: null, reconnectCount: 0, reconnectGeneration: 0, // 重连代数,用于丢弃僵尸回调 writeLock: Promise.resolve(), bound: false, cancelScan: null, scanAborted: false, // 标记扫描已被外部中止,防止僵尸扫描 onDeviceFound: null, // 当前扫描的设备发现回调 scanStartHistory: [], // 近期 startDiscovery 调用时间戳,用于检测 Android 限流 intentionalDisconnect: false, // 标记主动断开,防止系统回调误触发重连 _connectedBeforeScan: false, // 标记扫描前是否处于已连接状态 parser: null, config: { ...DEFAULT_CONFIG } } console.log(`[BLE Store] 首次创建共享实例,instanceId = ${app.globalData._bleInternal.instanceId}`) } return app.globalData._bleInternal } // 兼容模块加载阶段(getApp()可能未就绪),延迟到首次调用时获取 let _shared = null function _S() { if (!_shared) _shared = _getShared() return _shared } // 便捷访问 function _instanceId() { return _S().instanceId } // ========== 工具函数 ========== function _invoke(apiFn, params) { return new Promise((resolve, reject) => { apiFn({ ...params, success: resolve, fail: reject, complete: () => {} }) }) } function _err(code, origin) { const err = new Error(code) err.code = code if (origin) err.origin = origin return err } function _uuidEq(a, b) { return String(a || '').toLowerCase() === String(b || '').toLowerCase() } export const useBleStore = defineStore('ble', { state: () => ({ // ===== 连接状态 ===== bleState: BLE_STATE.IDLE, device: null, // { deviceId, name, RSSI } linked: false, // ===== 蓝牙开关状态 ===== bleAdapterOff: false, // 蓝牙适配器未开启,页面可监听此状态展示提示 // ===== 扫描 ===== searching: false, scannedDevices: [], scanThrottled: false, // 检测到 Android 扫描限流时为 true,页面可监听此状态提示用户 // ===== 设备运行状态(来自上报) ===== deviceStatus: 0, // 0停止 1预热 2点火 3艾灸 4灭火 5暂停 hotPercentage: '0%', ispreHot: false, subTime: '00:00:00', modeType: 0, // 0无艾灸 1专业 2自定义 3专家 chairAngle: 90, // ===== 重连 ===== reconnectDrawer: false, reconnectCount: 0, // ===== 异常 ===== excepDrawer: false, exceTxt: 0, // ===== 耗材状态 ===== otherSetting: { aijiuNum: '0', lvxinNum: '0', huishouNum: '0' } }), actions: { // ============== 配置 ============== configure(opt = {}) { const s = _S() s.config = { ...s.config, ...opt } logger.setEnabled(s.config.debug) }, // ============== 内部状态管理 ============== _setState(s) { if (this.bleState === s) return const prevState = this.bleState this.bleState = s // 进入扫描状态时,如果当前有活跃连接,不应丢失 linked 状态 if (s === BLE_STATE.SCANNING && prevState === BLE_STATE.READY_COMM) { _S()._connectedBeforeScan = true // linked 保持 true,仅更新 searching } else if (s === BLE_STATE.READY && _S()._connectedBeforeScan && this.device) { // 扫描结束恢复连接状态:物理连接仍存活 _S()._connectedBeforeScan = false this.bleState = BLE_STATE.READY_COMM this.linked = true } else { _S()._connectedBeforeScan = false this.linked = (s === BLE_STATE.READY_COMM) } this.searching = (s === BLE_STATE.SCANNING) logger.info(`[instanceId=${_instanceId()}] state ->`, s, this.linked ? '(linked)' : '') }, // ============== 初始化 / 释放 ============== async init() { console.log(`[BLE Store] init() called, instanceId = ${_instanceId()}`) if (this.bleState !== BLE_STATE.IDLE && this.bleState !== BLE_STATE.DISCONNECTED) { logger.info('蓝牙已初始化,跳过重复初始化') return } try { await _invoke(uni.openBluetoothAdapter, {}) this.bleAdapterOff = false } catch (e) { logger.error('openBluetoothAdapter fail', e) const code = e && (e.errCode || e.code) const isOff = code === 10001 if (isOff) { this.bleAdapterOff = true this._showBleOffPrompt('请先开启手机蓝牙,才能连接艾灸椅设备') } throw _err(isOff ? BLE_ERROR.ADAPTER_OFF : BLE_ERROR.NOT_SUPPORT, e) } this._bindSystemListeners() this._setState(BLE_STATE.READY) logger.info('蓝牙适配器已初始化') }, async destroy() { this._clearReconnect() const s = _S() if (s.cancelScan) { s.cancelScan(); s.cancelScan = null } s.onDeviceFound = null try { await this.disconnect() } catch (_) {} try { await _invoke(uni.closeBluetoothAdapter, {}) } catch (_) {} this._unbindSystemListeners() this._setState(BLE_STATE.IDLE) if (s.parser) s.parser.reset() }, /** * 轻量级重置:只关闭适配器并重置状态,不解绑全局监听器 * 适用于页面切换时清除残留扫描/连接状态 */ async resetAdapter() { const s = _S() s.intentionalDisconnect = true this._clearReconnect() if (s.cancelScan) { s.cancelScan(); s.cancelScan = null } s.onDeviceFound = null try { await _invoke(uni.closeBluetoothAdapter, {}) } catch (_) {} this._setState(BLE_STATE.IDLE) if (s.parser) s.parser.reset() s.intentionalDisconnect = false }, /** * 绑定BLE系统监听器 - 必须在 App.vue onLaunch 中调用 * 确保回调绑定到 App.vue 的 taskCenter(永不销毁) */ bindGlobalListeners() { if (_S().bound) return this._bindSystemListeners() }, _bindSystemListeners() { const s = _S() if (s.bound) return // 初始化帧解析器 if (!s.parser) { s.parser = new FrameParser( (decoded, frame) => this._onFrame(decoded, frame), (err, frame) => logger.warn('frame parse error', err.message, bytesToHex(frame)) ) } uni.onBluetoothAdapterStateChange(this._onAdapterStateChange = (res) => { logger.info('adapterStateChange', res) const s = _S() if (!res.available) { // 主动关闭适配器时不显示提示且不触发重连 if (s.intentionalDisconnect) return this.bleAdapterOff = true this._showBleOffPrompt('蓝牙已关闭,设备连接已断开。请重新开启蓝牙以继续使用') this._handleDisconnected(BLE_ERROR.ADAPTER_OFF) } else { this.bleAdapterOff = false // 蓝牙重新开启:如果之前有连接过的设备且当前处于断连状态,自动发起重连 this._onAdapterRestored() } }) 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)) if (s.parser) s.parser.feed(u8) }) // 永久注册设备发现监听器(不可反复 on/off,否则多次后系统丢失监听) uni.onBluetoothDeviceFound((res) => { const deviceCount = res && res.devices ? res.devices.length : 0 console.log(`[BLE Global] onBluetoothDeviceFound 触发, 设备数=${deviceCount}, s.onDeviceFound=${!!s.onDeviceFound}`) // 立即在系统回调内转换 advertisData,避免原生 ArrayBuffer 被平台回收 if (res && res.devices) { res.devices.forEach(device => { if (device.advertisData && device.advertisData.byteLength > 0) { const bytes = new Uint8Array(device.advertisData) device._advertisDataHex = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('') console.log(`[BLE Scan] 广播数据: ${device._advertisDataHex}`) } else { device._advertisDataHex = '' } }) } if (s.onDeviceFound) s.onDeviceFound(res) }) s.bound = true logger.info(`[instanceId=${s.instanceId}] BLE系统监听器已全局绑定(含onBluetoothDeviceFound)`) }, _unbindSystemListeners() { // 全局监听器不再主动解绑,防止 taskCenter 丢失 // 仅在极端情况下(如蓝牙完全不再使用)才解绑 }, // ============== 确保就绪 ============== async _ensureReady() { if (this.bleState === BLE_STATE.IDLE) await this.init() try { const res = await _invoke(uni.getBluetoothAdapterState, {}) if (!res.available) { this.bleAdapterOff = true this._showBleOffPrompt('请先开启手机蓝牙,才能连接艾灸椅设备') throw _err(BLE_ERROR.ADAPTER_OFF) } this.bleAdapterOff = false } catch (e) { if (e && e.code && String(e.code).startsWith('BLE_')) throw e throw _err(BLE_ERROR.ADAPTER_OFF, e) } }, // ============== 扫描 ============== async startScan(opt = {}) { const s = _S() const platform = uni.getSystemInfoSync().platform console.log(`[BLE Scan] ====== startScan 开始 ====== platform=${platform}, instanceId=${s.instanceId}`) console.log(`[BLE Scan] 当前状态: bleState=${this.bleState}, searching=${this.searching}, linked=${this.linked}, bound=${s.bound}`) // 重置中止标记 s.scanAborted = false this.scanThrottled = false // 取消上一次残留扫描 if (s.cancelScan) { console.log('[BLE Scan] 取消上一次残留扫描') s.cancelScan(); s.cancelScan = null } // 停止上一次发现(不使用 await _invoke,因为 iOS 无活跃扫描时不回调会导致卡死) console.log('[BLE Scan] 调用 stopBluetoothDevicesDiscovery 清除上一次扫描') try { uni.stopBluetoothDevicesDiscovery({ success: () => console.log('[BLE Scan] stopDiscovery 成功'), fail: (e) => console.log('[BLE Scan] stopDiscovery 失败(无害):', JSON.stringify(e)), complete: () => {} }) } catch (e) { console.log('[BLE Scan] stopDiscovery 异常(无害):', e) } // 给 iOS 一点时间处理 stop await new Promise(r => setTimeout(r, 100)) console.log('[BLE Scan] stopDiscovery 处理完毕,继续...') // iOS 专用修复:关闭并重新打开蓝牙适配器以清除 CoreBluetooth 外设缓存 // iOS 的 CBCentralManager 会缓存已发现的外设,单纯 stop/start 不会清除缓存 // 导致重新扫描时系统不再上报之前已发现的设备 // 只有 close + open adapter 才能让 CoreBluetooth 重置外设缓存 if (platform === 'ios' && !this.linked) { console.log('[BLE Scan] [iOS] 开始回收适配器以清除 CoreBluetooth 缓存...') s.intentionalDisconnect = true s.onDeviceFound = null try { await _invoke(uni.closeBluetoothAdapter, {}) console.log('[BLE Scan] [iOS] closeBluetoothAdapter 成功') } catch (e) { console.log('[BLE Scan] [iOS] closeBluetoothAdapter 失败(无害):', JSON.stringify(e)) } this._setState(BLE_STATE.IDLE) s.intentionalDisconnect = false // 等待 iOS BLE 栈完全释放资源 await new Promise(r => setTimeout(r, 300)) console.log('[BLE Scan] [iOS] 等待 300ms 后重新打开适配器...') // 重新打开适配器 try { await _invoke(uni.openBluetoothAdapter, {}) this.bleAdapterOff = false this._setState(BLE_STATE.READY) console.log('[BLE Scan] [iOS] openBluetoothAdapter 成功, state=READY') } catch (e) { console.log('[BLE Scan] [iOS] openBluetoothAdapter 失败:', JSON.stringify(e)) const code = e && (e.errCode || e.code) if (code === 10001) { this.bleAdapterOff = true throw _err(BLE_ERROR.ADAPTER_OFF, e) } throw _err(BLE_ERROR.NOT_SUPPORT, e) } // 再等 200ms 让 adapter 完全就绪 await new Promise(r => setTimeout(r, 200)) console.log('[BLE Scan] [iOS] 适配器回收完成,准备开始扫描') } console.log('[BLE Scan] 调用 _ensureReady...') await this._ensureReady() console.log('[BLE Scan] _ensureReady 完成, bleState=', this.bleState) // 如果在 await 期间页面已卸载并调用了 stopScan,直接中止 if (s.scanAborted) { console.log('[BLE Scan] 扫描已被中止(scanAborted=true),直接返回') return opt.returnAll ? [] : null } const { namePrefix = s.config.deviceNamePrefix, deviceName, services, timeout = s.config.scanTimeout, returnAll = false } = opt // 清空上次扫描结果 this.scannedDevices = [] console.log(`[BLE Scan] 扫描参数: namePrefix=${namePrefix || '(无)'}, deviceName=${deviceName || '(无)'}, timeout=${timeout}, returnAll=${returnAll}`) // Android 限流检测(30秒内最多5次,iOS 无此限制) if (platform === 'android') { const now = Date.now() s.scanStartHistory = s.scanStartHistory.filter(t => now - t < 30000) if (s.scanStartHistory.length >= 4) { this.scanThrottled = true logger.warn(`BLE scan throttle: ${s.scanStartHistory.length + 1} starts in 30s, system may ignore`) } s.scanStartHistory.push(now) } const devices = new Map() const matched = [] // iOS 上使用 allowDuplicatesKey=true 确保系统持续上报所有设备 // 避免 CoreBluetooth 缓存导致重新扫描时不报告已知设备 const allowDuplicates = (platform === 'ios') console.log(`[BLE Scan] allowDuplicatesKey=${allowDuplicates} (iOS=${platform === 'ios'})`) return new Promise(async (resolve, reject) => { let finished = false let foundCount = 0 // 保存扫描前的连接状态,扫描结束后恢复,避免覆盖 READY_COMM const wasConnected = (this.bleState === BLE_STATE.READY_COMM) const onFound = (res) => { if (finished) return for (const d of res.devices) { const advertisDataHex = d._advertisDataHex || '' const name = d.name || d.localName || '' // 重复设备:如果本次带有 advertisData 但之前没有,补充更新 if (devices.has(d.deviceId)) { if (advertisDataHex) { const existing = this.scannedDevices.find(item => item.deviceId === d.deviceId) if (existing && !existing.advertisData) { existing.advertisData = advertisDataHex console.log(`[BLE Scan] 补充广播数据: ${name || d.deviceId} -> ${advertisDataHex}`) } } continue } devices.set(d.deviceId, d) foundCount++ console.log(`[BLE Scan] 发现设备 #${foundCount}: name=${name}, deviceId=${d.deviceId}, RSSI=${d.RSSI}`) // advertisData为空的设备跳过,无法区分同名设备 if (!advertisDataHex) { console.log(`[BLE Scan] 跳过无广播数据的设备: ${name || d.deviceId}`) continue } const hit = (deviceName && name === deviceName) || (namePrefix && name.startsWith(namePrefix)) || (!deviceName && !namePrefix) if (hit) { matched.push(d) const exists = this.scannedDevices.find(item => item.deviceId === d.deviceId) if (!exists) { console.log(`[BLE Scan] ✅ 设备匹配并加入列表: ${name || d.deviceId}, 广播数据: ${advertisDataHex}`) this.scannedDevices.push({ deviceId: d.deviceId, name: name, RSSI: d.RSSI || '', advertisData: advertisDataHex }) } if (!returnAll) { finish(null, d); return } } } } const timer = setTimeout(() => { console.log(`[BLE Scan] 扫描超时(${timeout}ms),共发现 ${foundCount} 个设备,匹配 ${matched.length} 个`) if (returnAll) finish(null, matched) else if (matched.length) finish(null, matched[0]) else finish(_err(BLE_ERROR.SCAN_FAIL, { msg: '扫描超时,未发现目标设备' })) }, timeout) const finish = (err, data) => { if (finished) return finished = true console.log(`[BLE Scan] finish 被调用, err=${err ? err.message || err.code : 'null'}, 设备数=${Array.isArray(data) ? data.length : (data ? 1 : 0)}, wasConnected=${wasConnected}`) s.cancelScan = null s.onDeviceFound = null clearTimeout(timer) try { uni.stopBluetoothDevicesDiscovery({ success() {}, fail() {}, complete() {} }) } catch (_) {} // 如果扫描前是连接状态,恢复为 READY_COMM,不要降级为 READY if (wasConnected) { this._setState(BLE_STATE.READY_COMM) } else { this._setState(BLE_STATE.READY) } this.searching = false err ? reject(err) : resolve(data) } s.cancelScan = () => finish(null, returnAll ? matched : (matched[0] || null)) // 通过共享回调分发事件(监听器已在 _bindSystemListeners 中永久注册) s.onDeviceFound = onFound // 如果当前已连接,不调用 _setState 避免覆盖 linked 状态 if (!wasConnected) { this._setState(BLE_STATE.SCANNING) } else { this.searching = true } console.log(`[BLE Scan] s.onDeviceFound 已设置, s.bound=${s.bound}, wasConnected=${wasConnected}, 准备调用 startBluetoothDevicesDiscovery`) try { await _invoke(uni.startBluetoothDevicesDiscovery, { allowDuplicatesKey: allowDuplicates, interval: 0, services }) console.log('[BLE Scan] ✅ startBluetoothDevicesDiscovery 调用成功,等待设备回调...') } catch (e) { console.log('[BLE Scan] ❌ startBluetoothDevicesDiscovery 失败:', JSON.stringify(e)) finish(_err(BLE_ERROR.SCAN_FAIL, e)) } }) }, /** 停止扫描 */ stopScan() { const s = _S() s.scanAborted = true s.onDeviceFound = null if (s.cancelScan) { s.cancelScan() s.cancelScan = null } else { try { uni.stopBluetoothDevicesDiscovery({ success() {}, fail() {} }) } catch (e) {} } this.searching = false this.scanThrottled = false if (this.bleState === BLE_STATE.SCANNING) { // _setState(READY) 内部会检测 _connectedBeforeScan, // 若扫描前有连接则自动恢复为 READY_COMM this._setState(BLE_STATE.READY) } }, // ============== 连接 ============== async connectDevice(deviceId) { const s = _S() console.log(`[BLE Store] connectDevice() called, instanceId = ${s.instanceId}`) if (!deviceId) throw _err(BLE_ERROR.CONNECT_FAIL, { msg: 'deviceId 不能为空' }) await this._ensureReady() if (this.bleState === BLE_STATE.CONNECTING) throw _err(BLE_ERROR.BUSY) this._setState(BLE_STATE.CONNECTING) this.device = { deviceId, name: '' } console.log("连接蓝牙时的设备id",this.device) try { await _invoke(uni.createBLEConnection, { deviceId, timeout: s.config.connectTimeout }) this._setState(BLE_STATE.CONNECTED) // Android 提升 MTU // #ifdef APP-PLUS if (uni.getSystemInfoSync().platform === 'android' && uni.setBLEMTU) { try { await _invoke(uni.setBLEMTU, { deviceId, mtu: 185 }) } catch (_) {} } // #endif await this._discoverAndSubscribe(deviceId) this._setState(BLE_STATE.READY_COMM) s.reconnectCount = 0 } catch (e) { console.log("蓝牙连接失败",e) this._setState(BLE_STATE.DISCONNECTED) this.device = null // 连接失败,清空 device 防止延迟回调误触发重连 try { await _invoke(uni.closeBLEConnection, { deviceId }) } catch (_) {} const code = e && (e.errCode || e.code) if (code === 10003 || code === -1) { throw _err(BLE_ERROR.CONNECT_TIMEOUT, e) } throw _err(BLE_ERROR.CONNECT_FAIL, e) } }, /** 扫描 + 连接一步到位(支持 deviceId 直连优先,跳过扫描) */ async scanAndConnect(opt = {}) { const { deviceId: directId, deviceName, advertisData: passedAdvertisData, ...restOpt } = opt console.log("连接opt",opt,directId,passedAdvertisData,restOpt,{ ...restOpt }) // 策略:如果已有 deviceId,先尝试直连(不扫描),失败后回退扫描 if (directId) { try { logger.info(`directConnect attempt, deviceId=${directId}`) await this.connectDevice(directId) this.device.name = deviceName || this.device.name || '' // 存储 advertisData 作为跨平台设备标识 if (passedAdvertisData) { console.log("连接passedAdvertisData",passedAdvertisData) this.device.advertisData = passedAdvertisData } logger.info('directConnect success, scan skipped') return this.device } catch (e) { logger.warn('directConnect failed, fallback to scan', e.message || e.code) // connectDevice 失败时已清空 this.device 并关闭连接,无需额外处理 } } // 回退:扫描 + 连接 const scanOpt = { ...restOpt } let device = null if (passedAdvertisData) { // 有 advertisData 时:不传 deviceName 做名称过滤(后端名与蓝牙广播名可能不一致) // 扫描所有设备,再通过 advertisData 精确定位目标 scanOpt.returnAll = true const devices = await this.startScan(scanOpt) console.log(`[BLE scanAndConnect] 扫描到 ${devices.length} 个设备,目标 advertisData=${passedAdvertisData}`) // 精确匹配 advertisData device = devices.find(d => { const hex = d._advertisDataHex || d.advertisData || '' return hex === passedAdvertisData }) if (!device) { throw _err(BLE_ERROR.SCAN_FAIL, { msg: '未找到目标设备(advertisData不匹配)' }) } console.log(`[BLE scanAndConnect] ✅ 通过advertisData匹配到设备: name=${device.name || device.localName}, deviceId=${device.deviceId}`) } else { // 无 advertisData 时按设备名过滤,返回第一个匹配设备 if (deviceName) scanOpt.deviceName = deviceName device = await this.startScan(scanOpt) } await this.connectDevice(device.deviceId) this.device.name = device.name || device.localName || '' console.log("连接device._advertisDataHex",device._advertisDataHex) // 存储 advertisData(hex 字符串):优先用传入的(后台 deviceCode),其次用扫描结果的 hex // 注意:device.advertisData 是原始 ArrayBuffer,不能直接存储,需使用 _advertisDataHex this.device.advertisData = passedAdvertisData || device._advertisDataHex || device.advertisData || '' return this.device }, /** 主动断开 */ async disconnect() { const s = _S() s.intentionalDisconnect = true this._clearReconnect() if (!this.device) { s.intentionalDisconnect = false return } const { deviceId } = this.device // 先置空 device,防止 onBLEConnectionStateChange 回调误匹配 this.device = null this._setState(BLE_STATE.DISCONNECTED) try { await _invoke(uni.closeBLEConnection, { deviceId }) } catch (_) {} s.intentionalDisconnect = false }, /** 发现服务并订阅通知 */ async _discoverAndSubscribe(deviceId) { const s = _S() const svcRes = await _invoke(uni.getBLEDeviceServices, { deviceId }) const services = svcRes.services || [] const strict = s.config.strictUUID const targetSvc = (strict && services.find(sv => _uuidEq(sv.uuid, s.config.serviceId))) || services.find(sv => sv.isPrimary) || services[0] if (!targetSvc) throw _err(BLE_ERROR.SERVICE_NOT_FOUND) s.serviceId = targetSvc.uuid const charRes = await _invoke(uni.getBLEDeviceCharacteristics, { deviceId, serviceId: s.serviceId }) const chars = charRes.characteristics || [] const writeChar = (strict && chars.find(c => _uuidEq(c.uuid, s.config.writeCharId))) || chars.find(c => c.properties && (c.properties.write || c.properties.writeNoResponse || c.properties.writeDefault)) const notifyChar = (strict && chars.find(c => _uuidEq(c.uuid, s.config.notifyCharId))) || chars.find(c => c.properties && (c.properties.notify || c.properties.indicate)) if (!writeChar) throw _err(BLE_ERROR.CHAR_NOT_FOUND, { msg: '未找到写特征' }) if (!notifyChar) throw _err(BLE_ERROR.CHAR_NOT_FOUND, { msg: '未找到通知特征' }) s.writeCharId = writeChar.uuid s.notifyCharId = notifyChar.uuid await _invoke(uni.notifyBLECharacteristicValueChange, { deviceId, serviceId: s.serviceId, characteristicId: s.notifyCharId, state: true }) }, // ============== 断连处理 & 自动重连 ============== _handleDisconnected(reason) { const s = _S() // 主动断开/重置时不触发重连 if (s.intentionalDisconnect) return if (this.bleState === BLE_STATE.DISCONNECTED || this.bleState === BLE_STATE.IDLE) return const device = this.device this._setState(BLE_STATE.DISCONNECTED) if (s.parser) s.parser.reset() this._attemptReconnect(device, reason) }, _attemptReconnect(device, reason) { const s = _S() if (!s.config.autoReconnect || !device || s.reconnectCount >= s.config.maxReconnect) { if (s.reconnectCount >= s.config.maxReconnect) { logger.warn('reconnect max reached, waiting for adapter restore') s.reconnectCount = 0 this.reconnectDrawer = false this.reconnectCount = 0 } return } s.reconnectCount++ this.reconnectCount = s.reconnectCount this.reconnectDrawer = true const delay = Math.min(1000 * s.reconnectCount, 5000) logger.warn(`reconnect in ${delay}ms (${s.reconnectCount}/${s.config.maxReconnect})`) const gen = s.reconnectGeneration s.reconnectTimer = setTimeout(async () => { if (s.reconnectGeneration !== gen) { logger.info('reconnect callback aborted (generation mismatch)') return } try { // 先关闭适配器清除脏状态(加标志防止回调干扰) s.intentionalDisconnect = true try { await _invoke(uni.closeBluetoothAdapter, {}) } catch (_) {} s.intentionalDisconnect = false this._setState(BLE_STATE.IDLE) if (s.reconnectGeneration !== gen) return // 优先使用 deviceId 直连,避免不必要的扫描触发 Android 限流 const scanOpt = { timeout: 8000 } if (device.deviceId) scanOpt.deviceId = device.deviceId if (device.name) scanOpt.deviceName = device.name await this.scanAndConnect(scanOpt) if (s.reconnectGeneration !== gen) return this.reconnectDrawer = false this.reconnectCount = 0 logger.info('reconnect success') } catch (e) { if (s.reconnectGeneration !== gen) return logger.error('reconnect fail', e) this._attemptReconnect(device, reason) } }, delay) }, _clearReconnect() { const s = _S() if (s.reconnectTimer) { clearTimeout(s.reconnectTimer); s.reconnectTimer = null } s.reconnectCount = 0 s.reconnectGeneration++ // 递增代数,使正在执行的僵尸回调自动失效 this.reconnectCount = 0 this.reconnectDrawer = false }, /** * 蓝牙适配器从关闭恢复为开启时调用 * 如果之前有连接过的设备且当前处于断连状态,自动扫描并重新连接 */ _onAdapterRestored() { const device = this.device if (!device || !device.deviceId) return if (this.bleState !== BLE_STATE.DISCONNECTED && this.bleState !== BLE_STATE.IDLE) return logger.info('adapter restored, auto reconnecting to', device.name || device.deviceId) // 重置重连计数,发起新一轮重连 this._clearReconnect() this._setState(BLE_STATE.IDLE) // 延迟 1.5s 等待适配器完全就绪(Android 蓝牙状态延迟) const s = _S() const gen = s.reconnectGeneration this.reconnectDrawer = true this.reconnectCount = 1 s.reconnectTimer = setTimeout(async () => { if (s.reconnectGeneration !== gen) return try { // 关闭旧适配器,确保重新打开时状态干净 s.intentionalDisconnect = true try { await _invoke(uni.closeBluetoothAdapter, {}) } catch (_) {} s.intentionalDisconnect = false if (s.reconnectGeneration !== gen) return // 优先直连,避免扫描触发限流 const scanOpt = { timeout: 8000 } if (device.deviceId) scanOpt.deviceId = device.deviceId if (device.name) scanOpt.deviceName = device.name await this.scanAndConnect(scanOpt) if (s.reconnectGeneration !== gen) return this.reconnectDrawer = false this.reconnectCount = 0 logger.info('adapter restore reconnect success') } catch (e) { if (s.reconnectGeneration !== gen) return logger.error('adapter restore reconnect fail', e) // 失败后进入常规重连流程(还有2次机会) s.reconnectCount = 1 this._attemptReconnect(device, BLE_ERROR.ADAPTER_OFF) } }, 1500) }, // ============== 收包处理 ============== _onFrame(decoded, frame) { console.log("获取到消息",decoded) logger.info('frame received', decoded.funcCode) if (decoded.parsed) { if (decoded.parsed.type === 'GROUP_1') { this._handleGroup1(decoded.parsed) } else if (decoded.parsed.type === 'GROUP_2') { this._handleGroup2(decoded.parsed) } } }, /** 解析参数组1 */ _handleGroup1(data) { // 设备运行状态 switch (data.runtimeState) { case 0x00: this.deviceStatus = 0; break case 0x01: case 0x02: this.deviceStatus = 1; break case 0x03: this.deviceStatus = 3; break case 0x04: this.deviceStatus = 4; break case 0x05: this.deviceStatus = 5; break } // 模式 switch (data.mode) { case MODE.LEISURE: this.modeType = 0; break case MODE.PROFESSIONAL: this.modeType = 1; break case MODE.PERSONAL: this.modeType = 2; break case MODE.EXPERT: this.modeType = 3; break } // 剩余时间 const m = data.remainMinute || 0 const s = data.remainSecond || 0 const h = Math.floor(m / 60) const m1 = m % 60 this.subTime = `${h.toString().padStart(2, '0')}:${m1.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}` // 异常检测 if (data.foreignDetect === 0x02) { this.excepDrawer = true this.exceTxt = 1 } // 耗材状态 this.otherSetting.aijiuNum = String(data.consumable || 0) this.otherSetting.lvxinNum = String(data.filterPercent || 0) this.otherSetting.huishouNum = String(data.recycleBinPct || 0) }, /** 解析参数组2 */ _handleGroup2(data) { if (data.preheatPercent < 100) { this.ispreHot = false this.hotPercentage = data.preheatPercent + '%' } else { this.ispreHot = true this.hotPercentage = data.ignitePercent + '%' } // 椅子角度 if (data.chairAngle) { const angleMap = { 1: 90, 2: 105, 3: 120, 4: 135, 5: 150 } if (angleMap[data.chairAngle]) { this.chairAngle = angleMap[data.chairAngle] } } }, // ============== 发送指令 ============== async writeRaw(u8) { if (!this.linked) throw _err(BLE_ERROR.DISCONNECTED) const s = _S() const payload = bufferToArrayBuffer(u8) s.writeLock = s.writeLock.then(async () => { logger.log('=>', bytesToHex(u8)) logger.log('write params:', { deviceId: this.device.deviceId, serviceId: s.serviceId, characteristicId: s.writeCharId, valueByteLength: payload.byteLength }) try { await _invoke(uni.writeBLECharacteristicValue, { deviceId: this.device.deviceId, serviceId: s.serviceId, characteristicId: s.writeCharId, value: payload }) } catch (e) { logger.error('writeBLE origin error:', JSON.stringify(e)) throw _err(BLE_ERROR.WRITE_FAIL, e) } }) return s.writeLock }, /** 下发基本功能指令 (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)) }, /** 下发穴位坐标 */ 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 }) }, // ============== 蓝牙关闭提示 ============== /** * 弹窗提示用户蓝牙未开启,引导用户前往设置开启 * 内部做防抖,避免短时间内重复弹窗 */ _showBleOffPrompt(message) { const s = _S() // 弹窗正在显示中,不重复弹出 if (s._bleOffPromptShowing) return s._bleOffPromptShowing = true uni.showModal({ title: '蓝牙未开启', content: message || '请开启手机蓝牙后重试', confirmText: '去设置', cancelText: '取消', success: (res) => { s._bleOffPromptShowing = false if (res.confirm) { // #ifdef APP-PLUS const platform = uni.getSystemInfoSync().platform if (platform === 'android') { try { const main = plus.android.runtimeMainActivity() const Intent = plus.android.importClass('android.content.Intent') const Settings = plus.android.importClass('android.provider.Settings') const intent = new Intent(Settings.ACTION_BLUETOOTH_SETTINGS) main.startActivity(intent) } catch (e) { logger.error('跳转蓝牙设置失败', e) } } else if (platform === 'ios') { // iOS 可以打开 App 设置页 plus.runtime.openURL('App-Prefs:root=Bluetooth') } // #endif } } }) } } })