Improve heart rate device reconnect flow

This commit is contained in:
2026-03-24 17:17:29 +08:00
parent 71ad6c6535
commit 0ccf7daf50
8 changed files with 831 additions and 72 deletions

View File

@@ -1,6 +1,6 @@
import { getTileSizePx, screenToWorld, worldToScreen, type CameraState } from '../camera/camera'
import { CompassHeadingController } from '../sensor/compassHeadingController'
import { HeartRateController } from '../sensor/heartRateController'
import { HeartRateController, type HeartRateDiscoveredDevice } from '../sensor/heartRateController'
import { LocationController } from '../sensor/locationController'
import { WebGLMapRenderer } from '../renderer/webglMapRenderer'
import { type MapRendererStats } from '../renderer/mapRenderer'
@@ -132,6 +132,14 @@ export interface MapEngineViewState {
heartRateConnected: boolean
heartRateStatusText: string
heartRateDeviceText: string
heartRateScanText: string
heartRateDiscoveredDevices: Array<{
deviceId: string
name: string
rssiText: string
preferred: boolean
connected: boolean
}>
gameSessionStatus: 'idle' | 'running' | 'finished' | 'failed'
gameModeText: string
panelTimerText: string
@@ -227,6 +235,8 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
'heartRateConnected',
'heartRateStatusText',
'heartRateDeviceText',
'heartRateScanText',
'heartRateDiscoveredDevices',
'gameSessionStatus',
'gameModeText',
'panelTimerText',
@@ -612,37 +622,64 @@ export class MapEngine {
this.setState(this.getLocationControllerViewPatch(), true)
},
})
this.heartRateController = new HeartRateController({
onHeartRate: (bpm) => {
this.telemetryRuntime.dispatch({
type: 'heart_rate_updated',
at: Date.now(),
this.heartRateController = new HeartRateController({
onHeartRate: (bpm) => {
this.telemetryRuntime.dispatch({
type: 'heart_rate_updated',
at: Date.now(),
bpm,
})
this.syncSessionTimerText()
},
onStatus: (message) => {
this.setState({
heartRateStatusText: message,
heartRateDeviceText: this.heartRateController.currentDeviceName || '--',
}, true)
},
onError: (message) => {
this.setState({
heartRateConnected: false,
heartRateStatusText: message,
heartRateDeviceText: '--',
statusText: `${message} (${this.buildVersion})`,
}, true)
},
onConnectionChange: (connected, deviceName) => {
this.setState({
heartRateConnected: connected,
heartRateDeviceText: deviceName || '--',
heartRateStatusText: connected ? '心率带已连接' : '心率带未连接',
}, true)
},
})
},
onStatus: (message) => {
const deviceName = this.heartRateController.currentDeviceName
|| (this.heartRateController.reconnecting ? this.heartRateController.lastDeviceName : null)
|| '--'
this.setState({
heartRateStatusText: message,
heartRateDeviceText: deviceName,
heartRateScanText: this.getHeartRateScanText(),
}, true)
},
onError: (message) => {
this.clearHeartRateSignal()
const deviceName = this.heartRateController.reconnecting
? (this.heartRateController.lastDeviceName || '--')
: '--'
this.setState({
heartRateConnected: false,
heartRateStatusText: message,
heartRateDeviceText: deviceName,
heartRateScanText: this.getHeartRateScanText(),
statusText: `${message} (${this.buildVersion})`,
}, true)
},
onConnectionChange: (connected, deviceName) => {
if (!connected) {
this.clearHeartRateSignal()
}
const resolvedDeviceName = connected
? (deviceName || '--')
: (this.heartRateController.reconnecting
? (this.heartRateController.lastDeviceName || '--')
: '--')
this.setState({
heartRateConnected: connected,
heartRateDeviceText: resolvedDeviceName,
heartRateStatusText: connected
? '心率带已连接'
: (this.heartRateController.reconnecting ? '心率带自动重连中' : '心率带未连接'),
heartRateScanText: this.getHeartRateScanText(),
heartRateDiscoveredDevices: this.formatHeartRateDevices(this.heartRateController.discoveredDevices),
}, true)
},
onDeviceListChange: (devices) => {
this.setState({
heartRateDiscoveredDevices: this.formatHeartRateDevices(devices),
heartRateScanText: this.getHeartRateScanText(),
}, true)
},
})
this.feedbackDirector = new FeedbackDirector({
showPunchFeedback: (text, tone, motionClass) => {
this.showPunchFeedback(text, tone, motionClass)
@@ -745,9 +782,11 @@ export class MapEngine {
mockCoordText: '--',
mockSpeedText: '--',
gpsCoordText: '--',
heartRateConnected: false,
heartRateStatusText: '心率带未连接',
heartRateDeviceText: '--',
heartRateConnected: false,
heartRateStatusText: '心率带未连接',
heartRateDeviceText: '--',
heartRateScanText: '未扫描',
heartRateDiscoveredDevices: [],
panelTimerText: '00:00:00',
panelMileageText: '0m',
panelActionTagText: '目标',
@@ -848,6 +887,14 @@ export class MapEngine {
this.mounted = false
}
handleAppShow(): void {
this.feedbackDirector.setAppAudioMode('foreground')
}
handleAppHide(): void {
this.feedbackDirector.setAppAudioMode('foreground')
}
clearGameRuntime(): void {
this.gameRuntime.clear()
@@ -858,6 +905,15 @@ export class MapEngine {
this.setCourseHeading(null)
}
clearHeartRateSignal(): void {
this.telemetryRuntime.dispatch({
type: 'heart_rate_updated',
at: Date.now(),
bpm: null,
})
this.syncSessionTimerText()
}
clearFinishedTestOverlay(): void {
this.currentGpsPoint = null
this.currentGpsTrack = []
@@ -1386,6 +1442,18 @@ export class MapEngine {
this.heartRateController.disconnect()
}
handleConnectHeartRateDevice(deviceId: string): void {
this.heartRateController.connectToDiscoveredDevice(deviceId)
}
handleClearPreferredHeartRateDevice(): void {
this.heartRateController.clearPreferredDevice()
this.setState({
heartRateDeviceText: this.heartRateController.currentDeviceName || '--',
heartRateScanText: this.getHeartRateScanText(),
}, true)
}
handleDebugHeartRateTone(tone: HeartRateTone): void {
const sampleBpm = getHeartRateToneSampleBpm(tone, this.telemetryRuntime.config)
this.telemetryRuntime.dispatch({
@@ -1407,9 +1475,42 @@ export class MapEngine {
})
this.setState({
heartRateStatusText: this.heartRateController.connected ? '心率带已连接' : '心率带未连接',
heartRateScanText: this.getHeartRateScanText(),
}, true)
this.syncSessionTimerText()
}
formatHeartRateDevices(devices: HeartRateDiscoveredDevice[]): Array<{ deviceId: string; name: string; rssiText: string; preferred: boolean; connected: boolean }> {
return devices.map((device) => ({
deviceId: device.deviceId,
name: device.name,
rssiText: device.rssi === null ? '--' : `${device.rssi} dBm`,
preferred: device.isPreferred,
connected: !!this.heartRateController.currentDeviceId && this.heartRateController.currentDeviceId === device.deviceId && this.heartRateController.connected,
}))
}
getHeartRateScanText(): string {
if (this.heartRateController.connected) {
return '已连接'
}
if (this.heartRateController.connecting) {
return '连接中'
}
if (this.heartRateController.disconnecting) {
return '断开中'
}
if (this.heartRateController.scanning) {
return this.heartRateController.lastDeviceId ? '扫描中(优先首选)' : '扫描中(等待选择)'
}
return this.heartRateController.discoveredDevices.length
? `已发现 ${this.heartRateController.discoveredDevices.length} 个设备`
: '未扫描'
}
setStage(rect: MapEngineStageRect): void {
this.previewScale = 1
this.previewOriginX = rect.width / 2

View File

@@ -3,18 +3,30 @@ export interface HeartRateControllerCallbacks {
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()
@@ -80,9 +92,18 @@ export class HeartRateController {
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
@@ -94,12 +115,22 @@ export class HeartRateController {
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 {
@@ -108,40 +139,55 @@ export class HeartRateController {
return
}
if (this.disconnecting) {
this.callbacks.onStatus('心率带断开中,请稍后再试')
return
}
if (this.scanning || this.connecting) {
this.callbacks.onStatus('心率带连接进行中')
return
}
const wxAny = wx as any
wxAny.openBluetoothAdapter({
success: () => {
this.manualDisconnect = false
this.reconnecting = false
this.clearReconnectTimer()
this.withFreshBluetoothAdapter(() => {
if (this.lastDeviceId) {
this.callbacks.onStatus(`正在扫描并优先连接 ${this.lastDeviceName || '心率带'}`)
this.beginDiscovery()
},
fail: (error: any) => {
const message = error && error.errMsg ? error.errMsg : 'openBluetoothAdapter 失败'
this.callbacks.onError(`蓝牙不可用: ${message}`)
},
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('心率带已断开')
},
@@ -150,6 +196,7 @@ export class HeartRateController {
destroy(): void {
this.clearDiscoveryTimer()
this.clearReconnectTimer()
this.stopDiscovery()
this.detachListeners()
@@ -173,14 +220,20 @@ export class HeartRateController {
}
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.callbacks.onStatus('正在扫描心率带')
this.pruneDiscoveredDevices()
this.callbacks.onStatus(this.autoConnectDeviceId ? '正在扫描心率带并等待自动连接' : '正在扫描心率带,请选择设备')
this.clearDiscoveryTimer()
this.discoveryTimer = setTimeout(() => {
this.discoveryTimer = 0
@@ -189,7 +242,7 @@ export class HeartRateController {
}
this.stopDiscovery()
this.callbacks.onError('未发现可连接的心率带')
this.callbacks.onError(this.discoveredDevices.length ? '已发现心率带,请从列表选择连接' : '未发现可连接的心率带')
}, DISCOVERY_TIMEOUT_MS) as unknown as number
},
fail: (error: any) => {
@@ -207,6 +260,7 @@ export class HeartRateController {
}
this.scanning = false
this.autoConnectDeviceId = null
const wxAny = wx as any
wxAny.stopBluetoothDevicesDiscovery({
complete: () => {},
@@ -224,8 +278,13 @@ export class HeartRateController {
? [result]
: []
const targetDevice = devices.find((device: BluetoothDeviceLike) => isHeartRateDevice(device))
if (!targetDevice || !targetDevice.deviceId || !this.scanning || this.connecting || this.connected) {
this.mergeDiscoveredDevices(devices)
if (!this.scanning || this.connecting || this.connected) {
return
}
const targetDevice = this.selectTargetDevice(devices)
if (!targetDevice || !targetDevice.deviceId) {
return
}
@@ -267,7 +326,7 @@ export class HeartRateController {
if (!this.connectionStateHandler) {
this.connectionStateHandler = (result: any) => {
if (!result || !this.currentDeviceId || result.deviceId !== this.currentDeviceId) {
if (!result || !result.deviceId) {
return
}
@@ -275,8 +334,20 @@ export class HeartRateController {
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') {
@@ -303,8 +374,13 @@ export class HeartRateController {
this.connectionStateHandler = null
}
connectToDevice(deviceId: string, deviceName: string): void {
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}`)
@@ -319,6 +395,11 @@ export class HeartRateController {
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}`)
},
})
@@ -364,6 +445,15 @@ export class HeartRateController {
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}`)
},
@@ -393,6 +483,7 @@ export class HeartRateController {
complete: () => {
this.clearConnectionState()
this.callbacks.onError(message)
this.scheduleAutoReconnect(this.lastDeviceId, this.lastDeviceName)
},
})
}
@@ -402,6 +493,9 @@ export class HeartRateController {
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
@@ -418,4 +512,264 @@ export class HeartRateController {
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 {}
}
}