heartRateController.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775
  1. export interface HeartRateControllerCallbacks {
  2. onHeartRate: (bpm: number) => void
  3. onStatus: (message: string) => void
  4. onError: (message: string) => void
  5. onConnectionChange: (connected: boolean, deviceName: string | null) => void
  6. onDeviceListChange: (devices: HeartRateDiscoveredDevice[]) => void
  7. }
  8. export interface HeartRateDiscoveredDevice {
  9. deviceId: string
  10. name: string
  11. rssi: number | null
  12. lastSeenAt: number
  13. isPreferred: boolean
  14. }
  15. type BluetoothDeviceLike = {
  16. deviceId?: string
  17. name?: string
  18. localName?: string
  19. RSSI?: number
  20. advertisServiceUUIDs?: string[]
  21. }
  22. const HEART_RATE_SERVICE_UUID = '180D'
  23. const HEART_RATE_MEASUREMENT_UUID = '2A37'
  24. const DISCOVERY_TIMEOUT_MS = 12000
  25. const DEVICE_STALE_MS = 15000
  26. const PREFERRED_HEART_RATE_DEVICE_STORAGE_KEY = 'cmr.preferredHeartRateDevice'
  27. function normalizeUuid(uuid: string | undefined | null): string {
  28. return String(uuid || '').replace(/[^0-9a-f]/gi, '').toUpperCase()
  29. }
  30. function matchesShortUuid(uuid: string | undefined | null, shortUuid: string): boolean {
  31. const normalized = normalizeUuid(uuid)
  32. const normalizedShort = normalizeUuid(shortUuid)
  33. if (!normalized || !normalizedShort) {
  34. return false
  35. }
  36. return normalized === normalizedShort
  37. || normalized.indexOf(`0000${normalizedShort}00001000800000805F9B34FB`) === 0
  38. || normalized.endsWith(normalizedShort)
  39. }
  40. function getDeviceDisplayName(device: BluetoothDeviceLike | null | undefined): string {
  41. if (!device) {
  42. return '心率带'
  43. }
  44. return device.name || device.localName || '未命名心率带'
  45. }
  46. function isHeartRateDevice(device: BluetoothDeviceLike): boolean {
  47. const serviceIds = Array.isArray(device.advertisServiceUUIDs) ? device.advertisServiceUUIDs : []
  48. if (serviceIds.some((uuid) => matchesShortUuid(uuid, HEART_RATE_SERVICE_UUID))) {
  49. return true
  50. }
  51. const name = `${device.name || ''} ${device.localName || ''}`.toUpperCase()
  52. return name.indexOf('HR') !== -1
  53. || name.indexOf('HEART') !== -1
  54. || name.indexOf('POLAR') !== -1
  55. || name.indexOf('GARMIN') !== -1
  56. || name.indexOf('COOSPO') !== -1
  57. }
  58. function parseHeartRateMeasurement(buffer: ArrayBuffer): number | null {
  59. if (!buffer || buffer.byteLength < 2) {
  60. return null
  61. }
  62. const view = new DataView(buffer)
  63. const flags = view.getUint8(0)
  64. const isUint16 = (flags & 0x01) === 0x01
  65. if (isUint16) {
  66. if (buffer.byteLength < 3) {
  67. return null
  68. }
  69. return view.getUint16(1, true)
  70. }
  71. return view.getUint8(1)
  72. }
  73. export class HeartRateController {
  74. callbacks: HeartRateControllerCallbacks
  75. scanning: boolean
  76. connecting: boolean
  77. connected: boolean
  78. currentDeviceId: string | null
  79. currentDeviceName: string | null
  80. discoveredDevices: HeartRateDiscoveredDevice[]
  81. lastDeviceId: string | null
  82. lastDeviceName: string | null
  83. manualDisconnect: boolean
  84. reconnecting: boolean
  85. disconnecting: boolean
  86. disconnectingDeviceId: string | null
  87. autoConnectDeviceId: string | null
  88. measurementServiceId: string | null
  89. measurementCharacteristicId: string | null
  90. discoveryTimer: number
  91. reconnectTimer: number
  92. deviceFoundHandler: ((result: any) => void) | null
  93. characteristicHandler: ((result: any) => void) | null
  94. connectionStateHandler: ((result: any) => void) | null
  95. constructor(callbacks: HeartRateControllerCallbacks) {
  96. this.callbacks = callbacks
  97. this.scanning = false
  98. this.connecting = false
  99. this.connected = false
  100. this.currentDeviceId = null
  101. this.currentDeviceName = null
  102. this.discoveredDevices = []
  103. this.lastDeviceId = null
  104. this.lastDeviceName = null
  105. this.manualDisconnect = false
  106. this.reconnecting = false
  107. this.disconnecting = false
  108. this.disconnectingDeviceId = null
  109. this.autoConnectDeviceId = null
  110. this.measurementServiceId = null
  111. this.measurementCharacteristicId = null
  112. this.discoveryTimer = 0
  113. this.reconnectTimer = 0
  114. this.deviceFoundHandler = null
  115. this.characteristicHandler = null
  116. this.connectionStateHandler = null
  117. this.restorePreferredDevice()
  118. }
  119. startScanAndConnect(): void {
  120. if (this.connected) {
  121. this.callbacks.onStatus(`心率带已连接: ${this.currentDeviceName || '设备'}`)
  122. return
  123. }
  124. if (this.disconnecting) {
  125. this.callbacks.onStatus('心率带断开中,请稍后再试')
  126. return
  127. }
  128. if (this.scanning || this.connecting) {
  129. this.callbacks.onStatus('心率带连接进行中')
  130. return
  131. }
  132. this.manualDisconnect = false
  133. this.reconnecting = false
  134. this.clearReconnectTimer()
  135. this.withFreshBluetoothAdapter(() => {
  136. if (this.lastDeviceId) {
  137. this.callbacks.onStatus(`正在扫描并优先连接 ${this.lastDeviceName || '心率带'}`)
  138. this.beginDiscovery()
  139. return
  140. }
  141. this.beginDiscovery()
  142. })
  143. }
  144. disconnect(): void {
  145. this.clearDiscoveryTimer()
  146. this.clearReconnectTimer()
  147. this.stopDiscovery()
  148. const deviceId = this.currentDeviceId
  149. this.connecting = false
  150. this.reconnecting = false
  151. this.manualDisconnect = true
  152. this.disconnecting = true
  153. if (!deviceId) {
  154. this.disconnecting = false
  155. this.clearConnectionState()
  156. this.callbacks.onStatus('心率带未连接')
  157. return
  158. }
  159. this.disconnectingDeviceId = deviceId
  160. const wxAny = wx as any
  161. wxAny.closeBLEConnection({
  162. deviceId,
  163. complete: () => {
  164. this.disconnecting = false
  165. this.clearConnectionState()
  166. this.callbacks.onStatus('心率带已断开')
  167. },
  168. })
  169. }
  170. destroy(): void {
  171. this.clearDiscoveryTimer()
  172. this.clearReconnectTimer()
  173. this.stopDiscovery()
  174. this.detachListeners()
  175. const deviceId = this.currentDeviceId
  176. if (deviceId) {
  177. const wxAny = wx as any
  178. wxAny.closeBLEConnection({
  179. deviceId,
  180. complete: () => {},
  181. })
  182. }
  183. const wxAny = wx as any
  184. if (typeof wxAny.closeBluetoothAdapter === 'function') {
  185. wxAny.closeBluetoothAdapter({
  186. complete: () => {},
  187. })
  188. }
  189. this.clearConnectionState()
  190. }
  191. beginDiscovery(): void {
  192. if (this.scanning || this.connecting || this.connected) {
  193. return
  194. }
  195. this.bindListeners()
  196. this.autoConnectDeviceId = this.lastDeviceId
  197. const wxAny = wx as any
  198. wxAny.startBluetoothDevicesDiscovery({
  199. allowDuplicatesKey: false,
  200. services: [HEART_RATE_SERVICE_UUID],
  201. success: () => {
  202. this.scanning = true
  203. this.pruneDiscoveredDevices()
  204. this.callbacks.onStatus(this.autoConnectDeviceId ? '正在扫描心率带并等待自动连接' : '正在扫描心率带,请选择设备')
  205. this.clearDiscoveryTimer()
  206. this.discoveryTimer = setTimeout(() => {
  207. this.discoveryTimer = 0
  208. if (!this.scanning || this.connected || this.connecting) {
  209. return
  210. }
  211. this.stopDiscovery()
  212. this.callbacks.onError(this.discoveredDevices.length ? '已发现心率带,请从列表选择连接' : '未发现可连接的心率带')
  213. }, DISCOVERY_TIMEOUT_MS) as unknown as number
  214. },
  215. fail: (error: any) => {
  216. const message = error && error.errMsg ? error.errMsg : 'startBluetoothDevicesDiscovery 失败'
  217. this.callbacks.onError(`扫描心率带失败: ${message}`)
  218. },
  219. })
  220. }
  221. stopDiscovery(): void {
  222. this.clearDiscoveryTimer()
  223. if (!this.scanning) {
  224. return
  225. }
  226. this.scanning = false
  227. this.autoConnectDeviceId = null
  228. const wxAny = wx as any
  229. wxAny.stopBluetoothDevicesDiscovery({
  230. complete: () => {},
  231. })
  232. }
  233. bindListeners(): void {
  234. const wxAny = wx as any
  235. if (!this.deviceFoundHandler) {
  236. this.deviceFoundHandler = (result: any) => {
  237. const devices = Array.isArray(result && result.devices)
  238. ? result.devices
  239. : result && result.deviceId
  240. ? [result]
  241. : []
  242. this.mergeDiscoveredDevices(devices)
  243. if (!this.scanning || this.connecting || this.connected) {
  244. return
  245. }
  246. const targetDevice = this.selectTargetDevice(devices)
  247. if (!targetDevice || !targetDevice.deviceId) {
  248. return
  249. }
  250. this.stopDiscovery()
  251. this.connectToDevice(targetDevice.deviceId, getDeviceDisplayName(targetDevice))
  252. }
  253. if (typeof wxAny.onBluetoothDeviceFound === 'function') {
  254. wxAny.onBluetoothDeviceFound(this.deviceFoundHandler)
  255. }
  256. }
  257. if (!this.characteristicHandler) {
  258. this.characteristicHandler = (result: any) => {
  259. if (!result || !result.value) {
  260. return
  261. }
  262. if (this.currentDeviceId && result.deviceId && result.deviceId !== this.currentDeviceId) {
  263. return
  264. }
  265. if (!matchesShortUuid(result.characteristicId, HEART_RATE_MEASUREMENT_UUID)) {
  266. return
  267. }
  268. const bpm = parseHeartRateMeasurement(result.value)
  269. if (bpm === null || !Number.isFinite(bpm) || bpm <= 0) {
  270. return
  271. }
  272. this.callbacks.onHeartRate(Math.round(bpm))
  273. }
  274. if (typeof wxAny.onBLECharacteristicValueChange === 'function') {
  275. wxAny.onBLECharacteristicValueChange(this.characteristicHandler)
  276. }
  277. }
  278. if (!this.connectionStateHandler) {
  279. this.connectionStateHandler = (result: any) => {
  280. if (!result || !result.deviceId) {
  281. return
  282. }
  283. if (result.connected) {
  284. return
  285. }
  286. if (this.disconnectingDeviceId && result.deviceId === this.disconnectingDeviceId) {
  287. this.disconnectingDeviceId = null
  288. return
  289. }
  290. if (!this.currentDeviceId || result.deviceId !== this.currentDeviceId) {
  291. return
  292. }
  293. const disconnectedDeviceId = this.currentDeviceId
  294. const disconnectedDeviceName = this.currentDeviceName
  295. this.clearConnectionState()
  296. this.callbacks.onStatus('心率带连接已断开')
  297. this.scheduleAutoReconnect(disconnectedDeviceId, disconnectedDeviceName)
  298. }
  299. if (typeof wxAny.onBLEConnectionStateChange === 'function') {
  300. wxAny.onBLEConnectionStateChange(this.connectionStateHandler)
  301. }
  302. }
  303. }
  304. detachListeners(): void {
  305. const wxAny = wx as any
  306. if (this.deviceFoundHandler && typeof wxAny.offBluetoothDeviceFound === 'function') {
  307. wxAny.offBluetoothDeviceFound(this.deviceFoundHandler)
  308. }
  309. if (this.characteristicHandler && typeof wxAny.offBLECharacteristicValueChange === 'function') {
  310. wxAny.offBLECharacteristicValueChange(this.characteristicHandler)
  311. }
  312. if (this.connectionStateHandler && typeof wxAny.offBLEConnectionStateChange === 'function') {
  313. wxAny.offBLEConnectionStateChange(this.connectionStateHandler)
  314. }
  315. this.deviceFoundHandler = null
  316. this.characteristicHandler = null
  317. this.connectionStateHandler = null
  318. }
  319. connectToDevice(deviceId: string, deviceName: string, fallbackToDiscovery: boolean = false): void {
  320. this.connecting = true
  321. this.reconnecting = false
  322. this.disconnecting = false
  323. this.manualDisconnect = false
  324. this.disconnectingDeviceId = null
  325. this.autoConnectDeviceId = deviceId
  326. this.currentDeviceId = deviceId
  327. this.currentDeviceName = deviceName
  328. this.callbacks.onStatus(`正在连接 ${deviceName}`)
  329. const wxAny = wx as any
  330. wxAny.createBLEConnection({
  331. deviceId,
  332. timeout: 10000,
  333. success: () => {
  334. this.discoverMeasurementCharacteristic(deviceId, deviceName)
  335. },
  336. fail: (error: any) => {
  337. const message = error && error.errMsg ? error.errMsg : 'createBLEConnection 失败'
  338. this.clearConnectionState()
  339. if (fallbackToDiscovery && !this.manualDisconnect) {
  340. this.callbacks.onStatus(`直连失败,转入扫描: ${deviceName}`)
  341. this.beginDiscovery()
  342. return
  343. }
  344. this.callbacks.onError(`连接心率带失败: ${message}`)
  345. },
  346. })
  347. }
  348. discoverMeasurementCharacteristic(deviceId: string, deviceName: string): void {
  349. const wxAny = wx as any
  350. wxAny.getBLEDeviceServices({
  351. deviceId,
  352. success: (serviceResult: any) => {
  353. const services = Array.isArray(serviceResult && serviceResult.services) ? serviceResult.services : []
  354. const service = services.find((item: any) => matchesShortUuid(item && item.uuid, HEART_RATE_SERVICE_UUID))
  355. if (!service || !service.uuid) {
  356. this.failConnection(deviceId, '未找到标准心率服务')
  357. return
  358. }
  359. wxAny.getBLEDeviceCharacteristics({
  360. deviceId,
  361. serviceId: service.uuid,
  362. success: (characteristicResult: any) => {
  363. const characteristics = Array.isArray(characteristicResult && characteristicResult.characteristics)
  364. ? characteristicResult.characteristics
  365. : []
  366. const characteristic = characteristics.find((item: any) => {
  367. const properties = item && item.properties ? item.properties : {}
  368. return matchesShortUuid(item && item.uuid, HEART_RATE_MEASUREMENT_UUID)
  369. && (properties.notify || properties.indicate)
  370. })
  371. if (!characteristic || !characteristic.uuid) {
  372. this.failConnection(deviceId, '未找到心率通知特征')
  373. return
  374. }
  375. this.measurementServiceId = service.uuid
  376. this.measurementCharacteristicId = characteristic.uuid
  377. wxAny.notifyBLECharacteristicValueChange({
  378. state: true,
  379. deviceId,
  380. serviceId: service.uuid,
  381. characteristicId: characteristic.uuid,
  382. success: () => {
  383. this.connecting = false
  384. this.connected = true
  385. this.lastDeviceId = deviceId
  386. this.lastDeviceName = deviceName
  387. this.persistPreferredDevice()
  388. this.manualDisconnect = false
  389. this.reconnecting = false
  390. this.disconnectingDeviceId = null
  391. this.autoConnectDeviceId = deviceId
  392. this.refreshPreferredFlags()
  393. this.clearReconnectTimer()
  394. this.callbacks.onConnectionChange(true, deviceName)
  395. this.callbacks.onStatus(`心率带已连接: ${deviceName}`)
  396. },
  397. fail: (error: any) => {
  398. const message = error && error.errMsg ? error.errMsg : 'notifyBLECharacteristicValueChange 失败'
  399. this.failConnection(deviceId, `心率订阅失败: ${message}`)
  400. },
  401. })
  402. },
  403. fail: (error: any) => {
  404. const message = error && error.errMsg ? error.errMsg : 'getBLEDeviceCharacteristics 失败'
  405. this.failConnection(deviceId, `读取心率特征失败: ${message}`)
  406. },
  407. })
  408. },
  409. fail: (error: any) => {
  410. const message = error && error.errMsg ? error.errMsg : 'getBLEDeviceServices 失败'
  411. this.failConnection(deviceId, `读取心率服务失败: ${message}`)
  412. },
  413. })
  414. }
  415. failConnection(deviceId: string, message: string): void {
  416. const wxAny = wx as any
  417. wxAny.closeBLEConnection({
  418. deviceId,
  419. complete: () => {
  420. this.clearConnectionState()
  421. this.callbacks.onError(message)
  422. this.scheduleAutoReconnect(this.lastDeviceId, this.lastDeviceName)
  423. },
  424. })
  425. }
  426. clearConnectionState(): void {
  427. const wasConnected = this.connected
  428. this.scanning = false
  429. this.connecting = false
  430. this.connected = false
  431. this.disconnecting = false
  432. this.disconnectingDeviceId = null
  433. this.autoConnectDeviceId = null
  434. this.currentDeviceId = null
  435. this.measurementServiceId = null
  436. this.measurementCharacteristicId = null
  437. const previousDeviceName = this.currentDeviceName
  438. this.currentDeviceName = null
  439. if (wasConnected || previousDeviceName) {
  440. this.callbacks.onConnectionChange(false, null)
  441. }
  442. }
  443. clearDiscoveryTimer(): void {
  444. if (this.discoveryTimer) {
  445. clearTimeout(this.discoveryTimer)
  446. this.discoveryTimer = 0
  447. }
  448. }
  449. clearReconnectTimer(): void {
  450. if (this.reconnectTimer) {
  451. clearTimeout(this.reconnectTimer)
  452. this.reconnectTimer = 0
  453. }
  454. }
  455. selectTargetDevice(devices: BluetoothDeviceLike[]): BluetoothDeviceLike | null {
  456. if (!Array.isArray(devices) || !devices.length) {
  457. return null
  458. }
  459. if (this.autoConnectDeviceId) {
  460. const rememberedDevice = devices.find((device) => device && device.deviceId === this.autoConnectDeviceId)
  461. if (rememberedDevice && isHeartRateDevice(rememberedDevice)) {
  462. return rememberedDevice
  463. }
  464. return null
  465. }
  466. return null
  467. }
  468. scheduleAutoReconnect(deviceId: string | null, deviceName: string | null): void {
  469. if (this.manualDisconnect || !deviceId || this.connected || this.connecting || this.scanning) {
  470. return
  471. }
  472. this.lastDeviceId = deviceId
  473. this.lastDeviceName = deviceName || this.lastDeviceName
  474. this.clearReconnectTimer()
  475. this.reconnecting = true
  476. this.callbacks.onStatus(`心率带已断开,等待自动重连: ${this.lastDeviceName || '设备'}`)
  477. this.reconnectTimer = setTimeout(() => {
  478. this.reconnectTimer = 0
  479. if (this.manualDisconnect || this.connected || this.connecting || this.scanning) {
  480. return
  481. }
  482. const rememberedDeviceId = this.lastDeviceId
  483. const rememberedDeviceName = this.lastDeviceName || '心率带'
  484. this.reconnecting = false
  485. this.withFreshBluetoothAdapter(() => {
  486. if (rememberedDeviceId) {
  487. this.callbacks.onStatus(`正在自动重连 ${rememberedDeviceName}`)
  488. this.resetConnectionAndConnect(rememberedDeviceId, rememberedDeviceName, true)
  489. return
  490. }
  491. this.beginDiscovery()
  492. })
  493. }, 600) as unknown as number
  494. }
  495. resetConnectionAndConnect(deviceId: string, deviceName: string, fallbackToDiscovery: boolean): void {
  496. const wxAny = wx as any
  497. this.disconnectingDeviceId = deviceId
  498. wxAny.closeBLEConnection({
  499. deviceId,
  500. complete: () => {
  501. if (this.disconnectingDeviceId === deviceId) {
  502. this.disconnectingDeviceId = null
  503. }
  504. setTimeout(() => {
  505. if (this.connected || this.connecting || this.scanning || this.disconnecting) {
  506. return
  507. }
  508. this.connectToDevice(deviceId, deviceName, fallbackToDiscovery)
  509. }, 320)
  510. },
  511. })
  512. }
  513. connectToDiscoveredDevice(deviceId: string): void {
  514. if (!deviceId) {
  515. return
  516. }
  517. if (this.disconnecting) {
  518. this.callbacks.onStatus('心率带断开中,请稍后再试')
  519. return
  520. }
  521. const targetDevice = this.discoveredDevices.find((device) => device.deviceId === deviceId)
  522. const deviceName = targetDevice ? targetDevice.name : (this.lastDeviceId === deviceId ? (this.lastDeviceName || '心率带') : '心率带')
  523. this.lastDeviceId = deviceId
  524. this.lastDeviceName = deviceName
  525. this.refreshPreferredFlags()
  526. this.stopDiscovery()
  527. this.withFreshBluetoothAdapter(() => {
  528. this.callbacks.onStatus(`正在连接 ${deviceName}`)
  529. this.resetConnectionAndConnect(deviceId, deviceName, true)
  530. })
  531. }
  532. clearPreferredDevice(): void {
  533. this.lastDeviceId = null
  534. this.lastDeviceName = null
  535. this.autoConnectDeviceId = null
  536. this.removePreferredDevice()
  537. this.refreshPreferredFlags()
  538. this.callbacks.onStatus('已清除首选心率带')
  539. }
  540. mergeDiscoveredDevices(devices: BluetoothDeviceLike[]): void {
  541. if (!Array.isArray(devices) || !devices.length) {
  542. return
  543. }
  544. const now = Date.now()
  545. let changed = false
  546. const nextDevices = [...this.discoveredDevices]
  547. for (const rawDevice of devices) {
  548. if (!rawDevice || !rawDevice.deviceId || !isHeartRateDevice(rawDevice)) {
  549. continue
  550. }
  551. const name = getDeviceDisplayName(rawDevice)
  552. const rssi = typeof rawDevice.RSSI === 'number' && Number.isFinite(rawDevice.RSSI) ? rawDevice.RSSI : null
  553. const existingIndex = nextDevices.findIndex((item) => item.deviceId === rawDevice.deviceId)
  554. const nextDevice: HeartRateDiscoveredDevice = {
  555. deviceId: rawDevice.deviceId,
  556. name,
  557. rssi,
  558. lastSeenAt: now,
  559. isPreferred: rawDevice.deviceId === this.lastDeviceId,
  560. }
  561. if (existingIndex >= 0) {
  562. nextDevices[existingIndex] = nextDevice
  563. } else {
  564. nextDevices.push(nextDevice)
  565. }
  566. changed = true
  567. }
  568. if (!changed) {
  569. return
  570. }
  571. this.discoveredDevices = this.sortDiscoveredDevices(nextDevices.filter((device) => now - device.lastSeenAt <= DEVICE_STALE_MS))
  572. this.callbacks.onDeviceListChange([...this.discoveredDevices])
  573. }
  574. pruneDiscoveredDevices(): void {
  575. const now = Date.now()
  576. const nextDevices = this.sortDiscoveredDevices(
  577. this.discoveredDevices.filter((device) => now - device.lastSeenAt <= DEVICE_STALE_MS),
  578. )
  579. const changed = nextDevices.length !== this.discoveredDevices.length
  580. || nextDevices.some((device, index) => {
  581. const previous = this.discoveredDevices[index]
  582. return !previous || previous.deviceId !== device.deviceId || previous.isPreferred !== device.isPreferred || previous.rssi !== device.rssi
  583. })
  584. this.discoveredDevices = nextDevices
  585. if (changed) {
  586. this.callbacks.onDeviceListChange([...this.discoveredDevices])
  587. }
  588. }
  589. refreshPreferredFlags(): void {
  590. if (!this.discoveredDevices.length) {
  591. this.callbacks.onDeviceListChange([])
  592. return
  593. }
  594. this.discoveredDevices = this.sortDiscoveredDevices(
  595. this.discoveredDevices.map((device) => ({
  596. ...device,
  597. isPreferred: !!this.lastDeviceId && device.deviceId === this.lastDeviceId,
  598. })),
  599. )
  600. this.callbacks.onDeviceListChange([...this.discoveredDevices])
  601. }
  602. sortDiscoveredDevices(devices: HeartRateDiscoveredDevice[]): HeartRateDiscoveredDevice[] {
  603. return [...devices].sort((a, b) => {
  604. if (a.isPreferred !== b.isPreferred) {
  605. return a.isPreferred ? -1 : 1
  606. }
  607. const aRssi = a.rssi === null ? -999 : a.rssi
  608. const bRssi = b.rssi === null ? -999 : b.rssi
  609. if (aRssi !== bRssi) {
  610. return bRssi - aRssi
  611. }
  612. return b.lastSeenAt - a.lastSeenAt
  613. })
  614. }
  615. withFreshBluetoothAdapter(onReady: () => void): void {
  616. const wxAny = wx as any
  617. const openAdapter = () => {
  618. wxAny.openBluetoothAdapter({
  619. success: () => {
  620. onReady()
  621. },
  622. fail: (error: any) => {
  623. const message = error && error.errMsg ? error.errMsg : 'openBluetoothAdapter 失败'
  624. this.callbacks.onError(`蓝牙不可用: ${message}`)
  625. },
  626. })
  627. }
  628. if (typeof wxAny.closeBluetoothAdapter !== 'function') {
  629. openAdapter()
  630. return
  631. }
  632. wxAny.closeBluetoothAdapter({
  633. complete: () => {
  634. setTimeout(() => {
  635. openAdapter()
  636. }, 180)
  637. },
  638. })
  639. }
  640. restorePreferredDevice(): void {
  641. try {
  642. const stored = wx.getStorageSync(PREFERRED_HEART_RATE_DEVICE_STORAGE_KEY)
  643. if (!stored || typeof stored !== 'object') {
  644. return
  645. }
  646. const normalized = stored as { deviceId?: unknown; name?: unknown }
  647. if (typeof normalized.deviceId !== 'string' || !normalized.deviceId) {
  648. return
  649. }
  650. this.lastDeviceId = normalized.deviceId
  651. this.lastDeviceName = typeof normalized.name === 'string' && normalized.name ? normalized.name : '心率带'
  652. } catch {}
  653. }
  654. persistPreferredDevice(): void {
  655. if (!this.lastDeviceId) {
  656. return
  657. }
  658. try {
  659. wx.setStorageSync(PREFERRED_HEART_RATE_DEVICE_STORAGE_KEY, {
  660. deviceId: this.lastDeviceId,
  661. name: this.lastDeviceName || '心率带',
  662. })
  663. } catch {}
  664. }
  665. removePreferredDevice(): void {
  666. try {
  667. wx.removeStorageSync(PREFERRED_HEART_RATE_DEVICE_STORAGE_KEY)
  668. } catch {}
  669. }
  670. }