ble.js 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904
  1. /**
  2. * 艾灸椅 BLE Pinia Store
  3. * 真正的应用级单例,跨页面共享蓝牙状态
  4. *
  5. * 用法:
  6. * import { useBleStore } from '@/stores/ble'
  7. * const bleStore = useBleStore()
  8. *
  9. * // 扫描
  10. * await bleStore.startScan({ timeout: 15000, returnAll: true })
  11. *
  12. * // 连接
  13. * await bleStore.connectDevice(deviceId)
  14. *
  15. * // 发送指令
  16. * await bleStore.sendBasic({ power: 1, moxiState: 1 })
  17. *
  18. * // 读取状态(自动响应式)
  19. * bleStore.linked / bleStore.searching / bleStore.deviceStatus
  20. */
  21. import { defineStore } from 'pinia'
  22. import logger from '@/utils/ble/logger.js'
  23. import {
  24. DEFAULT_CONFIG, BLE_STATE, BLE_ERROR,
  25. POWER, MOXI_STATE, MUTE, MODE, SUB_MODE, TEMPERATURE, CHAIR_ANGLE
  26. } from '@/utils/ble/constants.js'
  27. import {
  28. encodeBasic, encodeModeParam1, encodeModeParam2, encodeAcupoints,
  29. bufferToArrayBuffer, arrayBufferToU8, bytesToHex, FrameParser
  30. } from '@/utils/ble/protocol.js'
  31. import { ensureBlePrerequisite, openLocationSettings } from '@/utils/ble/permission.js'
  32. // ========== 跨 nvue 页面共享内部变量(通过 globalData 确保单例) ==========
  33. function _getShared() {
  34. const app = getApp()
  35. if (!app.globalData) app.globalData = {}
  36. if (!app.globalData._bleInternal) {
  37. app.globalData._bleInternal = {
  38. instanceId: Date.now() + '_' + Math.random().toString(36).slice(2, 6),
  39. serviceId: null,
  40. writeCharId: null,
  41. notifyCharId: null,
  42. reconnectTimer: null,
  43. reconnectCount: 0,
  44. reconnectGeneration: 0, // 重连代数,用于丢弃僵尸回调
  45. writeLock: Promise.resolve(),
  46. bound: false,
  47. cancelScan: null,
  48. scanAborted: false, // 标记扫描已被外部中止,防止僵尸扫描
  49. onDeviceFound: null, // 当前扫描的设备发现回调
  50. scanStartHistory: [], // 近期 startDiscovery 调用时间戳,用于检测 Android 限流
  51. intentionalDisconnect: false, // 标记主动断开,防止系统回调误触发重连
  52. _connectedBeforeScan: false, // 标记扫描前是否处于已连接状态
  53. parser: null,
  54. config: { ...DEFAULT_CONFIG }
  55. }
  56. console.log(`[BLE Store] 首次创建共享实例,instanceId = ${app.globalData._bleInternal.instanceId}`)
  57. }
  58. return app.globalData._bleInternal
  59. }
  60. // 兼容模块加载阶段(getApp()可能未就绪),延迟到首次调用时获取
  61. let _shared = null
  62. function _S() {
  63. if (!_shared) _shared = _getShared()
  64. return _shared
  65. }
  66. // 便捷访问
  67. function _instanceId() { return _S().instanceId }
  68. // ========== 工具函数 ==========
  69. function _invoke(apiFn, params) {
  70. return new Promise((resolve, reject) => {
  71. apiFn({ ...params, success: resolve, fail: reject, complete: () => {} })
  72. })
  73. }
  74. function _err(code, origin) {
  75. const err = new Error(code)
  76. err.code = code
  77. if (origin) err.origin = origin
  78. return err
  79. }
  80. function _uuidEq(a, b) {
  81. return String(a || '').toLowerCase() === String(b || '').toLowerCase()
  82. }
  83. export const useBleStore = defineStore('ble', {
  84. state: () => ({
  85. // ===== 连接状态 =====
  86. bleState: BLE_STATE.IDLE,
  87. device: null, // { deviceId, name, RSSI }
  88. linked: false,
  89. // ===== 蓝牙开关状态 =====
  90. bleAdapterOff: false, // 蓝牙适配器未开启,页面可监听此状态展示提示
  91. // ===== 扫描 =====
  92. searching: false,
  93. scannedDevices: [],
  94. scanThrottled: false, // 检测到 Android 扫描限流时为 true,页面可监听此状态提示用户
  95. // ===== 设备运行状态(来自上报) =====
  96. deviceStatus: 0, // 0停止 1预热 2点火 3艾灸 4灭火 5暂停
  97. hotPercentage: '0%',
  98. ispreHot: false,
  99. subTime: '00:00:00',
  100. modeType: 0, // 0无艾灸 1专业 2自定义 3专家
  101. chairAngle: 90,
  102. // ===== 重连 =====
  103. reconnectDrawer: false,
  104. reconnectCount: 0,
  105. // ===== 异常 =====
  106. excepDrawer: false,
  107. exceTxt: 0,
  108. // ===== 耗材状态 =====
  109. otherSetting: {
  110. aijiuNum: '0',
  111. lvxinNum: '0',
  112. huishouNum: '0'
  113. }
  114. }),
  115. actions: {
  116. // ============== 配置 ==============
  117. configure(opt = {}) {
  118. const s = _S()
  119. s.config = { ...s.config, ...opt }
  120. logger.setEnabled(s.config.debug)
  121. },
  122. // ============== 内部状态管理 ==============
  123. _setState(s) {
  124. if (this.bleState === s) return
  125. const prevState = this.bleState
  126. this.bleState = s
  127. // 进入扫描状态时,如果当前有活跃连接,不应丢失 linked 状态
  128. if (s === BLE_STATE.SCANNING && prevState === BLE_STATE.READY_COMM) {
  129. _S()._connectedBeforeScan = true
  130. // linked 保持 true,仅更新 searching
  131. } else if (s === BLE_STATE.READY && _S()._connectedBeforeScan && this.device) {
  132. // 扫描结束恢复连接状态:物理连接仍存活
  133. _S()._connectedBeforeScan = false
  134. this.bleState = BLE_STATE.READY_COMM
  135. this.linked = true
  136. } else {
  137. _S()._connectedBeforeScan = false
  138. this.linked = (s === BLE_STATE.READY_COMM)
  139. }
  140. this.searching = (s === BLE_STATE.SCANNING)
  141. logger.info(`[instanceId=${_instanceId()}] state ->`, s, this.linked ? '(linked)' : '')
  142. },
  143. // ============== 初始化 / 释放 ==============
  144. async init() {
  145. console.log(`[BLE Store] init() called, instanceId = ${_instanceId()}`)
  146. if (this.bleState !== BLE_STATE.IDLE && this.bleState !== BLE_STATE.DISCONNECTED) {
  147. logger.info('蓝牙已初始化,跳过重复初始化')
  148. return
  149. }
  150. try {
  151. await _invoke(uni.openBluetoothAdapter, {})
  152. this.bleAdapterOff = false
  153. } catch (e) {
  154. logger.error('openBluetoothAdapter fail', e)
  155. const code = e && (e.errCode || e.code)
  156. const isOff = code === 10001
  157. if (isOff) {
  158. this.bleAdapterOff = true
  159. this._showBleOffPrompt('请先开启手机蓝牙,才能连接艾灸椅设备')
  160. }
  161. throw _err(isOff ? BLE_ERROR.ADAPTER_OFF : BLE_ERROR.NOT_SUPPORT, e)
  162. }
  163. this._bindSystemListeners()
  164. this._setState(BLE_STATE.READY)
  165. logger.info('蓝牙适配器已初始化')
  166. },
  167. async destroy() {
  168. this._clearReconnect()
  169. const s = _S()
  170. if (s.cancelScan) { s.cancelScan(); s.cancelScan = null }
  171. s.onDeviceFound = null
  172. try { await this.disconnect() } catch (_) {}
  173. try { await _invoke(uni.closeBluetoothAdapter, {}) } catch (_) {}
  174. this._unbindSystemListeners()
  175. this._setState(BLE_STATE.IDLE)
  176. if (s.parser) s.parser.reset()
  177. },
  178. /**
  179. * 轻量级重置:只关闭适配器并重置状态,不解绑全局监听器
  180. * 适用于页面切换时清除残留扫描/连接状态
  181. */
  182. async resetAdapter() {
  183. const s = _S()
  184. s.intentionalDisconnect = true
  185. this._clearReconnect()
  186. if (s.cancelScan) { s.cancelScan(); s.cancelScan = null }
  187. s.onDeviceFound = null
  188. try { await _invoke(uni.closeBluetoothAdapter, {}) } catch (_) {}
  189. this._setState(BLE_STATE.IDLE)
  190. if (s.parser) s.parser.reset()
  191. s.intentionalDisconnect = false
  192. },
  193. /**
  194. * 绑定BLE系统监听器 - 必须在 App.vue onLaunch 中调用
  195. * 确保回调绑定到 App.vue 的 taskCenter(永不销毁)
  196. */
  197. bindGlobalListeners() {
  198. if (_S().bound) return
  199. this._bindSystemListeners()
  200. },
  201. _bindSystemListeners() {
  202. const s = _S()
  203. if (s.bound) return
  204. // 初始化帧解析器
  205. if (!s.parser) {
  206. s.parser = new FrameParser(
  207. (decoded, frame) => this._onFrame(decoded, frame),
  208. (err, frame) => logger.warn('frame parse error', err.message, bytesToHex(frame))
  209. )
  210. }
  211. uni.onBluetoothAdapterStateChange(this._onAdapterStateChange = (res) => {
  212. logger.info('adapterStateChange', res)
  213. const s = _S()
  214. if (!res.available) {
  215. // 主动关闭适配器时不显示提示且不触发重连
  216. if (s.intentionalDisconnect) return
  217. this.bleAdapterOff = true
  218. this._showBleOffPrompt('蓝牙已关闭,设备连接已断开。请重新开启蓝牙以继续使用')
  219. this._handleDisconnected(BLE_ERROR.ADAPTER_OFF)
  220. } else {
  221. this.bleAdapterOff = false
  222. // 蓝牙重新开启:如果之前有连接过的设备且当前处于断连状态,自动发起重连
  223. this._onAdapterRestored()
  224. }
  225. })
  226. uni.onBLEConnectionStateChange(this._onConnStateChange = (res) => {
  227. logger.info('connectionStateChange', res)
  228. if (!res.connected && this.device && res.deviceId === this.device.deviceId) {
  229. this._handleDisconnected(BLE_ERROR.DISCONNECTED)
  230. }
  231. })
  232. uni.onBLECharacteristicValueChange(this._onCharChange = (res) => {
  233. const u8 = arrayBufferToU8(res.value)
  234. logger.log('<=', bytesToHex(u8))
  235. if (s.parser) s.parser.feed(u8)
  236. })
  237. // 永久注册设备发现监听器(不可反复 on/off,否则多次后系统丢失监听)
  238. uni.onBluetoothDeviceFound((res) => {
  239. const deviceCount = res && res.devices ? res.devices.length : 0
  240. console.log(`[BLE Global] onBluetoothDeviceFound 触发, 设备数=${deviceCount}, s.onDeviceFound=${!!s.onDeviceFound}`)
  241. if (s.onDeviceFound) s.onDeviceFound(res)
  242. })
  243. s.bound = true
  244. logger.info(`[instanceId=${s.instanceId}] BLE系统监听器已全局绑定(含onBluetoothDeviceFound)`)
  245. },
  246. _unbindSystemListeners() {
  247. // 全局监听器不再主动解绑,防止 taskCenter 丢失
  248. // 仅在极端情况下(如蓝牙完全不再使用)才解绑
  249. },
  250. // ============== 确保就绪 ==============
  251. async _ensureReady() {
  252. if (this.bleState === BLE_STATE.IDLE) await this.init()
  253. try {
  254. const res = await _invoke(uni.getBluetoothAdapterState, {})
  255. if (!res.available) {
  256. this.bleAdapterOff = true
  257. this._showBleOffPrompt('请先开启手机蓝牙,才能连接艾灸椅设备')
  258. throw _err(BLE_ERROR.ADAPTER_OFF)
  259. }
  260. this.bleAdapterOff = false
  261. } catch (e) {
  262. if (e && e.code && String(e.code).startsWith('BLE_')) throw e
  263. throw _err(BLE_ERROR.ADAPTER_OFF, e)
  264. }
  265. },
  266. // ============== 扫描 ==============
  267. async startScan(opt = {}) {
  268. const s = _S()
  269. const platform = uni.getSystemInfoSync().platform
  270. console.log(`[BLE Scan] ====== startScan 开始 ====== platform=${platform}, instanceId=${s.instanceId}`)
  271. console.log(`[BLE Scan] 当前状态: bleState=${this.bleState}, searching=${this.searching}, linked=${this.linked}, bound=${s.bound}`)
  272. // 重置中止标记
  273. s.scanAborted = false
  274. this.scanThrottled = false
  275. // 取消上一次残留扫描
  276. if (s.cancelScan) {
  277. console.log('[BLE Scan] 取消上一次残留扫描')
  278. s.cancelScan(); s.cancelScan = null
  279. }
  280. // 停止上一次发现(不使用 await _invoke,因为 iOS 无活跃扫描时不回调会导致卡死)
  281. console.log('[BLE Scan] 调用 stopBluetoothDevicesDiscovery 清除上一次扫描')
  282. try {
  283. uni.stopBluetoothDevicesDiscovery({
  284. success: () => console.log('[BLE Scan] stopDiscovery 成功'),
  285. fail: (e) => console.log('[BLE Scan] stopDiscovery 失败(无害):', JSON.stringify(e)),
  286. complete: () => {}
  287. })
  288. } catch (e) {
  289. console.log('[BLE Scan] stopDiscovery 异常(无害):', e)
  290. }
  291. // 给 iOS 一点时间处理 stop
  292. await new Promise(r => setTimeout(r, 100))
  293. console.log('[BLE Scan] stopDiscovery 处理完毕,继续...')
  294. // iOS 专用修复:关闭并重新打开蓝牙适配器以清除 CoreBluetooth 外设缓存
  295. // iOS 的 CBCentralManager 会缓存已发现的外设,单纯 stop/start 不会清除缓存
  296. // 导致重新扫描时系统不再上报之前已发现的设备
  297. // 只有 close + open adapter 才能让 CoreBluetooth 重置外设缓存
  298. if (platform === 'ios' && !this.linked) {
  299. console.log('[BLE Scan] [iOS] 开始回收适配器以清除 CoreBluetooth 缓存...')
  300. s.intentionalDisconnect = true
  301. s.onDeviceFound = null
  302. try {
  303. await _invoke(uni.closeBluetoothAdapter, {})
  304. console.log('[BLE Scan] [iOS] closeBluetoothAdapter 成功')
  305. } catch (e) {
  306. console.log('[BLE Scan] [iOS] closeBluetoothAdapter 失败(无害):', JSON.stringify(e))
  307. }
  308. this._setState(BLE_STATE.IDLE)
  309. s.intentionalDisconnect = false
  310. // 等待 iOS BLE 栈完全释放资源
  311. await new Promise(r => setTimeout(r, 300))
  312. console.log('[BLE Scan] [iOS] 等待 300ms 后重新打开适配器...')
  313. // 重新打开适配器
  314. try {
  315. await _invoke(uni.openBluetoothAdapter, {})
  316. this.bleAdapterOff = false
  317. this._setState(BLE_STATE.READY)
  318. console.log('[BLE Scan] [iOS] openBluetoothAdapter 成功, state=READY')
  319. } catch (e) {
  320. console.log('[BLE Scan] [iOS] openBluetoothAdapter 失败:', JSON.stringify(e))
  321. const code = e && (e.errCode || e.code)
  322. if (code === 10001) {
  323. this.bleAdapterOff = true
  324. throw _err(BLE_ERROR.ADAPTER_OFF, e)
  325. }
  326. throw _err(BLE_ERROR.NOT_SUPPORT, e)
  327. }
  328. // 再等 200ms 让 adapter 完全就绪
  329. await new Promise(r => setTimeout(r, 200))
  330. console.log('[BLE Scan] [iOS] 适配器回收完成,准备开始扫描')
  331. }
  332. console.log('[BLE Scan] 调用 _ensureReady...')
  333. await this._ensureReady()
  334. console.log('[BLE Scan] _ensureReady 完成, bleState=', this.bleState)
  335. // 如果在 await 期间页面已卸载并调用了 stopScan,直接中止
  336. if (s.scanAborted) {
  337. console.log('[BLE Scan] 扫描已被中止(scanAborted=true),直接返回')
  338. return opt.returnAll ? [] : null
  339. }
  340. const {
  341. namePrefix = s.config.deviceNamePrefix,
  342. deviceName,
  343. services,
  344. timeout = s.config.scanTimeout,
  345. returnAll = false
  346. } = opt
  347. // 清空上次扫描结果
  348. this.scannedDevices = []
  349. console.log(`[BLE Scan] 扫描参数: namePrefix=${namePrefix || '(无)'}, deviceName=${deviceName || '(无)'}, timeout=${timeout}, returnAll=${returnAll}`)
  350. // Android 限流检测(30秒内最多5次,iOS 无此限制)
  351. if (platform === 'android') {
  352. const now = Date.now()
  353. s.scanStartHistory = s.scanStartHistory.filter(t => now - t < 30000)
  354. if (s.scanStartHistory.length >= 4) {
  355. this.scanThrottled = true
  356. logger.warn(`BLE scan throttle: ${s.scanStartHistory.length + 1} starts in 30s, system may ignore`)
  357. }
  358. s.scanStartHistory.push(now)
  359. }
  360. const devices = new Map()
  361. const matched = []
  362. // iOS 上使用 allowDuplicatesKey=true 确保系统持续上报所有设备
  363. // 避免 CoreBluetooth 缓存导致重新扫描时不报告已知设备
  364. const allowDuplicates = (platform === 'ios')
  365. console.log(`[BLE Scan] allowDuplicatesKey=${allowDuplicates} (iOS=${platform === 'ios'})`)
  366. return new Promise(async (resolve, reject) => {
  367. let finished = false
  368. let foundCount = 0
  369. // 保存扫描前的连接状态,扫描结束后恢复,避免覆盖 READY_COMM
  370. const wasConnected = (this.bleState === BLE_STATE.READY_COMM)
  371. const onFound = (res) => {
  372. if (finished) return
  373. for (const d of res.devices) {
  374. if (devices.has(d.deviceId)) continue
  375. devices.set(d.deviceId, d)
  376. foundCount++
  377. const name = d.name || d.localName || ''
  378. console.log(`[BLE Scan] 发现设备 #${foundCount}: name=${name}, deviceId=${d.deviceId}, RSSI=${d.RSSI}`)
  379. const hit =
  380. (deviceName && name === deviceName) ||
  381. (namePrefix && name.startsWith(namePrefix)) ||
  382. (!deviceName && !namePrefix)
  383. if (hit) {
  384. matched.push(d)
  385. const exists = this.scannedDevices.find(item => item.deviceId === d.deviceId)
  386. if (!exists) {
  387. console.log(`[BLE Scan] ✅ 设备匹配并加入列表: ${name || d.deviceId}`)
  388. this.scannedDevices.push({
  389. deviceId: d.deviceId,
  390. name: name,
  391. RSSI: d.RSSI || ''
  392. })
  393. }
  394. if (!returnAll) { finish(null, d); return }
  395. }
  396. }
  397. }
  398. const timer = setTimeout(() => {
  399. console.log(`[BLE Scan] 扫描超时(${timeout}ms),共发现 ${foundCount} 个设备,匹配 ${matched.length} 个`)
  400. if (returnAll) finish(null, matched)
  401. else if (matched.length) finish(null, matched[0])
  402. else finish(_err(BLE_ERROR.SCAN_FAIL, { msg: '扫描超时,未发现目标设备' }))
  403. }, timeout)
  404. const finish = (err, data) => {
  405. if (finished) return
  406. finished = true
  407. console.log(`[BLE Scan] finish 被调用, err=${err ? err.message || err.code : 'null'}, 设备数=${Array.isArray(data) ? data.length : (data ? 1 : 0)}, wasConnected=${wasConnected}`)
  408. s.cancelScan = null
  409. s.onDeviceFound = null
  410. clearTimeout(timer)
  411. try {
  412. uni.stopBluetoothDevicesDiscovery({ success() {}, fail() {}, complete() {} })
  413. } catch (_) {}
  414. // 如果扫描前是连接状态,恢复为 READY_COMM,不要降级为 READY
  415. if (wasConnected) {
  416. this._setState(BLE_STATE.READY_COMM)
  417. } else {
  418. this._setState(BLE_STATE.READY)
  419. }
  420. this.searching = false
  421. err ? reject(err) : resolve(data)
  422. }
  423. s.cancelScan = () => finish(null, returnAll ? matched : (matched[0] || null))
  424. // 通过共享回调分发事件(监听器已在 _bindSystemListeners 中永久注册)
  425. s.onDeviceFound = onFound
  426. // 如果当前已连接,不调用 _setState 避免覆盖 linked 状态
  427. if (!wasConnected) {
  428. this._setState(BLE_STATE.SCANNING)
  429. } else {
  430. this.searching = true
  431. }
  432. console.log(`[BLE Scan] s.onDeviceFound 已设置, s.bound=${s.bound}, wasConnected=${wasConnected}, 准备调用 startBluetoothDevicesDiscovery`)
  433. try {
  434. await _invoke(uni.startBluetoothDevicesDiscovery, {
  435. allowDuplicatesKey: allowDuplicates,
  436. interval: 0,
  437. services
  438. })
  439. console.log('[BLE Scan] ✅ startBluetoothDevicesDiscovery 调用成功,等待设备回调...')
  440. } catch (e) {
  441. console.log('[BLE Scan] ❌ startBluetoothDevicesDiscovery 失败:', JSON.stringify(e))
  442. finish(_err(BLE_ERROR.SCAN_FAIL, e))
  443. }
  444. })
  445. },
  446. /** 停止扫描 */
  447. stopScan() {
  448. const s = _S()
  449. s.scanAborted = true
  450. s.onDeviceFound = null
  451. if (s.cancelScan) {
  452. s.cancelScan()
  453. s.cancelScan = null
  454. } else {
  455. try {
  456. uni.stopBluetoothDevicesDiscovery({ success() {}, fail() {} })
  457. } catch (e) {}
  458. }
  459. this.searching = false
  460. this.scanThrottled = false
  461. if (this.bleState === BLE_STATE.SCANNING) {
  462. // _setState(READY) 内部会检测 _connectedBeforeScan,
  463. // 若扫描前有连接则自动恢复为 READY_COMM
  464. this._setState(BLE_STATE.READY)
  465. }
  466. },
  467. // ============== 连接 ==============
  468. async connectDevice(deviceId) {
  469. const s = _S()
  470. console.log(`[BLE Store] connectDevice() called, instanceId = ${s.instanceId}`)
  471. if (!deviceId) throw _err(BLE_ERROR.CONNECT_FAIL, { msg: 'deviceId 不能为空' })
  472. await this._ensureReady()
  473. if (this.bleState === BLE_STATE.CONNECTING) throw _err(BLE_ERROR.BUSY)
  474. this._setState(BLE_STATE.CONNECTING)
  475. this.device = { deviceId, name: '' }
  476. try {
  477. await _invoke(uni.createBLEConnection, {
  478. deviceId,
  479. timeout: s.config.connectTimeout
  480. })
  481. this._setState(BLE_STATE.CONNECTED)
  482. // Android 提升 MTU
  483. // #ifdef APP-PLUS
  484. if (uni.getSystemInfoSync().platform === 'android' && uni.setBLEMTU) {
  485. try { await _invoke(uni.setBLEMTU, { deviceId, mtu: 185 }) } catch (_) {}
  486. }
  487. // #endif
  488. await this._discoverAndSubscribe(deviceId)
  489. this._setState(BLE_STATE.READY_COMM)
  490. s.reconnectCount = 0
  491. } catch (e) {
  492. this._setState(BLE_STATE.DISCONNECTED)
  493. this.device = null // 连接失败,清空 device 防止延迟回调误触发重连
  494. try { await _invoke(uni.closeBLEConnection, { deviceId }) } catch (_) {}
  495. const code = e && (e.errCode || e.code)
  496. if (code === 10003 || code === -1) {
  497. throw _err(BLE_ERROR.CONNECT_TIMEOUT, e)
  498. }
  499. throw _err(BLE_ERROR.CONNECT_FAIL, e)
  500. }
  501. },
  502. /** 扫描 + 连接一步到位(支持 deviceId 直连优先,跳过扫描) */
  503. async scanAndConnect(opt = {}) {
  504. const { deviceId: directId, deviceName, ...restOpt } = opt
  505. // 策略:如果已有 deviceId,先尝试直连(不扫描),失败后回退扫描
  506. if (directId) {
  507. try {
  508. logger.info(`directConnect attempt, deviceId=${directId}`)
  509. await this.connectDevice(directId)
  510. this.device.name = deviceName || this.device.name || ''
  511. logger.info('directConnect success, scan skipped')
  512. return this.device
  513. } catch (e) {
  514. logger.warn('directConnect failed, fallback to scan', e.message || e.code)
  515. // connectDevice 失败时已清空 this.device 并关闭连接,无需额外处理
  516. }
  517. }
  518. // 回退:扫描 + 连接
  519. const scanOpt = { ...restOpt }
  520. if (deviceName) scanOpt.deviceName = deviceName
  521. const device = await this.startScan(scanOpt)
  522. await this.connectDevice(device.deviceId)
  523. this.device.name = device.name || device.localName || ''
  524. return this.device
  525. },
  526. /** 主动断开 */
  527. async disconnect() {
  528. const s = _S()
  529. s.intentionalDisconnect = true
  530. this._clearReconnect()
  531. if (!this.device) {
  532. s.intentionalDisconnect = false
  533. return
  534. }
  535. const { deviceId } = this.device
  536. // 先置空 device,防止 onBLEConnectionStateChange 回调误匹配
  537. this.device = null
  538. this._setState(BLE_STATE.DISCONNECTED)
  539. try { await _invoke(uni.closeBLEConnection, { deviceId }) } catch (_) {}
  540. s.intentionalDisconnect = false
  541. },
  542. /** 发现服务并订阅通知 */
  543. async _discoverAndSubscribe(deviceId) {
  544. const s = _S()
  545. const svcRes = await _invoke(uni.getBLEDeviceServices, { deviceId })
  546. const services = svcRes.services || []
  547. const targetSvc = services.find(sv => _uuidEq(sv.uuid, s.config.serviceId))
  548. || services.find(sv => sv.isPrimary)
  549. || services[0]
  550. if (!targetSvc) throw _err(BLE_ERROR.SERVICE_NOT_FOUND)
  551. s.serviceId = targetSvc.uuid
  552. const charRes = await _invoke(uni.getBLEDeviceCharacteristics, {
  553. deviceId, serviceId: s.serviceId
  554. })
  555. const chars = charRes.characteristics || []
  556. const writeChar = chars.find(c => _uuidEq(c.uuid, s.config.writeCharId))
  557. || chars.find(c => c.properties && (c.properties.write || c.properties.writeNoResponse || c.properties.writeDefault))
  558. const notifyChar = chars.find(c => _uuidEq(c.uuid, s.config.notifyCharId))
  559. || chars.find(c => c.properties && (c.properties.notify || c.properties.indicate))
  560. if (!writeChar) throw _err(BLE_ERROR.CHAR_NOT_FOUND, { msg: '未找到写特征' })
  561. if (!notifyChar) throw _err(BLE_ERROR.CHAR_NOT_FOUND, { msg: '未找到通知特征' })
  562. s.writeCharId = writeChar.uuid
  563. s.notifyCharId = notifyChar.uuid
  564. await _invoke(uni.notifyBLECharacteristicValueChange, {
  565. deviceId,
  566. serviceId: s.serviceId,
  567. characteristicId: s.notifyCharId,
  568. state: true
  569. })
  570. },
  571. // ============== 断连处理 & 自动重连 ==============
  572. _handleDisconnected(reason) {
  573. const s = _S()
  574. // 主动断开/重置时不触发重连
  575. if (s.intentionalDisconnect) return
  576. if (this.bleState === BLE_STATE.DISCONNECTED || this.bleState === BLE_STATE.IDLE) return
  577. const device = this.device
  578. this._setState(BLE_STATE.DISCONNECTED)
  579. if (s.parser) s.parser.reset()
  580. this._attemptReconnect(device, reason)
  581. },
  582. _attemptReconnect(device, reason) {
  583. const s = _S()
  584. if (!s.config.autoReconnect || !device || s.reconnectCount >= s.config.maxReconnect) {
  585. if (s.reconnectCount >= s.config.maxReconnect) {
  586. logger.warn('reconnect max reached, waiting for adapter restore')
  587. s.reconnectCount = 0
  588. this.reconnectDrawer = false
  589. this.reconnectCount = 0
  590. }
  591. return
  592. }
  593. s.reconnectCount++
  594. this.reconnectCount = s.reconnectCount
  595. this.reconnectDrawer = true
  596. const delay = Math.min(1000 * s.reconnectCount, 5000)
  597. logger.warn(`reconnect in ${delay}ms (${s.reconnectCount}/${s.config.maxReconnect})`)
  598. const gen = s.reconnectGeneration
  599. s.reconnectTimer = setTimeout(async () => {
  600. if (s.reconnectGeneration !== gen) {
  601. logger.info('reconnect callback aborted (generation mismatch)')
  602. return
  603. }
  604. try {
  605. // 先关闭适配器清除脏状态(加标志防止回调干扰)
  606. s.intentionalDisconnect = true
  607. try { await _invoke(uni.closeBluetoothAdapter, {}) } catch (_) {}
  608. s.intentionalDisconnect = false
  609. this._setState(BLE_STATE.IDLE)
  610. if (s.reconnectGeneration !== gen) return
  611. // 优先使用 deviceId 直连,避免不必要的扫描触发 Android 限流
  612. const scanOpt = { timeout: 8000 }
  613. if (device.deviceId) scanOpt.deviceId = device.deviceId
  614. if (device.name) scanOpt.deviceName = device.name
  615. await this.scanAndConnect(scanOpt)
  616. if (s.reconnectGeneration !== gen) return
  617. this.reconnectDrawer = false
  618. this.reconnectCount = 0
  619. logger.info('reconnect success')
  620. } catch (e) {
  621. if (s.reconnectGeneration !== gen) return
  622. logger.error('reconnect fail', e)
  623. this._attemptReconnect(device, reason)
  624. }
  625. }, delay)
  626. },
  627. _clearReconnect() {
  628. const s = _S()
  629. if (s.reconnectTimer) { clearTimeout(s.reconnectTimer); s.reconnectTimer = null }
  630. s.reconnectCount = 0
  631. s.reconnectGeneration++ // 递增代数,使正在执行的僵尸回调自动失效
  632. this.reconnectCount = 0
  633. this.reconnectDrawer = false
  634. },
  635. /**
  636. * 蓝牙适配器从关闭恢复为开启时调用
  637. * 如果之前有连接过的设备且当前处于断连状态,自动扫描并重新连接
  638. */
  639. _onAdapterRestored() {
  640. const device = this.device
  641. if (!device || !device.deviceId) return
  642. if (this.bleState !== BLE_STATE.DISCONNECTED && this.bleState !== BLE_STATE.IDLE) return
  643. logger.info('adapter restored, auto reconnecting to', device.name || device.deviceId)
  644. // 重置重连计数,发起新一轮重连
  645. this._clearReconnect()
  646. this._setState(BLE_STATE.IDLE)
  647. // 延迟 1.5s 等待适配器完全就绪(Android 蓝牙状态延迟)
  648. const s = _S()
  649. const gen = s.reconnectGeneration
  650. this.reconnectDrawer = true
  651. this.reconnectCount = 1
  652. s.reconnectTimer = setTimeout(async () => {
  653. if (s.reconnectGeneration !== gen) return
  654. try {
  655. // 关闭旧适配器,确保重新打开时状态干净
  656. s.intentionalDisconnect = true
  657. try { await _invoke(uni.closeBluetoothAdapter, {}) } catch (_) {}
  658. s.intentionalDisconnect = false
  659. if (s.reconnectGeneration !== gen) return
  660. // 优先直连,避免扫描触发限流
  661. const scanOpt = { timeout: 8000 }
  662. if (device.deviceId) scanOpt.deviceId = device.deviceId
  663. if (device.name) scanOpt.deviceName = device.name
  664. await this.scanAndConnect(scanOpt)
  665. if (s.reconnectGeneration !== gen) return
  666. this.reconnectDrawer = false
  667. this.reconnectCount = 0
  668. logger.info('adapter restore reconnect success')
  669. } catch (e) {
  670. if (s.reconnectGeneration !== gen) return
  671. logger.error('adapter restore reconnect fail', e)
  672. // 失败后进入常规重连流程(还有2次机会)
  673. s.reconnectCount = 1
  674. this._attemptReconnect(device, BLE_ERROR.ADAPTER_OFF)
  675. }
  676. }, 1500)
  677. },
  678. // ============== 收包处理 ==============
  679. _onFrame(decoded, frame) {
  680. console.log("获取到消息",decoded)
  681. logger.info('frame received', decoded.funcCode)
  682. if (decoded.parsed) {
  683. if (decoded.parsed.type === 'GROUP_1') {
  684. this._handleGroup1(decoded.parsed)
  685. } else if (decoded.parsed.type === 'GROUP_2') {
  686. this._handleGroup2(decoded.parsed)
  687. }
  688. }
  689. },
  690. /** 解析参数组1 */
  691. _handleGroup1(data) {
  692. // 设备运行状态
  693. switch (data.runtimeState) {
  694. case 0x00: this.deviceStatus = 0; break
  695. case 0x01:
  696. case 0x02: this.deviceStatus = 1; break
  697. case 0x03: this.deviceStatus = 3; break
  698. case 0x04: this.deviceStatus = 4; break
  699. case 0x05: this.deviceStatus = 5; break
  700. }
  701. // 模式
  702. switch (data.mode) {
  703. case MODE.LEISURE: this.modeType = 0; break
  704. case MODE.PROFESSIONAL: this.modeType = 1; break
  705. case MODE.PERSONAL: this.modeType = 2; break
  706. case MODE.EXPERT: this.modeType = 3; break
  707. }
  708. // 剩余时间
  709. const m = data.remainMinute || 0
  710. const s = data.remainSecond || 0
  711. const h = Math.floor(m / 60)
  712. const m1 = m % 60
  713. this.subTime = `${h.toString().padStart(2, '0')}:${m1.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
  714. // 异常检测
  715. if (data.foreignDetect === 0x02) {
  716. this.excepDrawer = true
  717. this.exceTxt = 1
  718. }
  719. // 耗材状态
  720. this.otherSetting.aijiuNum = String(data.consumable || 0)
  721. this.otherSetting.lvxinNum = String(data.filterPercent || 0)
  722. this.otherSetting.huishouNum = String(data.recycleBinPct || 0)
  723. },
  724. /** 解析参数组2 */
  725. _handleGroup2(data) {
  726. if (data.preheatPercent < 100) {
  727. this.ispreHot = false
  728. this.hotPercentage = data.preheatPercent + '%'
  729. } else {
  730. this.ispreHot = true
  731. this.hotPercentage = data.ignitePercent + '%'
  732. }
  733. // 椅子角度
  734. if (data.chairAngle) {
  735. const angleMap = {
  736. 1: 90, 2: 105, 3: 120, 4: 135, 5: 150
  737. }
  738. if (angleMap[data.chairAngle]) {
  739. this.chairAngle = angleMap[data.chairAngle]
  740. }
  741. }
  742. },
  743. // ============== 发送指令 ==============
  744. async writeRaw(u8) {
  745. if (!this.linked) throw _err(BLE_ERROR.DISCONNECTED)
  746. const s = _S()
  747. const payload = bufferToArrayBuffer(u8)
  748. s.writeLock = s.writeLock.then(async () => {
  749. logger.log('=>', bytesToHex(u8))
  750. logger.log('write params:', {
  751. deviceId: this.device.deviceId,
  752. serviceId: s.serviceId,
  753. characteristicId: s.writeCharId,
  754. valueByteLength: payload.byteLength
  755. })
  756. try {
  757. await _invoke(uni.writeBLECharacteristicValue, {
  758. deviceId: this.device.deviceId,
  759. serviceId: s.serviceId,
  760. characteristicId: s.writeCharId,
  761. value: payload
  762. })
  763. } catch (e) {
  764. logger.error('writeBLE origin error:', JSON.stringify(e))
  765. throw _err(BLE_ERROR.WRITE_FAIL, e)
  766. }
  767. })
  768. return s.writeLock
  769. },
  770. /** 下发基本功能指令 (0x01) */
  771. sendBasic(opt) { return this.writeRaw(encodeBasic(opt)) },
  772. /** 下发模式参数 1 (步骤 1-7) */
  773. sendModeParam1(opt) { return this.writeRaw(encodeModeParam1(opt)) },
  774. /** 下发模式参数 2 (步骤 8-14) */
  775. sendModeParam2(opt) { return this.writeRaw(encodeModeParam2(opt)) },
  776. /** 下发穴位坐标 */
  777. async sendAcupoints(points = []) {
  778. for (let i = 0; i < points.length; i += 2) {
  779. const pair = points.slice(i, i + 2)
  780. await this.writeRaw(encodeAcupoints(pair))
  781. }
  782. },
  783. // ---- 常用快捷方法 ----
  784. powerOn() { return this.sendBasic({ power: POWER.ON }) },
  785. powerOff() { return this.sendBasic({ power: POWER.OFF }) },
  786. startMoxi(opt = {}) { return this.sendBasic({ power: POWER.ON, moxiState: MOXI_STATE.START, ...opt }) },
  787. pauseMoxi() { return this.sendBasic({ moxiState: MOXI_STATE.PAUSE }) },
  788. stopMoxi() { return this.sendBasic({ moxiState: MOXI_STATE.DONE }) },
  789. setMute(on) { return this.sendBasic({ mute: on ? MUTE.ON : MUTE.OFF }) },
  790. setTemperature(v) { return this.sendBasic({ temperature: v }) },
  791. setChairAngle(v) { return this.sendBasic({ angle: v }) },
  792. // ============== 蓝牙关闭提示 ==============
  793. /**
  794. * 弹窗提示用户蓝牙未开启,引导用户前往设置开启
  795. * 内部做防抖,避免短时间内重复弹窗
  796. */
  797. _showBleOffPrompt(message) {
  798. const s = _S()
  799. // 弹窗正在显示中,不重复弹出
  800. if (s._bleOffPromptShowing) return
  801. s._bleOffPromptShowing = true
  802. uni.showModal({
  803. title: '蓝牙未开启',
  804. content: message || '请开启手机蓝牙后重试',
  805. confirmText: '去设置',
  806. cancelText: '取消',
  807. success: (res) => {
  808. s._bleOffPromptShowing = false
  809. if (res.confirm) {
  810. // #ifdef APP-PLUS
  811. const platform = uni.getSystemInfoSync().platform
  812. if (platform === 'android') {
  813. try {
  814. const main = plus.android.runtimeMainActivity()
  815. const Intent = plus.android.importClass('android.content.Intent')
  816. const Settings = plus.android.importClass('android.provider.Settings')
  817. const intent = new Intent(Settings.ACTION_BLUETOOTH_SETTINGS)
  818. main.startActivity(intent)
  819. } catch (e) {
  820. logger.error('跳转蓝牙设置失败', e)
  821. }
  822. } else if (platform === 'ios') {
  823. // iOS 可以打开 App 设置页
  824. plus.runtime.openURL('App-Prefs:root=Bluetooth')
  825. }
  826. // #endif
  827. }
  828. }
  829. })
  830. }
  831. }
  832. })