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 } } }