| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421 |
- export interface HeartRateControllerCallbacks {
- onHeartRate: (bpm: number) => void
- onStatus: (message: string) => void
- onError: (message: string) => void
- onConnectionChange: (connected: boolean, deviceName: string | null) => void
- }
- type BluetoothDeviceLike = {
- deviceId?: string
- name?: string
- localName?: string
- advertisServiceUUIDs?: string[]
- }
- const HEART_RATE_SERVICE_UUID = '180D'
- const HEART_RATE_MEASUREMENT_UUID = '2A37'
- const DISCOVERY_TIMEOUT_MS = 12000
- function normalizeUuid(uuid: string | undefined | null): string {
- return String(uuid || '').replace(/[^0-9a-f]/gi, '').toUpperCase()
- }
- function matchesShortUuid(uuid: string | undefined | null, shortUuid: string): boolean {
- const normalized = normalizeUuid(uuid)
- const normalizedShort = normalizeUuid(shortUuid)
- if (!normalized || !normalizedShort) {
- return false
- }
- return normalized === normalizedShort
- || normalized.indexOf(`0000${normalizedShort}00001000800000805F9B34FB`) === 0
- || normalized.endsWith(normalizedShort)
- }
- function getDeviceDisplayName(device: BluetoothDeviceLike | null | undefined): string {
- if (!device) {
- return '心率带'
- }
- return device.name || device.localName || '未命名心率带'
- }
- function isHeartRateDevice(device: BluetoothDeviceLike): boolean {
- const serviceIds = Array.isArray(device.advertisServiceUUIDs) ? device.advertisServiceUUIDs : []
- if (serviceIds.some((uuid) => matchesShortUuid(uuid, HEART_RATE_SERVICE_UUID))) {
- return true
- }
- const name = `${device.name || ''} ${device.localName || ''}`.toUpperCase()
- return name.indexOf('HR') !== -1
- || name.indexOf('HEART') !== -1
- || name.indexOf('POLAR') !== -1
- || name.indexOf('GARMIN') !== -1
- || name.indexOf('COOSPO') !== -1
- }
- function parseHeartRateMeasurement(buffer: ArrayBuffer): number | null {
- if (!buffer || buffer.byteLength < 2) {
- return null
- }
- const view = new DataView(buffer)
- const flags = view.getUint8(0)
- const isUint16 = (flags & 0x01) === 0x01
- if (isUint16) {
- if (buffer.byteLength < 3) {
- return null
- }
- return view.getUint16(1, true)
- }
- return view.getUint8(1)
- }
- export class HeartRateController {
- callbacks: HeartRateControllerCallbacks
- scanning: boolean
- connecting: boolean
- connected: boolean
- currentDeviceId: string | null
- currentDeviceName: string | null
- measurementServiceId: string | null
- measurementCharacteristicId: string | null
- discoveryTimer: number
- deviceFoundHandler: ((result: any) => void) | null
- characteristicHandler: ((result: any) => void) | null
- connectionStateHandler: ((result: any) => void) | null
- constructor(callbacks: HeartRateControllerCallbacks) {
- this.callbacks = callbacks
- this.scanning = false
- this.connecting = false
- this.connected = false
- this.currentDeviceId = null
- this.currentDeviceName = null
- this.measurementServiceId = null
- this.measurementCharacteristicId = null
- this.discoveryTimer = 0
- this.deviceFoundHandler = null
- this.characteristicHandler = null
- this.connectionStateHandler = null
- }
- startScanAndConnect(): void {
- if (this.connected) {
- this.callbacks.onStatus(`心率带已连接: ${this.currentDeviceName || '设备'}`)
- return
- }
- if (this.scanning || this.connecting) {
- this.callbacks.onStatus('心率带连接进行中')
- return
- }
- const wxAny = wx as any
- wxAny.openBluetoothAdapter({
- success: () => {
- this.beginDiscovery()
- },
- fail: (error: any) => {
- const message = error && error.errMsg ? error.errMsg : 'openBluetoothAdapter 失败'
- this.callbacks.onError(`蓝牙不可用: ${message}`)
- },
- })
- }
- disconnect(): void {
- this.clearDiscoveryTimer()
- this.stopDiscovery()
- const deviceId = this.currentDeviceId
- this.connecting = false
- if (!deviceId) {
- this.clearConnectionState()
- this.callbacks.onStatus('心率带未连接')
- return
- }
- const wxAny = wx as any
- wxAny.closeBLEConnection({
- deviceId,
- complete: () => {
- this.clearConnectionState()
- this.callbacks.onStatus('心率带已断开')
- },
- })
- }
- destroy(): void {
- this.clearDiscoveryTimer()
- this.stopDiscovery()
- this.detachListeners()
- const deviceId = this.currentDeviceId
- if (deviceId) {
- const wxAny = wx as any
- wxAny.closeBLEConnection({
- deviceId,
- complete: () => {},
- })
- }
- const wxAny = wx as any
- if (typeof wxAny.closeBluetoothAdapter === 'function') {
- wxAny.closeBluetoothAdapter({
- complete: () => {},
- })
- }
- this.clearConnectionState()
- }
- beginDiscovery(): void {
- this.bindListeners()
- const wxAny = wx as any
- wxAny.startBluetoothDevicesDiscovery({
- allowDuplicatesKey: false,
- services: [HEART_RATE_SERVICE_UUID],
- success: () => {
- this.scanning = true
- this.callbacks.onStatus('正在扫描心率带')
- this.clearDiscoveryTimer()
- this.discoveryTimer = setTimeout(() => {
- this.discoveryTimer = 0
- if (!this.scanning || this.connected || this.connecting) {
- return
- }
- this.stopDiscovery()
- this.callbacks.onError('未发现可连接的心率带')
- }, DISCOVERY_TIMEOUT_MS) as unknown as number
- },
- fail: (error: any) => {
- const message = error && error.errMsg ? error.errMsg : 'startBluetoothDevicesDiscovery 失败'
- this.callbacks.onError(`扫描心率带失败: ${message}`)
- },
- })
- }
- stopDiscovery(): void {
- this.clearDiscoveryTimer()
- if (!this.scanning) {
- return
- }
- this.scanning = false
- const wxAny = wx as any
- wxAny.stopBluetoothDevicesDiscovery({
- complete: () => {},
- })
- }
- bindListeners(): void {
- const wxAny = wx as any
- if (!this.deviceFoundHandler) {
- this.deviceFoundHandler = (result: any) => {
- const devices = Array.isArray(result && result.devices)
- ? result.devices
- : result && result.deviceId
- ? [result]
- : []
- const targetDevice = devices.find((device: BluetoothDeviceLike) => isHeartRateDevice(device))
- if (!targetDevice || !targetDevice.deviceId || !this.scanning || this.connecting || this.connected) {
- return
- }
- this.stopDiscovery()
- this.connectToDevice(targetDevice.deviceId, getDeviceDisplayName(targetDevice))
- }
- if (typeof wxAny.onBluetoothDeviceFound === 'function') {
- wxAny.onBluetoothDeviceFound(this.deviceFoundHandler)
- }
- }
- if (!this.characteristicHandler) {
- this.characteristicHandler = (result: any) => {
- if (!result || !result.value) {
- return
- }
- if (this.currentDeviceId && result.deviceId && result.deviceId !== this.currentDeviceId) {
- return
- }
- if (!matchesShortUuid(result.characteristicId, HEART_RATE_MEASUREMENT_UUID)) {
- return
- }
- const bpm = parseHeartRateMeasurement(result.value)
- if (bpm === null || !Number.isFinite(bpm) || bpm <= 0) {
- return
- }
- this.callbacks.onHeartRate(Math.round(bpm))
- }
- if (typeof wxAny.onBLECharacteristicValueChange === 'function') {
- wxAny.onBLECharacteristicValueChange(this.characteristicHandler)
- }
- }
- if (!this.connectionStateHandler) {
- this.connectionStateHandler = (result: any) => {
- if (!result || !this.currentDeviceId || result.deviceId !== this.currentDeviceId) {
- return
- }
- if (result.connected) {
- return
- }
- this.clearConnectionState()
- this.callbacks.onStatus('心率带连接已断开')
- }
- if (typeof wxAny.onBLEConnectionStateChange === 'function') {
- wxAny.onBLEConnectionStateChange(this.connectionStateHandler)
- }
- }
- }
- detachListeners(): void {
- const wxAny = wx as any
- if (this.deviceFoundHandler && typeof wxAny.offBluetoothDeviceFound === 'function') {
- wxAny.offBluetoothDeviceFound(this.deviceFoundHandler)
- }
- if (this.characteristicHandler && typeof wxAny.offBLECharacteristicValueChange === 'function') {
- wxAny.offBLECharacteristicValueChange(this.characteristicHandler)
- }
- if (this.connectionStateHandler && typeof wxAny.offBLEConnectionStateChange === 'function') {
- wxAny.offBLEConnectionStateChange(this.connectionStateHandler)
- }
- this.deviceFoundHandler = null
- this.characteristicHandler = null
- this.connectionStateHandler = null
- }
- connectToDevice(deviceId: string, deviceName: string): void {
- this.connecting = true
- this.currentDeviceId = deviceId
- this.currentDeviceName = deviceName
- this.callbacks.onStatus(`正在连接 ${deviceName}`)
- const wxAny = wx as any
- wxAny.createBLEConnection({
- deviceId,
- timeout: 10000,
- success: () => {
- this.discoverMeasurementCharacteristic(deviceId, deviceName)
- },
- fail: (error: any) => {
- const message = error && error.errMsg ? error.errMsg : 'createBLEConnection 失败'
- this.clearConnectionState()
- this.callbacks.onError(`连接心率带失败: ${message}`)
- },
- })
- }
- discoverMeasurementCharacteristic(deviceId: string, deviceName: string): void {
- const wxAny = wx as any
- wxAny.getBLEDeviceServices({
- deviceId,
- success: (serviceResult: any) => {
- const services = Array.isArray(serviceResult && serviceResult.services) ? serviceResult.services : []
- const service = services.find((item: any) => matchesShortUuid(item && item.uuid, HEART_RATE_SERVICE_UUID))
- if (!service || !service.uuid) {
- this.failConnection(deviceId, '未找到标准心率服务')
- return
- }
- wxAny.getBLEDeviceCharacteristics({
- deviceId,
- serviceId: service.uuid,
- success: (characteristicResult: any) => {
- const characteristics = Array.isArray(characteristicResult && characteristicResult.characteristics)
- ? characteristicResult.characteristics
- : []
- const characteristic = characteristics.find((item: any) => {
- const properties = item && item.properties ? item.properties : {}
- return matchesShortUuid(item && item.uuid, HEART_RATE_MEASUREMENT_UUID)
- && (properties.notify || properties.indicate)
- })
- if (!characteristic || !characteristic.uuid) {
- this.failConnection(deviceId, '未找到心率通知特征')
- return
- }
- this.measurementServiceId = service.uuid
- this.measurementCharacteristicId = characteristic.uuid
- wxAny.notifyBLECharacteristicValueChange({
- state: true,
- deviceId,
- serviceId: service.uuid,
- characteristicId: characteristic.uuid,
- success: () => {
- this.connecting = false
- this.connected = true
- this.callbacks.onConnectionChange(true, deviceName)
- this.callbacks.onStatus(`心率带已连接: ${deviceName}`)
- },
- fail: (error: any) => {
- const message = error && error.errMsg ? error.errMsg : 'notifyBLECharacteristicValueChange 失败'
- this.failConnection(deviceId, `心率订阅失败: ${message}`)
- },
- })
- },
- fail: (error: any) => {
- const message = error && error.errMsg ? error.errMsg : 'getBLEDeviceCharacteristics 失败'
- this.failConnection(deviceId, `读取心率特征失败: ${message}`)
- },
- })
- },
- fail: (error: any) => {
- const message = error && error.errMsg ? error.errMsg : 'getBLEDeviceServices 失败'
- this.failConnection(deviceId, `读取心率服务失败: ${message}`)
- },
- })
- }
- failConnection(deviceId: string, message: string): void {
- const wxAny = wx as any
- wxAny.closeBLEConnection({
- deviceId,
- complete: () => {
- this.clearConnectionState()
- this.callbacks.onError(message)
- },
- })
- }
- clearConnectionState(): void {
- const wasConnected = this.connected
- this.scanning = false
- this.connecting = false
- this.connected = false
- this.currentDeviceId = null
- this.measurementServiceId = null
- this.measurementCharacteristicId = null
- const previousDeviceName = this.currentDeviceName
- this.currentDeviceName = null
- if (wasConnected || previousDeviceName) {
- this.callbacks.onConnectionChange(false, null)
- }
- }
- clearDiscoveryTimer(): void {
- if (this.discoveryTimer) {
- clearTimeout(this.discoveryTimer)
- this.discoveryTimer = 0
- }
- }
- }
|