ble.js 26 KB

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