Refine telemetry-driven HUD and fitness feedback
This commit is contained in:
421
miniprogram/engine/sensor/heartRateController.ts
Normal file
421
miniprogram/engine/sensor/heartRateController.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user