|
|
@@ -0,0 +1,798 @@
|
|
|
+/**
|
|
|
+ * 艾灸椅 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, // 标记主动断开,防止系统回调误触发重连
|
|
|
+ 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
|
|
|
+ this.bleState = s
|
|
|
+ this.linked = (s === BLE_STATE.READY_COMM)
|
|
|
+ this.searching = (s === BLE_STATE.SCANNING)
|
|
|
+ logger.info(`[instanceId=${_instanceId()}] state ->`, s)
|
|
|
+ },
|
|
|
+
|
|
|
+ // ============== 初始化 / 释放 ==============
|
|
|
+ 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) => {
|
|
|
+ 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()
|
|
|
+ console.log(`[BLE Store] startScan() called, instanceId = ${s.instanceId}`)
|
|
|
+ // 重置中止标记
|
|
|
+ s.scanAborted = false
|
|
|
+ this.scanThrottled = false
|
|
|
+ // 取消上一次残留扫描
|
|
|
+ if (s.cancelScan) { s.cancelScan(); s.cancelScan = null }
|
|
|
+ // 停止上一次发现
|
|
|
+ await _invoke(uni.stopBluetoothDevicesDiscovery, {}).catch(() => {})
|
|
|
+
|
|
|
+ await this._ensureReady()
|
|
|
+
|
|
|
+ // 如果在 await 期间页面已卸载并调用了 stopScan,直接中止
|
|
|
+ if (s.scanAborted) {
|
|
|
+ logger.info('startScan aborted (page already unloaded)')
|
|
|
+ return opt.returnAll ? [] : null
|
|
|
+ }
|
|
|
+
|
|
|
+ const {
|
|
|
+ namePrefix = s.config.deviceNamePrefix,
|
|
|
+ deviceName,
|
|
|
+ services,
|
|
|
+ timeout = s.config.scanTimeout,
|
|
|
+ returnAll = false
|
|
|
+ } = opt
|
|
|
+
|
|
|
+ // 清空上次扫描结果
|
|
|
+ this.scannedDevices = []
|
|
|
+
|
|
|
+ // Android 限流检测(30秒内最多5次,iOS 无此限制)
|
|
|
+ const platform = uni.getSystemInfoSync().platform
|
|
|
+ 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 = []
|
|
|
+
|
|
|
+ return new Promise(async (resolve, reject) => {
|
|
|
+ let finished = false
|
|
|
+
|
|
|
+ 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)
|
|
|
+ const exists = this.scannedDevices.find(item => item.deviceId === d.deviceId)
|
|
|
+ if (!exists) {
|
|
|
+ this.scannedDevices.push({
|
|
|
+ deviceId: d.deviceId,
|
|
|
+ name: name,
|
|
|
+ RSSI: d.RSSI || ''
|
|
|
+ })
|
|
|
+ }
|
|
|
+ if (!returnAll) { finish(null, d); return }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const timer = setTimeout(() => {
|
|
|
+ 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
|
|
|
+ s.cancelScan = null
|
|
|
+ s.onDeviceFound = null
|
|
|
+ clearTimeout(timer)
|
|
|
+ _invoke(uni.stopBluetoothDevicesDiscovery, {}).catch(() => {})
|
|
|
+ this._setState(BLE_STATE.READY)
|
|
|
+ err ? reject(err) : resolve(data)
|
|
|
+ }
|
|
|
+
|
|
|
+ s.cancelScan = () => finish(null, returnAll ? matched : (matched[0] || null))
|
|
|
+
|
|
|
+ // 通过共享回调分发事件(监听器已在 _bindSystemListeners 中永久注册)
|
|
|
+ s.onDeviceFound = onFound
|
|
|
+ this._setState(BLE_STATE.SCANNING)
|
|
|
+
|
|
|
+ try {
|
|
|
+ await _invoke(uni.startBluetoothDevicesDiscovery, {
|
|
|
+ allowDuplicatesKey: false,
|
|
|
+ interval: 0,
|
|
|
+ services
|
|
|
+ })
|
|
|
+ } catch (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) {
|
|
|
+ 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: '' }
|
|
|
+
|
|
|
+ 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) {
|
|
|
+ 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, ...restOpt } = opt
|
|
|
+
|
|
|
+ // 策略:如果已有 deviceId,先尝试直连(不扫描),失败后回退扫描
|
|
|
+ if (directId) {
|
|
|
+ try {
|
|
|
+ logger.info(`directConnect attempt, deviceId=${directId}`)
|
|
|
+ await this.connectDevice(directId)
|
|
|
+ this.device.name = deviceName || this.device.name || ''
|
|
|
+ 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 }
|
|
|
+ if (deviceName) scanOpt.deviceName = deviceName
|
|
|
+ const device = await this.startScan(scanOpt)
|
|
|
+ await this.connectDevice(device.deviceId)
|
|
|
+ this.device.name = device.name || device.localName || ''
|
|
|
+ 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 targetSvc = 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 = 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 = 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) {
|
|
|
+ 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()
|
|
|
+ // 防抖:5秒内不重复弹窗
|
|
|
+ const now = Date.now()
|
|
|
+ if (s._lastBleOffPromptTime && now - s._lastBleOffPromptTime < 5000) return
|
|
|
+ s._lastBleOffPromptTime = now
|
|
|
+
|
|
|
+ uni.showModal({
|
|
|
+ title: '蓝牙未开启',
|
|
|
+ content: message || '请开启手机蓝牙后重试',
|
|
|
+ confirmText: '去设置',
|
|
|
+ cancelText: '取消',
|
|
|
+ success: (res) => {
|
|
|
+ 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
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+})
|