heartRateController.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  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. }
  7. type BluetoothDeviceLike = {
  8. deviceId?: string
  9. name?: string
  10. localName?: string
  11. advertisServiceUUIDs?: string[]
  12. }
  13. const HEART_RATE_SERVICE_UUID = '180D'
  14. const HEART_RATE_MEASUREMENT_UUID = '2A37'
  15. const DISCOVERY_TIMEOUT_MS = 12000
  16. function normalizeUuid(uuid: string | undefined | null): string {
  17. return String(uuid || '').replace(/[^0-9a-f]/gi, '').toUpperCase()
  18. }
  19. function matchesShortUuid(uuid: string | undefined | null, shortUuid: string): boolean {
  20. const normalized = normalizeUuid(uuid)
  21. const normalizedShort = normalizeUuid(shortUuid)
  22. if (!normalized || !normalizedShort) {
  23. return false
  24. }
  25. return normalized === normalizedShort
  26. || normalized.indexOf(`0000${normalizedShort}00001000800000805F9B34FB`) === 0
  27. || normalized.endsWith(normalizedShort)
  28. }
  29. function getDeviceDisplayName(device: BluetoothDeviceLike | null | undefined): string {
  30. if (!device) {
  31. return '心率带'
  32. }
  33. return device.name || device.localName || '未命名心率带'
  34. }
  35. function isHeartRateDevice(device: BluetoothDeviceLike): boolean {
  36. const serviceIds = Array.isArray(device.advertisServiceUUIDs) ? device.advertisServiceUUIDs : []
  37. if (serviceIds.some((uuid) => matchesShortUuid(uuid, HEART_RATE_SERVICE_UUID))) {
  38. return true
  39. }
  40. const name = `${device.name || ''} ${device.localName || ''}`.toUpperCase()
  41. return name.indexOf('HR') !== -1
  42. || name.indexOf('HEART') !== -1
  43. || name.indexOf('POLAR') !== -1
  44. || name.indexOf('GARMIN') !== -1
  45. || name.indexOf('COOSPO') !== -1
  46. }
  47. function parseHeartRateMeasurement(buffer: ArrayBuffer): number | null {
  48. if (!buffer || buffer.byteLength < 2) {
  49. return null
  50. }
  51. const view = new DataView(buffer)
  52. const flags = view.getUint8(0)
  53. const isUint16 = (flags & 0x01) === 0x01
  54. if (isUint16) {
  55. if (buffer.byteLength < 3) {
  56. return null
  57. }
  58. return view.getUint16(1, true)
  59. }
  60. return view.getUint8(1)
  61. }
  62. export class HeartRateController {
  63. callbacks: HeartRateControllerCallbacks
  64. scanning: boolean
  65. connecting: boolean
  66. connected: boolean
  67. currentDeviceId: string | null
  68. currentDeviceName: string | null
  69. measurementServiceId: string | null
  70. measurementCharacteristicId: string | null
  71. discoveryTimer: number
  72. deviceFoundHandler: ((result: any) => void) | null
  73. characteristicHandler: ((result: any) => void) | null
  74. connectionStateHandler: ((result: any) => void) | null
  75. constructor(callbacks: HeartRateControllerCallbacks) {
  76. this.callbacks = callbacks
  77. this.scanning = false
  78. this.connecting = false
  79. this.connected = false
  80. this.currentDeviceId = null
  81. this.currentDeviceName = null
  82. this.measurementServiceId = null
  83. this.measurementCharacteristicId = null
  84. this.discoveryTimer = 0
  85. this.deviceFoundHandler = null
  86. this.characteristicHandler = null
  87. this.connectionStateHandler = null
  88. }
  89. startScanAndConnect(): void {
  90. if (this.connected) {
  91. this.callbacks.onStatus(`心率带已连接: ${this.currentDeviceName || '设备'}`)
  92. return
  93. }
  94. if (this.scanning || this.connecting) {
  95. this.callbacks.onStatus('心率带连接进行中')
  96. return
  97. }
  98. const wxAny = wx as any
  99. wxAny.openBluetoothAdapter({
  100. success: () => {
  101. this.beginDiscovery()
  102. },
  103. fail: (error: any) => {
  104. const message = error && error.errMsg ? error.errMsg : 'openBluetoothAdapter 失败'
  105. this.callbacks.onError(`蓝牙不可用: ${message}`)
  106. },
  107. })
  108. }
  109. disconnect(): void {
  110. this.clearDiscoveryTimer()
  111. this.stopDiscovery()
  112. const deviceId = this.currentDeviceId
  113. this.connecting = false
  114. if (!deviceId) {
  115. this.clearConnectionState()
  116. this.callbacks.onStatus('心率带未连接')
  117. return
  118. }
  119. const wxAny = wx as any
  120. wxAny.closeBLEConnection({
  121. deviceId,
  122. complete: () => {
  123. this.clearConnectionState()
  124. this.callbacks.onStatus('心率带已断开')
  125. },
  126. })
  127. }
  128. destroy(): void {
  129. this.clearDiscoveryTimer()
  130. this.stopDiscovery()
  131. this.detachListeners()
  132. const deviceId = this.currentDeviceId
  133. if (deviceId) {
  134. const wxAny = wx as any
  135. wxAny.closeBLEConnection({
  136. deviceId,
  137. complete: () => {},
  138. })
  139. }
  140. const wxAny = wx as any
  141. if (typeof wxAny.closeBluetoothAdapter === 'function') {
  142. wxAny.closeBluetoothAdapter({
  143. complete: () => {},
  144. })
  145. }
  146. this.clearConnectionState()
  147. }
  148. beginDiscovery(): void {
  149. this.bindListeners()
  150. const wxAny = wx as any
  151. wxAny.startBluetoothDevicesDiscovery({
  152. allowDuplicatesKey: false,
  153. services: [HEART_RATE_SERVICE_UUID],
  154. success: () => {
  155. this.scanning = true
  156. this.callbacks.onStatus('正在扫描心率带')
  157. this.clearDiscoveryTimer()
  158. this.discoveryTimer = setTimeout(() => {
  159. this.discoveryTimer = 0
  160. if (!this.scanning || this.connected || this.connecting) {
  161. return
  162. }
  163. this.stopDiscovery()
  164. this.callbacks.onError('未发现可连接的心率带')
  165. }, DISCOVERY_TIMEOUT_MS) as unknown as number
  166. },
  167. fail: (error: any) => {
  168. const message = error && error.errMsg ? error.errMsg : 'startBluetoothDevicesDiscovery 失败'
  169. this.callbacks.onError(`扫描心率带失败: ${message}`)
  170. },
  171. })
  172. }
  173. stopDiscovery(): void {
  174. this.clearDiscoveryTimer()
  175. if (!this.scanning) {
  176. return
  177. }
  178. this.scanning = false
  179. const wxAny = wx as any
  180. wxAny.stopBluetoothDevicesDiscovery({
  181. complete: () => {},
  182. })
  183. }
  184. bindListeners(): void {
  185. const wxAny = wx as any
  186. if (!this.deviceFoundHandler) {
  187. this.deviceFoundHandler = (result: any) => {
  188. const devices = Array.isArray(result && result.devices)
  189. ? result.devices
  190. : result && result.deviceId
  191. ? [result]
  192. : []
  193. const targetDevice = devices.find((device: BluetoothDeviceLike) => isHeartRateDevice(device))
  194. if (!targetDevice || !targetDevice.deviceId || !this.scanning || this.connecting || this.connected) {
  195. return
  196. }
  197. this.stopDiscovery()
  198. this.connectToDevice(targetDevice.deviceId, getDeviceDisplayName(targetDevice))
  199. }
  200. if (typeof wxAny.onBluetoothDeviceFound === 'function') {
  201. wxAny.onBluetoothDeviceFound(this.deviceFoundHandler)
  202. }
  203. }
  204. if (!this.characteristicHandler) {
  205. this.characteristicHandler = (result: any) => {
  206. if (!result || !result.value) {
  207. return
  208. }
  209. if (this.currentDeviceId && result.deviceId && result.deviceId !== this.currentDeviceId) {
  210. return
  211. }
  212. if (!matchesShortUuid(result.characteristicId, HEART_RATE_MEASUREMENT_UUID)) {
  213. return
  214. }
  215. const bpm = parseHeartRateMeasurement(result.value)
  216. if (bpm === null || !Number.isFinite(bpm) || bpm <= 0) {
  217. return
  218. }
  219. this.callbacks.onHeartRate(Math.round(bpm))
  220. }
  221. if (typeof wxAny.onBLECharacteristicValueChange === 'function') {
  222. wxAny.onBLECharacteristicValueChange(this.characteristicHandler)
  223. }
  224. }
  225. if (!this.connectionStateHandler) {
  226. this.connectionStateHandler = (result: any) => {
  227. if (!result || !this.currentDeviceId || result.deviceId !== this.currentDeviceId) {
  228. return
  229. }
  230. if (result.connected) {
  231. return
  232. }
  233. this.clearConnectionState()
  234. this.callbacks.onStatus('心率带连接已断开')
  235. }
  236. if (typeof wxAny.onBLEConnectionStateChange === 'function') {
  237. wxAny.onBLEConnectionStateChange(this.connectionStateHandler)
  238. }
  239. }
  240. }
  241. detachListeners(): void {
  242. const wxAny = wx as any
  243. if (this.deviceFoundHandler && typeof wxAny.offBluetoothDeviceFound === 'function') {
  244. wxAny.offBluetoothDeviceFound(this.deviceFoundHandler)
  245. }
  246. if (this.characteristicHandler && typeof wxAny.offBLECharacteristicValueChange === 'function') {
  247. wxAny.offBLECharacteristicValueChange(this.characteristicHandler)
  248. }
  249. if (this.connectionStateHandler && typeof wxAny.offBLEConnectionStateChange === 'function') {
  250. wxAny.offBLEConnectionStateChange(this.connectionStateHandler)
  251. }
  252. this.deviceFoundHandler = null
  253. this.characteristicHandler = null
  254. this.connectionStateHandler = null
  255. }
  256. connectToDevice(deviceId: string, deviceName: string): void {
  257. this.connecting = true
  258. this.currentDeviceId = deviceId
  259. this.currentDeviceName = deviceName
  260. this.callbacks.onStatus(`正在连接 ${deviceName}`)
  261. const wxAny = wx as any
  262. wxAny.createBLEConnection({
  263. deviceId,
  264. timeout: 10000,
  265. success: () => {
  266. this.discoverMeasurementCharacteristic(deviceId, deviceName)
  267. },
  268. fail: (error: any) => {
  269. const message = error && error.errMsg ? error.errMsg : 'createBLEConnection 失败'
  270. this.clearConnectionState()
  271. this.callbacks.onError(`连接心率带失败: ${message}`)
  272. },
  273. })
  274. }
  275. discoverMeasurementCharacteristic(deviceId: string, deviceName: string): void {
  276. const wxAny = wx as any
  277. wxAny.getBLEDeviceServices({
  278. deviceId,
  279. success: (serviceResult: any) => {
  280. const services = Array.isArray(serviceResult && serviceResult.services) ? serviceResult.services : []
  281. const service = services.find((item: any) => matchesShortUuid(item && item.uuid, HEART_RATE_SERVICE_UUID))
  282. if (!service || !service.uuid) {
  283. this.failConnection(deviceId, '未找到标准心率服务')
  284. return
  285. }
  286. wxAny.getBLEDeviceCharacteristics({
  287. deviceId,
  288. serviceId: service.uuid,
  289. success: (characteristicResult: any) => {
  290. const characteristics = Array.isArray(characteristicResult && characteristicResult.characteristics)
  291. ? characteristicResult.characteristics
  292. : []
  293. const characteristic = characteristics.find((item: any) => {
  294. const properties = item && item.properties ? item.properties : {}
  295. return matchesShortUuid(item && item.uuid, HEART_RATE_MEASUREMENT_UUID)
  296. && (properties.notify || properties.indicate)
  297. })
  298. if (!characteristic || !characteristic.uuid) {
  299. this.failConnection(deviceId, '未找到心率通知特征')
  300. return
  301. }
  302. this.measurementServiceId = service.uuid
  303. this.measurementCharacteristicId = characteristic.uuid
  304. wxAny.notifyBLECharacteristicValueChange({
  305. state: true,
  306. deviceId,
  307. serviceId: service.uuid,
  308. characteristicId: characteristic.uuid,
  309. success: () => {
  310. this.connecting = false
  311. this.connected = true
  312. this.callbacks.onConnectionChange(true, deviceName)
  313. this.callbacks.onStatus(`心率带已连接: ${deviceName}`)
  314. },
  315. fail: (error: any) => {
  316. const message = error && error.errMsg ? error.errMsg : 'notifyBLECharacteristicValueChange 失败'
  317. this.failConnection(deviceId, `心率订阅失败: ${message}`)
  318. },
  319. })
  320. },
  321. fail: (error: any) => {
  322. const message = error && error.errMsg ? error.errMsg : 'getBLEDeviceCharacteristics 失败'
  323. this.failConnection(deviceId, `读取心率特征失败: ${message}`)
  324. },
  325. })
  326. },
  327. fail: (error: any) => {
  328. const message = error && error.errMsg ? error.errMsg : 'getBLEDeviceServices 失败'
  329. this.failConnection(deviceId, `读取心率服务失败: ${message}`)
  330. },
  331. })
  332. }
  333. failConnection(deviceId: string, message: string): void {
  334. const wxAny = wx as any
  335. wxAny.closeBLEConnection({
  336. deviceId,
  337. complete: () => {
  338. this.clearConnectionState()
  339. this.callbacks.onError(message)
  340. },
  341. })
  342. }
  343. clearConnectionState(): void {
  344. const wasConnected = this.connected
  345. this.scanning = false
  346. this.connecting = false
  347. this.connected = false
  348. this.currentDeviceId = null
  349. this.measurementServiceId = null
  350. this.measurementCharacteristicId = null
  351. const previousDeviceName = this.currentDeviceName
  352. this.currentDeviceName = null
  353. if (wasConnected || previousDeviceName) {
  354. this.callbacks.onConnectionChange(false, null)
  355. }
  356. }
  357. clearDiscoveryTimer(): void {
  358. if (this.discoveryTimer) {
  359. clearTimeout(this.discoveryTimer)
  360. this.discoveryTimer = 0
  361. }
  362. }
  363. }