export interface HeartRateControllerCallbacks { onHeartRate: (bpm: number) => void onStatus: (message: string) => void onError: (message: string) => void onConnectionChange: (connected: boolean, deviceName: string | null) => void onDeviceListChange: (devices: HeartRateDiscoveredDevice[]) => void } export interface HeartRateDiscoveredDevice { deviceId: string name: string rssi: number | null lastSeenAt: number isPreferred: boolean } type BluetoothDeviceLike = { deviceId?: string name?: string localName?: string RSSI?: number advertisServiceUUIDs?: string[] } const HEART_RATE_SERVICE_UUID = '180D' const HEART_RATE_MEASUREMENT_UUID = '2A37' const DISCOVERY_TIMEOUT_MS = 12000 const DEVICE_STALE_MS = 15000 const PREFERRED_HEART_RATE_DEVICE_STORAGE_KEY = 'cmr.preferredHeartRateDevice' 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 discoveredDevices: HeartRateDiscoveredDevice[] lastDeviceId: string | null lastDeviceName: string | null manualDisconnect: boolean reconnecting: boolean disconnecting: boolean disconnectingDeviceId: string | null autoConnectDeviceId: string | null measurementServiceId: string | null measurementCharacteristicId: string | null discoveryTimer: number reconnectTimer: 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.discoveredDevices = [] this.lastDeviceId = null this.lastDeviceName = null this.manualDisconnect = false this.reconnecting = false this.disconnecting = false this.disconnectingDeviceId = null this.autoConnectDeviceId = null this.measurementServiceId = null this.measurementCharacteristicId = null this.discoveryTimer = 0 this.reconnectTimer = 0 this.deviceFoundHandler = null this.characteristicHandler = null this.connectionStateHandler = null this.restorePreferredDevice() } startScanAndConnect(): void { if (this.connected) { this.callbacks.onStatus(`心率带已连接: ${this.currentDeviceName || '设备'}`) return } if (this.disconnecting) { this.callbacks.onStatus('心率带断开中,请稍后再试') return } if (this.scanning || this.connecting) { this.callbacks.onStatus('心率带连接进行中') return } this.manualDisconnect = false this.reconnecting = false this.clearReconnectTimer() this.withFreshBluetoothAdapter(() => { if (this.lastDeviceId) { this.callbacks.onStatus(`正在扫描并优先连接 ${this.lastDeviceName || '心率带'}`) this.beginDiscovery() return } this.beginDiscovery() }) } disconnect(): void { this.clearDiscoveryTimer() this.clearReconnectTimer() this.stopDiscovery() const deviceId = this.currentDeviceId this.connecting = false this.reconnecting = false this.manualDisconnect = true this.disconnecting = true if (!deviceId) { this.disconnecting = false this.clearConnectionState() this.callbacks.onStatus('心率带未连接') return } this.disconnectingDeviceId = deviceId const wxAny = wx as any wxAny.closeBLEConnection({ deviceId, complete: () => { this.disconnecting = false this.clearConnectionState() this.callbacks.onStatus('心率带已断开') }, }) } destroy(): void { this.clearDiscoveryTimer() this.clearReconnectTimer() 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 { if (this.scanning || this.connecting || this.connected) { return } this.bindListeners() this.autoConnectDeviceId = this.lastDeviceId const wxAny = wx as any wxAny.startBluetoothDevicesDiscovery({ allowDuplicatesKey: false, services: [HEART_RATE_SERVICE_UUID], success: () => { this.scanning = true this.pruneDiscoveredDevices() this.callbacks.onStatus(this.autoConnectDeviceId ? '正在扫描心率带并等待自动连接' : '正在扫描心率带,请选择设备') this.clearDiscoveryTimer() this.discoveryTimer = setTimeout(() => { this.discoveryTimer = 0 if (!this.scanning || this.connected || this.connecting) { return } this.stopDiscovery() this.callbacks.onError(this.discoveredDevices.length ? '已发现心率带,请从列表选择连接' : '未发现可连接的心率带') }, 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 this.autoConnectDeviceId = null 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] : [] this.mergeDiscoveredDevices(devices) if (!this.scanning || this.connecting || this.connected) { return } const targetDevice = this.selectTargetDevice(devices) if (!targetDevice || !targetDevice.deviceId) { 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 || !result.deviceId) { return } if (result.connected) { return } if (this.disconnectingDeviceId && result.deviceId === this.disconnectingDeviceId) { this.disconnectingDeviceId = null return } if (!this.currentDeviceId || result.deviceId !== this.currentDeviceId) { return } const disconnectedDeviceId = this.currentDeviceId const disconnectedDeviceName = this.currentDeviceName this.clearConnectionState() this.callbacks.onStatus('心率带连接已断开') this.scheduleAutoReconnect(disconnectedDeviceId, disconnectedDeviceName) } 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, fallbackToDiscovery: boolean = false): void { this.connecting = true this.reconnecting = false this.disconnecting = false this.manualDisconnect = false this.disconnectingDeviceId = null this.autoConnectDeviceId = deviceId 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() if (fallbackToDiscovery && !this.manualDisconnect) { this.callbacks.onStatus(`直连失败,转入扫描: ${deviceName}`) this.beginDiscovery() return } 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.lastDeviceId = deviceId this.lastDeviceName = deviceName this.persistPreferredDevice() this.manualDisconnect = false this.reconnecting = false this.disconnectingDeviceId = null this.autoConnectDeviceId = deviceId this.refreshPreferredFlags() this.clearReconnectTimer() 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) this.scheduleAutoReconnect(this.lastDeviceId, this.lastDeviceName) }, }) } clearConnectionState(): void { const wasConnected = this.connected this.scanning = false this.connecting = false this.connected = false this.disconnecting = false this.disconnectingDeviceId = null this.autoConnectDeviceId = null 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 } } clearReconnectTimer(): void { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer) this.reconnectTimer = 0 } } selectTargetDevice(devices: BluetoothDeviceLike[]): BluetoothDeviceLike | null { if (!Array.isArray(devices) || !devices.length) { return null } if (this.autoConnectDeviceId) { const rememberedDevice = devices.find((device) => device && device.deviceId === this.autoConnectDeviceId) if (rememberedDevice && isHeartRateDevice(rememberedDevice)) { return rememberedDevice } return null } return null } scheduleAutoReconnect(deviceId: string | null, deviceName: string | null): void { if (this.manualDisconnect || !deviceId || this.connected || this.connecting || this.scanning) { return } this.lastDeviceId = deviceId this.lastDeviceName = deviceName || this.lastDeviceName this.clearReconnectTimer() this.reconnecting = true this.callbacks.onStatus(`心率带已断开,等待自动重连: ${this.lastDeviceName || '设备'}`) this.reconnectTimer = setTimeout(() => { this.reconnectTimer = 0 if (this.manualDisconnect || this.connected || this.connecting || this.scanning) { return } const rememberedDeviceId = this.lastDeviceId const rememberedDeviceName = this.lastDeviceName || '心率带' this.reconnecting = false this.withFreshBluetoothAdapter(() => { if (rememberedDeviceId) { this.callbacks.onStatus(`正在自动重连 ${rememberedDeviceName}`) this.resetConnectionAndConnect(rememberedDeviceId, rememberedDeviceName, true) return } this.beginDiscovery() }) }, 600) as unknown as number } resetConnectionAndConnect(deviceId: string, deviceName: string, fallbackToDiscovery: boolean): void { const wxAny = wx as any this.disconnectingDeviceId = deviceId wxAny.closeBLEConnection({ deviceId, complete: () => { if (this.disconnectingDeviceId === deviceId) { this.disconnectingDeviceId = null } setTimeout(() => { if (this.connected || this.connecting || this.scanning || this.disconnecting) { return } this.connectToDevice(deviceId, deviceName, fallbackToDiscovery) }, 320) }, }) } connectToDiscoveredDevice(deviceId: string): void { if (!deviceId) { return } if (this.disconnecting) { this.callbacks.onStatus('心率带断开中,请稍后再试') return } const targetDevice = this.discoveredDevices.find((device) => device.deviceId === deviceId) const deviceName = targetDevice ? targetDevice.name : (this.lastDeviceId === deviceId ? (this.lastDeviceName || '心率带') : '心率带') this.lastDeviceId = deviceId this.lastDeviceName = deviceName this.refreshPreferredFlags() this.stopDiscovery() this.withFreshBluetoothAdapter(() => { this.callbacks.onStatus(`正在连接 ${deviceName}`) this.resetConnectionAndConnect(deviceId, deviceName, true) }) } clearPreferredDevice(): void { this.lastDeviceId = null this.lastDeviceName = null this.autoConnectDeviceId = null this.removePreferredDevice() this.refreshPreferredFlags() this.callbacks.onStatus('已清除首选心率带') } mergeDiscoveredDevices(devices: BluetoothDeviceLike[]): void { if (!Array.isArray(devices) || !devices.length) { return } const now = Date.now() let changed = false const nextDevices = [...this.discoveredDevices] for (const rawDevice of devices) { if (!rawDevice || !rawDevice.deviceId || !isHeartRateDevice(rawDevice)) { continue } const name = getDeviceDisplayName(rawDevice) const rssi = typeof rawDevice.RSSI === 'number' && Number.isFinite(rawDevice.RSSI) ? rawDevice.RSSI : null const existingIndex = nextDevices.findIndex((item) => item.deviceId === rawDevice.deviceId) const nextDevice: HeartRateDiscoveredDevice = { deviceId: rawDevice.deviceId, name, rssi, lastSeenAt: now, isPreferred: rawDevice.deviceId === this.lastDeviceId, } if (existingIndex >= 0) { nextDevices[existingIndex] = nextDevice } else { nextDevices.push(nextDevice) } changed = true } if (!changed) { return } this.discoveredDevices = this.sortDiscoveredDevices(nextDevices.filter((device) => now - device.lastSeenAt <= DEVICE_STALE_MS)) this.callbacks.onDeviceListChange([...this.discoveredDevices]) } pruneDiscoveredDevices(): void { const now = Date.now() const nextDevices = this.sortDiscoveredDevices( this.discoveredDevices.filter((device) => now - device.lastSeenAt <= DEVICE_STALE_MS), ) const changed = nextDevices.length !== this.discoveredDevices.length || nextDevices.some((device, index) => { const previous = this.discoveredDevices[index] return !previous || previous.deviceId !== device.deviceId || previous.isPreferred !== device.isPreferred || previous.rssi !== device.rssi }) this.discoveredDevices = nextDevices if (changed) { this.callbacks.onDeviceListChange([...this.discoveredDevices]) } } refreshPreferredFlags(): void { if (!this.discoveredDevices.length) { this.callbacks.onDeviceListChange([]) return } this.discoveredDevices = this.sortDiscoveredDevices( this.discoveredDevices.map((device) => ({ ...device, isPreferred: !!this.lastDeviceId && device.deviceId === this.lastDeviceId, })), ) this.callbacks.onDeviceListChange([...this.discoveredDevices]) } sortDiscoveredDevices(devices: HeartRateDiscoveredDevice[]): HeartRateDiscoveredDevice[] { return [...devices].sort((a, b) => { if (a.isPreferred !== b.isPreferred) { return a.isPreferred ? -1 : 1 } const aRssi = a.rssi === null ? -999 : a.rssi const bRssi = b.rssi === null ? -999 : b.rssi if (aRssi !== bRssi) { return bRssi - aRssi } return b.lastSeenAt - a.lastSeenAt }) } withFreshBluetoothAdapter(onReady: () => void): void { const wxAny = wx as any const openAdapter = () => { wxAny.openBluetoothAdapter({ success: () => { onReady() }, fail: (error: any) => { const message = error && error.errMsg ? error.errMsg : 'openBluetoothAdapter 失败' this.callbacks.onError(`蓝牙不可用: ${message}`) }, }) } if (typeof wxAny.closeBluetoothAdapter !== 'function') { openAdapter() return } wxAny.closeBluetoothAdapter({ complete: () => { setTimeout(() => { openAdapter() }, 180) }, }) } restorePreferredDevice(): void { try { const stored = wx.getStorageSync(PREFERRED_HEART_RATE_DEVICE_STORAGE_KEY) if (!stored || typeof stored !== 'object') { return } const normalized = stored as { deviceId?: unknown; name?: unknown } if (typeof normalized.deviceId !== 'string' || !normalized.deviceId) { return } this.lastDeviceId = normalized.deviceId this.lastDeviceName = typeof normalized.name === 'string' && normalized.name ? normalized.name : '心率带' } catch {} } persistPreferredDevice(): void { if (!this.lastDeviceId) { return } try { wx.setStorageSync(PREFERRED_HEART_RATE_DEVICE_STORAGE_KEY, { deviceId: this.lastDeviceId, name: this.lastDeviceName || '心率带', }) } catch {} } removePreferredDevice(): void { try { wx.removeStorageSync(PREFERRED_HEART_RATE_DEVICE_STORAGE_KEY) } catch {} } }