Pārlūkot izejas kodu

Add mock heart rate simulator flow

zhangyan 2 nedēļas atpakaļ
vecāks
revīzija
3f6563c992

+ 79 - 6
miniprogram/engine/map/mapEngine.ts

@@ -1,6 +1,7 @@
 import { getTileSizePx, screenToWorld, worldToScreen, type CameraState } from '../camera/camera'
 import { CompassHeadingController } from '../sensor/compassHeadingController'
-import { HeartRateController, type HeartRateDiscoveredDevice } from '../sensor/heartRateController'
+import { type HeartRateDiscoveredDevice } from '../sensor/heartRateController'
+import { HeartRateInputController } from '../sensor/heartRateInputController'
 import { LocationController } from '../sensor/locationController'
 import { WebGLMapRenderer } from '../renderer/webglMapRenderer'
 import { type MapRendererStats } from '../renderer/mapRenderer'
@@ -129,6 +130,8 @@ export interface MapEngineViewState {
   mockCoordText: string
   mockSpeedText: string
   gpsCoordText: string
+  heartRateSourceMode: 'real' | 'mock'
+  heartRateSourceText: string
   heartRateConnected: boolean
   heartRateStatusText: string
   heartRateDeviceText: string
@@ -140,6 +143,10 @@ export interface MapEngineViewState {
     preferred: boolean
     connected: boolean
   }>
+  mockHeartRateBridgeConnected: boolean
+  mockHeartRateBridgeStatusText: string
+  mockHeartRateBridgeUrlText: string
+  mockHeartRateText: string
   gameSessionStatus: 'idle' | 'running' | 'finished' | 'failed'
   gameModeText: string
   panelTimerText: string
@@ -232,11 +239,17 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
   'mockCoordText',
   'mockSpeedText',
   'gpsCoordText',
+  'heartRateSourceMode',
+  'heartRateSourceText',
   'heartRateConnected',
   'heartRateStatusText',
   'heartRateDeviceText',
   'heartRateScanText',
   'heartRateDiscoveredDevices',
+  'mockHeartRateBridgeConnected',
+  'mockHeartRateBridgeStatusText',
+  'mockHeartRateBridgeUrlText',
+  'mockHeartRateText',
   'gameSessionStatus',
   'gameModeText',
   'panelTimerText',
@@ -514,7 +527,7 @@ export class MapEngine {
   renderer: WebGLMapRenderer
   compassController: CompassHeadingController
   locationController: LocationController
-  heartRateController: HeartRateController
+  heartRateController: HeartRateInputController
   feedbackDirector: FeedbackDirector
   onData: (patch: Partial<MapEngineViewState>) => void
   state: MapEngineViewState
@@ -622,7 +635,7 @@ export class MapEngine {
         this.setState(this.getLocationControllerViewPatch(), true)
       },
     })
-      this.heartRateController = new HeartRateController({
+      this.heartRateController = new HeartRateInputController({
         onHeartRate: (bpm) => {
           this.telemetryRuntime.dispatch({
             type: 'heart_rate_updated',
@@ -639,6 +652,7 @@ export class MapEngine {
             heartRateStatusText: message,
             heartRateDeviceText: deviceName,
             heartRateScanText: this.getHeartRateScanText(),
+            ...this.getHeartRateControllerViewPatch(),
           }, true)
         },
         onError: (message) => {
@@ -651,6 +665,7 @@ export class MapEngine {
             heartRateStatusText: message,
             heartRateDeviceText: deviceName,
             heartRateScanText: this.getHeartRateScanText(),
+            ...this.getHeartRateControllerViewPatch(),
             statusText: `${message} (${this.buildVersion})`,
           }, true)
         },
@@ -667,18 +682,23 @@ export class MapEngine {
             heartRateConnected: connected,
             heartRateDeviceText: resolvedDeviceName,
             heartRateStatusText: connected
-              ? '心率带已连接'
-              : (this.heartRateController.reconnecting ? '心率带自动重连中' : '心率带未连接'),
+              ? (this.heartRateController.sourceMode === 'mock' ? '模拟心率源已连接' : '心率带已连接')
+              : (this.heartRateController.reconnecting ? '心率带自动重连中' : (this.heartRateController.sourceMode === 'mock' ? '模拟心率源未连接' : '心率带未连接')),
             heartRateScanText: this.getHeartRateScanText(),
             heartRateDiscoveredDevices: this.formatHeartRateDevices(this.heartRateController.discoveredDevices),
+            ...this.getHeartRateControllerViewPatch(),
           }, true)
         },
         onDeviceListChange: (devices) => {
           this.setState({
             heartRateDiscoveredDevices: this.formatHeartRateDevices(devices),
             heartRateScanText: this.getHeartRateScanText(),
+            ...this.getHeartRateControllerViewPatch(),
           }, true)
         },
+        onDebugStateChange: () => {
+          this.setState(this.getHeartRateControllerViewPatch(), true)
+        },
       })
     this.feedbackDirector = new FeedbackDirector({
       showPunchFeedback: (text, tone, motionClass) => {
@@ -782,11 +802,17 @@ export class MapEngine {
       mockCoordText: '--',
       mockSpeedText: '--',
       gpsCoordText: '--',
+      heartRateSourceMode: 'real',
+      heartRateSourceText: '真实心率',
         heartRateConnected: false,
         heartRateStatusText: '心率带未连接',
         heartRateDeviceText: '--',
         heartRateScanText: '未扫描',
         heartRateDiscoveredDevices: [],
+        mockHeartRateBridgeConnected: false,
+        mockHeartRateBridgeStatusText: '未连接',
+        mockHeartRateBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps',
+        mockHeartRateText: '--',
       panelTimerText: '00:00:00',
       panelMileageText: '0m',
       panelActionTagText: '目标',
@@ -951,6 +977,18 @@ export class MapEngine {
     }
   }
 
+  getHeartRateControllerViewPatch(): Partial<MapEngineViewState> {
+    const debugState = this.heartRateController.getDebugState()
+    return {
+      heartRateSourceMode: debugState.sourceMode,
+      heartRateSourceText: debugState.sourceModeText,
+      mockHeartRateBridgeConnected: debugState.mockBridgeConnected,
+      mockHeartRateBridgeStatusText: debugState.mockBridgeStatusText,
+      mockHeartRateBridgeUrlText: debugState.mockBridgeUrlText,
+      mockHeartRateText: debugState.mockHeartRateText,
+    }
+  }
+
   getGameModeText(): string {
     return this.gameMode === 'score-o' ? '积分赛' : '顺序赛'
   }
@@ -1442,6 +1480,26 @@ export class MapEngine {
     this.heartRateController.disconnect()
   }
 
+  handleSetRealHeartRateMode(): void {
+    this.heartRateController.setSourceMode('real')
+  }
+
+  handleSetMockHeartRateMode(): void {
+    this.heartRateController.setSourceMode('mock')
+  }
+
+  handleConnectMockHeartRateBridge(): void {
+    this.heartRateController.connectMockBridge()
+  }
+
+  handleDisconnectMockHeartRateBridge(): void {
+    this.heartRateController.disconnectMockBridge()
+  }
+
+  handleSetMockHeartRateBridgeUrl(url: string): void {
+    this.heartRateController.setMockBridgeUrl(url)
+  }
+
   handleConnectHeartRateDevice(deviceId: string): void {
     this.heartRateController.connectToDiscoveredDevice(deviceId)
   }
@@ -1474,8 +1532,11 @@ export class MapEngine {
       bpm: null,
     })
     this.setState({
-      heartRateStatusText: this.heartRateController.connected ? '心率带已连接' : '心率带未连接',
+      heartRateStatusText: this.heartRateController.connected
+        ? (this.heartRateController.sourceMode === 'mock' ? '模拟心率源已连接' : '心率带已连接')
+        : (this.heartRateController.sourceMode === 'mock' ? '模拟心率源未连接' : '心率带未连接'),
       heartRateScanText: this.getHeartRateScanText(),
+      ...this.getHeartRateControllerViewPatch(),
     }, true)
     this.syncSessionTimerText()
   }
@@ -1491,6 +1552,18 @@ export class MapEngine {
   }
 
   getHeartRateScanText(): string {
+    if (this.heartRateController.sourceMode === 'mock') {
+      if (this.heartRateController.connected) {
+        return '模拟源已连接'
+      }
+
+      if (this.heartRateController.connecting) {
+        return '模拟源连接中'
+      }
+
+      return '模拟模式'
+    }
+
     if (this.heartRateController.connected) {
       return '已连接'
     }

+ 325 - 0
miniprogram/engine/sensor/heartRateInputController.ts

@@ -0,0 +1,325 @@
+import { HeartRateController, type HeartRateControllerCallbacks, type HeartRateDiscoveredDevice } from './heartRateController'
+import { DEFAULT_MOCK_HEART_RATE_BRIDGE_URL, MockHeartRateBridge } from './mockHeartRateBridge'
+
+export type HeartRateSourceMode = 'real' | 'mock'
+
+export interface HeartRateInputControllerCallbacks {
+  onHeartRate: (bpm: number) => void
+  onStatus: (message: string) => void
+  onError: (message: string) => void
+  onConnectionChange: (connected: boolean, deviceName: string | null) => void
+  onDeviceListChange: (devices: HeartRateDiscoveredDevice[]) => void
+  onDebugStateChange?: () => void
+}
+
+export interface HeartRateInputControllerDebugState {
+  sourceMode: HeartRateSourceMode
+  sourceModeText: string
+  mockBridgeConnected: boolean
+  mockBridgeStatusText: string
+  mockBridgeUrlText: string
+  mockHeartRateText: string
+}
+
+function formatSourceModeText(mode: HeartRateSourceMode): string {
+  return mode === 'mock' ? '模拟心率' : '真实心率'
+}
+
+function formatMockHeartRateText(bpm: number | null): string {
+  return bpm === null ? '--' : `${bpm} bpm`
+}
+
+function normalizeMockBridgeUrl(rawUrl: string): string {
+  const trimmed = rawUrl.trim()
+  if (!trimmed) {
+    return DEFAULT_MOCK_HEART_RATE_BRIDGE_URL
+  }
+
+  let normalized = trimmed
+  if (!/^wss?:\/\//i.test(normalized)) {
+    normalized = `ws://${normalized.replace(/^\/+/, '')}`
+  }
+
+  if (!/\/mock-gps(?:\?.*)?$/i.test(normalized)) {
+    normalized = normalized.replace(/\/+$/, '')
+    normalized = `${normalized}/mock-gps`
+  }
+
+  return normalized
+}
+
+export class HeartRateInputController {
+  callbacks: HeartRateInputControllerCallbacks
+  realController: HeartRateController
+  mockBridge: MockHeartRateBridge
+  sourceMode: HeartRateSourceMode
+  mockBridgeStatusText: string
+  mockBridgeUrl: string
+  mockBpm: number | null
+
+  constructor(callbacks: HeartRateInputControllerCallbacks) {
+    this.callbacks = callbacks
+    this.sourceMode = 'real'
+    this.mockBridgeUrl = DEFAULT_MOCK_HEART_RATE_BRIDGE_URL
+    this.mockBridgeStatusText = `未连接 (${this.mockBridgeUrl})`
+    this.mockBpm = null
+
+    const realCallbacks: HeartRateControllerCallbacks = {
+      onHeartRate: (bpm) => {
+        if (this.sourceMode !== 'real') {
+          return
+        }
+
+        this.callbacks.onHeartRate(bpm)
+        this.emitDebugState()
+      },
+      onStatus: (message) => {
+        if (this.sourceMode !== 'real') {
+          return
+        }
+
+        this.callbacks.onStatus(message)
+        this.emitDebugState()
+      },
+      onError: (message) => {
+        if (this.sourceMode !== 'real') {
+          return
+        }
+
+        this.callbacks.onError(message)
+        this.emitDebugState()
+      },
+      onConnectionChange: (connected, deviceName) => {
+        if (this.sourceMode !== 'real') {
+          return
+        }
+
+        this.callbacks.onConnectionChange(connected, deviceName)
+        this.emitDebugState()
+      },
+      onDeviceListChange: (devices) => {
+        this.callbacks.onDeviceListChange(devices)
+        this.emitDebugState()
+      },
+    }
+
+    this.realController = new HeartRateController(realCallbacks)
+    this.mockBridge = new MockHeartRateBridge({
+      onOpen: () => {
+        this.mockBridgeStatusText = `已连接 (${this.mockBridge.url})`
+        if (this.sourceMode === 'mock') {
+          this.callbacks.onConnectionChange(true, '模拟心率源')
+          this.callbacks.onStatus('模拟心率源已连接,等待外部输入')
+        }
+        this.emitDebugState()
+      },
+      onClose: () => {
+        this.mockBridgeStatusText = `未连接 (${this.mockBridge.url})`
+        if (this.sourceMode === 'mock') {
+          this.callbacks.onConnectionChange(false, null)
+          this.callbacks.onStatus('模拟心率源已断开')
+        }
+        this.emitDebugState()
+      },
+      onError: (message) => {
+        this.mockBridgeStatusText = `连接失败 (${this.mockBridge.url})`
+        if (this.sourceMode === 'mock') {
+          this.callbacks.onConnectionChange(false, null)
+          this.callbacks.onError(`模拟心率源错误: ${message}`)
+        }
+        this.emitDebugState()
+      },
+      onBpm: (bpm) => {
+        this.mockBpm = bpm
+        if (this.sourceMode === 'mock') {
+          this.callbacks.onHeartRate(bpm)
+        }
+        this.emitDebugState()
+      },
+    })
+  }
+
+  get currentDeviceId(): string | null {
+    if (this.sourceMode === 'mock') {
+      return this.mockBridge.connected ? 'mock-heart-rate' : null
+    }
+
+    return this.realController.currentDeviceId
+  }
+
+  get currentDeviceName(): string | null {
+    if (this.sourceMode === 'mock') {
+      return this.mockBridge.connected ? '模拟心率源' : null
+    }
+
+    return this.realController.currentDeviceName
+  }
+
+  get connected(): boolean {
+    return this.sourceMode === 'mock' ? this.mockBridge.connected : this.realController.connected
+  }
+
+  get connecting(): boolean {
+    return this.sourceMode === 'mock' ? this.mockBridge.connecting : this.realController.connecting
+  }
+
+  get scanning(): boolean {
+    return this.sourceMode === 'mock' ? false : this.realController.scanning
+  }
+
+  get reconnecting(): boolean {
+    return this.sourceMode === 'mock' ? false : this.realController.reconnecting
+  }
+
+  get disconnecting(): boolean {
+    return this.sourceMode === 'mock' ? false : this.realController.disconnecting
+  }
+
+  get discoveredDevices(): HeartRateDiscoveredDevice[] {
+    return this.realController.discoveredDevices
+  }
+
+  get lastDeviceId(): string | null {
+    return this.realController.lastDeviceId
+  }
+
+  get lastDeviceName(): string | null {
+    return this.realController.lastDeviceName
+  }
+
+  getDebugState(): HeartRateInputControllerDebugState {
+    return {
+      sourceMode: this.sourceMode,
+      sourceModeText: formatSourceModeText(this.sourceMode),
+      mockBridgeConnected: this.mockBridge.connected,
+      mockBridgeStatusText: this.mockBridgeStatusText,
+      mockBridgeUrlText: this.mockBridgeUrl,
+      mockHeartRateText: formatMockHeartRateText(this.mockBpm),
+    }
+  }
+
+  startScanAndConnect(): void {
+    if (this.sourceMode === 'mock') {
+      this.callbacks.onStatus(this.mockBridge.connected ? '模拟心率源已连接' : '当前为模拟心率模式,请连接模拟源')
+      this.emitDebugState()
+      return
+    }
+
+    this.realController.startScanAndConnect()
+  }
+
+  disconnect(): void {
+    if (this.sourceMode === 'mock') {
+      if (!this.mockBridge.connected && !this.mockBridge.connecting) {
+        this.callbacks.onStatus('模拟心率源未连接')
+        this.emitDebugState()
+        return
+      }
+
+      this.mockBridge.disconnect()
+      this.emitDebugState()
+      return
+    }
+
+    this.realController.disconnect()
+  }
+
+  destroy(): void {
+    this.realController.destroy()
+    this.mockBridge.destroy()
+  }
+
+  setSourceMode(mode: HeartRateSourceMode): void {
+    if (this.sourceMode === mode) {
+      this.callbacks.onStatus(`${formatSourceModeText(mode)}已启用`)
+      this.emitDebugState()
+      return
+    }
+
+    const previousMode = this.sourceMode
+    this.sourceMode = mode
+
+    if (previousMode === 'real') {
+      this.realController.disconnect()
+    } else {
+      this.callbacks.onConnectionChange(false, null)
+    }
+
+    const activeDeviceName = this.currentDeviceName
+    this.callbacks.onConnectionChange(this.connected, activeDeviceName)
+    this.callbacks.onStatus(mode === 'mock' ? '已切换到模拟心率模式' : '已切换到真实心率模式')
+    this.emitDebugState()
+  }
+
+  setMockBridgeUrl(url: string): void {
+    this.mockBridgeUrl = normalizeMockBridgeUrl(url)
+
+    if (this.mockBridge.connected || this.mockBridge.connecting) {
+      this.mockBridgeStatusText = `已设置新地址,重连生效 (${this.mockBridgeUrl})`
+      if (this.sourceMode === 'mock') {
+        this.callbacks.onStatus('模拟心率源地址已更新,重连后生效')
+      }
+    } else {
+      this.mockBridgeStatusText = `未连接 (${this.mockBridgeUrl})`
+      if (this.sourceMode === 'mock') {
+        this.callbacks.onStatus('模拟心率源地址已更新')
+      }
+    }
+
+    this.emitDebugState()
+  }
+
+  connectMockBridge(url = DEFAULT_MOCK_HEART_RATE_BRIDGE_URL): void {
+    if (this.mockBridge.connected || this.mockBridge.connecting) {
+      if (this.sourceMode === 'mock') {
+        this.callbacks.onStatus('模拟心率源已连接')
+      }
+      this.emitDebugState()
+      return
+    }
+
+    const targetUrl = normalizeMockBridgeUrl(url === DEFAULT_MOCK_HEART_RATE_BRIDGE_URL ? this.mockBridgeUrl : url)
+    this.mockBridgeUrl = targetUrl
+    this.mockBridgeStatusText = `连接中 (${targetUrl})`
+    if (this.sourceMode === 'mock') {
+      this.callbacks.onStatus('模拟心率源连接中')
+    }
+    this.emitDebugState()
+    this.mockBridge.connect(targetUrl)
+  }
+
+  disconnectMockBridge(): void {
+    if (!this.mockBridge.connected && !this.mockBridge.connecting) {
+      if (this.sourceMode === 'mock') {
+        this.callbacks.onStatus('模拟心率源未连接')
+      }
+      this.emitDebugState()
+      return
+    }
+
+    this.mockBridge.disconnect()
+    this.mockBridgeStatusText = `未连接 (${this.mockBridge.url})`
+    this.emitDebugState()
+  }
+
+  connectToDiscoveredDevice(deviceId: string): void {
+    if (this.sourceMode !== 'real') {
+      this.callbacks.onStatus('当前为模拟心率模式,无法连接真实心率带')
+      this.emitDebugState()
+      return
+    }
+
+    this.realController.connectToDiscoveredDevice(deviceId)
+  }
+
+  clearPreferredDevice(): void {
+    this.realController.clearPreferredDevice()
+    this.emitDebugState()
+  }
+
+  emitDebugState(): void {
+    if (this.callbacks.onDebugStateChange) {
+      this.callbacks.onDebugStateChange()
+    }
+  }
+}

+ 134 - 0
miniprogram/engine/sensor/mockHeartRateBridge.ts

@@ -0,0 +1,134 @@
+export const DEFAULT_MOCK_HEART_RATE_BRIDGE_URL = 'wss://gs.gotomars.xyz/mock-gps'
+
+export interface MockHeartRateBridgeCallbacks {
+  onOpen: () => void
+  onClose: (message: string) => void
+  onError: (message: string) => void
+  onBpm: (bpm: number) => void
+}
+
+type RawMockHeartRateMessage = {
+  type?: string
+  timestamp?: number
+  bpm?: number
+}
+
+function safeParseMessage(data: string): RawMockHeartRateMessage | null {
+  try {
+    return JSON.parse(data) as RawMockHeartRateMessage
+  } catch (_error) {
+    return null
+  }
+}
+
+function toHeartRateValue(message: RawMockHeartRateMessage): number | null {
+  if (message.type !== 'mock_heart_rate' || !Number.isFinite(message.bpm)) {
+    return null
+  }
+
+  const bpm = Math.round(Number(message.bpm))
+  if (bpm <= 0) {
+    return null
+  }
+
+  return bpm
+}
+
+export class MockHeartRateBridge {
+  callbacks: MockHeartRateBridgeCallbacks
+  socketTask: WechatMiniprogram.SocketTask | null
+  connected: boolean
+  connecting: boolean
+  url: string
+
+  constructor(callbacks: MockHeartRateBridgeCallbacks) {
+    this.callbacks = callbacks
+    this.socketTask = null
+    this.connected = false
+    this.connecting = false
+    this.url = DEFAULT_MOCK_HEART_RATE_BRIDGE_URL
+  }
+
+  connect(url = DEFAULT_MOCK_HEART_RATE_BRIDGE_URL): void {
+    if (this.connected || this.connecting) {
+      return
+    }
+
+    this.url = url
+    this.connecting = true
+
+    try {
+      const socketTask = wx.connectSocket({
+        url,
+      })
+      this.socketTask = socketTask
+
+      socketTask.onOpen(() => {
+        this.connected = true
+        this.connecting = false
+        this.callbacks.onOpen()
+      })
+
+      socketTask.onClose((result) => {
+        const reason = result && result.reason ? result.reason : '模拟心率源连接已关闭'
+        this.connected = false
+        this.connecting = false
+        this.socketTask = null
+        this.callbacks.onClose(reason)
+      })
+
+      socketTask.onError((result) => {
+        const message = result && result.errMsg ? result.errMsg : '模拟心率源连接失败'
+        this.connected = false
+        this.connecting = false
+        this.socketTask = null
+        this.callbacks.onError(message)
+      })
+
+      socketTask.onMessage((result) => {
+        if (!result || typeof result.data !== 'string') {
+          return
+        }
+
+        const parsed = safeParseMessage(result.data)
+        if (!parsed) {
+          this.callbacks.onError('模拟心率消息不是合法 JSON')
+          return
+        }
+
+        const bpm = toHeartRateValue(parsed)
+        if (bpm === null) {
+          return
+        }
+
+        this.callbacks.onBpm(bpm)
+      })
+    } catch (error) {
+      this.connected = false
+      this.connecting = false
+      this.socketTask = null
+      const message = error && (error as Error).message ? (error as Error).message : '模拟心率源连接创建失败'
+      this.callbacks.onError(message)
+    }
+  }
+
+  disconnect(): void {
+    if (!this.socketTask) {
+      if (this.connected || this.connecting) {
+        this.connected = false
+        this.connecting = false
+      }
+      return
+    }
+
+    const socketTask = this.socketTask
+    this.socketTask = null
+    this.connected = false
+    this.connecting = false
+    socketTask.close({})
+  }
+
+  destroy(): void {
+    this.disconnect()
+  }
+}

+ 65 - 7
miniprogram/pages/map/map.ts

@@ -19,6 +19,7 @@ type MapPageData = MapEngineViewState & {
   topInsetHeight: number
   hudPanelIndex: number
   mockBridgeUrlDraft: string
+  mockHeartRateBridgeUrlDraft: string
   panelTimerText: string
   panelMileageText: string
   panelDistanceValueText: string
@@ -31,7 +32,7 @@ type MapPageData = MapEngineViewState & {
   showRightButtonGroups: boolean
   showBottomDebugButton: boolean
 }
-const INTERNAL_BUILD_VERSION = 'map-build-195'
+const INTERNAL_BUILD_VERSION = 'map-build-196'
 const REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/wxmini/test/game.json'
 let mapEngine: MapEngine | null = null
 let stageCanvasAttached = false
@@ -115,6 +116,13 @@ Page({
     mockBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps',
     mockCoordText: '--',
     mockSpeedText: '--',
+    heartRateSourceMode: 'real',
+    heartRateSourceText: '真实心率',
+    mockHeartRateBridgeConnected: false,
+    mockHeartRateBridgeStatusText: '未连接',
+    mockHeartRateBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps',
+    mockHeartRateBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps',
+    mockHeartRateText: '--',
     heartRateScanText: '未扫描',
     heartRateDiscoveredDevices: [],
     panelSpeedValueText: '0',
@@ -164,18 +172,25 @@ Page({
     mapEngine = new MapEngine(INTERNAL_BUILD_VERSION, {
       onData: (patch) => {
         const nextPatch = patch as Partial<MapPageData>
+        const nextData: Partial<MapPageData> = {
+          ...nextPatch,
+        }
+
         if (
           typeof nextPatch.mockBridgeUrlText === 'string'
           && this.data.mockBridgeUrlDraft === this.data.mockBridgeUrlText
         ) {
-          this.setData({
-            ...nextPatch,
-            mockBridgeUrlDraft: nextPatch.mockBridgeUrlText,
-          })
-          return
+          nextData.mockBridgeUrlDraft = nextPatch.mockBridgeUrlText
+        }
+
+        if (
+          typeof nextPatch.mockHeartRateBridgeUrlText === 'string'
+          && this.data.mockHeartRateBridgeUrlDraft === this.data.mockHeartRateBridgeUrlText
+        ) {
+          nextData.mockHeartRateBridgeUrlDraft = nextPatch.mockHeartRateBridgeUrlText
         }
 
-        this.setData(nextPatch)
+        this.setData(nextData)
       },
     })
 
@@ -202,6 +217,13 @@ Page({
       mockBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps',
       mockCoordText: '--',
       mockSpeedText: '--',
+      heartRateSourceMode: 'real',
+      heartRateSourceText: '真实心率',
+      mockHeartRateBridgeConnected: false,
+      mockHeartRateBridgeStatusText: '未连接',
+      mockHeartRateBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps',
+      mockHeartRateBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps',
+      mockHeartRateText: '--',
       panelSpeedValueText: '0',
       panelTelemetryTone: 'blue',
       panelHeartRateZoneNameText: '--',
@@ -469,6 +491,42 @@ Page({
     }
   },
 
+  handleSetRealHeartRateMode() {
+    if (mapEngine) {
+      mapEngine.handleSetRealHeartRateMode()
+    }
+  },
+
+  handleSetMockHeartRateMode() {
+    if (mapEngine) {
+      mapEngine.handleSetMockHeartRateMode()
+    }
+  },
+
+  handleMockHeartRateBridgeUrlInput(event: WechatMiniprogram.Input) {
+    this.setData({
+      mockHeartRateBridgeUrlDraft: event.detail.value,
+    })
+  },
+
+  handleSaveMockHeartRateBridgeUrl() {
+    if (mapEngine) {
+      mapEngine.handleSetMockHeartRateBridgeUrl(this.data.mockHeartRateBridgeUrlDraft)
+    }
+  },
+
+  handleConnectMockHeartRateBridge() {
+    if (mapEngine) {
+      mapEngine.handleConnectMockHeartRateBridge()
+    }
+  },
+
+  handleDisconnectMockHeartRateBridge() {
+    if (mapEngine) {
+      mapEngine.handleDisconnectMockHeartRateBridge()
+    }
+  },
+
   handleConnectHeartRate() {
     if (mapEngine) {
       mapEngine.handleConnectHeartRate()

+ 36 - 4
miniprogram/pages/map/map.wxml

@@ -328,15 +328,23 @@
             <text class="info-panel__label">Heart Rate</text>
             <text class="info-panel__value">{{heartRateStatusText}}</text>
           </view>
+          <view class="info-panel__row">
+            <text class="info-panel__label">Heart Source</text>
+            <text class="info-panel__value">{{heartRateSourceText}}</text>
+          </view>
           <view class="info-panel__row info-panel__row--stack">
             <text class="info-panel__label">HR Device</text>
             <text class="info-panel__value">{{heartRateDeviceText}}</text>
           </view>
-          <view class="info-panel__row">
+          <view class="control-row">
+            <view class="control-chip {{heartRateSourceMode === 'real' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetRealHeartRateMode">真实心率</view>
+            <view class="control-chip {{heartRateSourceMode === 'mock' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetMockHeartRateMode">模拟心率</view>
+          </view>
+          <view class="info-panel__row" wx:if="{{heartRateSourceMode === 'real'}}">
             <text class="info-panel__label">HR Scan</text>
             <text class="info-panel__value">{{heartRateScanText}}</text>
           </view>
-          <view class="debug-device-list" wx:if="{{heartRateDiscoveredDevices.length}}">
+          <view class="debug-device-list" wx:if="{{heartRateSourceMode === 'real' && heartRateDiscoveredDevices.length}}">
             <view class="debug-device-card" wx:for="{{heartRateDiscoveredDevices}}" wx:key="deviceId">
               <view class="debug-device-card__main">
                 <view class="debug-device-card__title-row">
@@ -348,13 +356,37 @@
               <view class="debug-device-card__action {{item.connected ? 'debug-device-card__action--active' : ''}}" data-device-id="{{item.deviceId}}" bindtap="handleConnectHeartRateDevice">{{item.connected ? '已连接' : '连接'}}</view>
             </view>
           </view>
-          <view class="control-row">
+          <view class="control-row" wx:if="{{heartRateSourceMode === 'real'}}">
             <view class="control-chip {{heartRateConnected ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleConnectHeartRate">{{heartRateConnected ? '心率带已连接' : '连接心率带'}}</view>
             <view class="control-chip control-chip--secondary" bindtap="handleDisconnectHeartRate">断开心率带</view>
           </view>
-          <view class="control-row">
+          <view class="control-row" wx:if="{{heartRateSourceMode === 'real'}}">
             <view class="control-chip control-chip--secondary" bindtap="handleClearPreferredHeartRateDevice">清除首选</view>
           </view>
+          <view class="info-panel__row info-panel__row--stack" wx:if="{{heartRateSourceMode === 'mock'}}">
+            <text class="info-panel__label">Mock HR Bridge</text>
+            <text class="info-panel__value">{{mockHeartRateBridgeStatusText}}</text>
+          </view>
+          <view class="info-panel__row info-panel__row--stack" wx:if="{{heartRateSourceMode === 'mock'}}">
+            <text class="info-panel__label">Mock HR URL</text>
+            <view class="debug-inline-stack">
+              <input
+                class="debug-input"
+                value="{{mockHeartRateBridgeUrlDraft}}"
+                placeholder="ws://192.168.x.x:17865/mock-gps"
+                bindinput="handleMockHeartRateBridgeUrlInput"
+              />
+              <view class="control-row control-row--compact">
+                <view class="control-chip control-chip--secondary" bindtap="handleSaveMockHeartRateBridgeUrl">保存地址</view>
+                <view class="control-chip {{mockHeartRateBridgeConnected ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleConnectMockHeartRateBridge">连接模拟心率源</view>
+                <view class="control-chip control-chip--secondary" bindtap="handleDisconnectMockHeartRateBridge">断开模拟心率源</view>
+              </view>
+            </view>
+          </view>
+          <view class="info-panel__row" wx:if="{{heartRateSourceMode === 'mock'}}">
+            <text class="info-panel__label">Mock BPM</text>
+            <text class="info-panel__value">{{mockHeartRateText}}</text>
+          </view>
           <view class="info-panel__row">
             <text class="info-panel__label">Heading Mode</text>
             <text class="info-panel__value">{{orientationModeText}}</text>

+ 38 - 0
tools/mock-gps-sim/public/index.html

@@ -73,6 +73,44 @@
           </label>
         </section>
 
+        <section class="group">
+          <div class="group__title">心率模拟</div>
+          <div id="heartRateStatus" class="group__status">心率模拟待命</div>
+          <div id="lastHeartRateStatus" class="group__status">最近发送: --</div>
+          <div class="row">
+            <button id="sendHeartRateOnceBtn" class="btn">发送一次</button>
+            <button id="startHeartRateStreamBtn" class="btn btn--accent">开始连续发送</button>
+          </div>
+          <div class="row">
+            <button id="stopHeartRateStreamBtn" class="btn">停止发送</button>
+            <button id="applyHeartRatePresetBtn" class="btn">应用分区样本</button>
+          </div>
+          <div class="row">
+            <button id="toggleHeartRateSampleBtn" class="btn">模拟真实样本</button>
+          </div>
+          <label class="field">
+            <span>心率值 (bpm)</span>
+            <input id="heartRateInput" type="number" min="40" max="220" value="120">
+          </label>
+          <label class="field">
+            <span>发送频率</span>
+            <select id="heartRateHzSelect">
+              <option value="1" selected>1 Hz</option>
+              <option value="2">2 Hz</option>
+              <option value="4">4 Hz</option>
+            </select>
+          </label>
+          <label class="field">
+            <span>样本模板</span>
+            <select id="heartRateSampleTemplateSelect">
+              <option value="jog" selected>慢跑样本</option>
+              <option value="tempo">节奏跑样本</option>
+              <option value="interval">间歇跑样本</option>
+              <option value="recovery">恢复走样本</option>
+            </select>
+          </label>
+        </section>
+
         <section class="group">
           <div class="group__title">路径回放</div>
           <div id="playbackStatus" class="group__status">路径待命</div>

+ 183 - 0
tools/mock-gps-sim/public/simulator.js

@@ -33,11 +33,15 @@
     connected: false,
     socketConnecting: false,
     streaming: false,
+    heartRateStreaming: false,
+    heartRateSampleMode: false,
     pathEditMode: false,
     playbackRunning: false,
     playbackTimer: 0,
     streamTimer: 0,
+    heartRateStreamTimer: 0,
     lastSentText: '--',
+    lastHeartRateSentText: '--',
     lastResourceDetailText: '尚未载入资源',
     lastTrackSourceText: '路径待命',
     currentLatLng: L.latLng(DEFAULT_CENTER[0], DEFAULT_CENTER[1]),
@@ -45,6 +49,7 @@
     currentSegmentIndex: 0,
     currentSegmentProgress: 0,
     lastPlaybackAt: 0,
+    heartRateSampleStartedAt: 0,
     loadedCourse: null,
     resourceLoading: false,
   }
@@ -66,6 +71,16 @@
     realtimeStatus: document.getElementById('realtimeStatus'),
     lastSendStatus: document.getElementById('lastSendStatus'),
     playbackStatus: document.getElementById('playbackStatus'),
+    heartRateStatus: document.getElementById('heartRateStatus'),
+    lastHeartRateStatus: document.getElementById('lastHeartRateStatus'),
+    sendHeartRateOnceBtn: document.getElementById('sendHeartRateOnceBtn'),
+    startHeartRateStreamBtn: document.getElementById('startHeartRateStreamBtn'),
+    stopHeartRateStreamBtn: document.getElementById('stopHeartRateStreamBtn'),
+    applyHeartRatePresetBtn: document.getElementById('applyHeartRatePresetBtn'),
+    toggleHeartRateSampleBtn: document.getElementById('toggleHeartRateSampleBtn'),
+    heartRateInput: document.getElementById('heartRateInput'),
+    heartRateHzSelect: document.getElementById('heartRateHzSelect'),
+    heartRateSampleTemplateSelect: document.getElementById('heartRateSampleTemplateSelect'),
     trackFileInput: document.getElementById('trackFileInput'),
     importTrackBtn: document.getElementById('importTrackBtn'),
     connectBtn: document.getElementById('connectBtn'),
@@ -144,6 +159,13 @@
     elements.streamBtn.classList.toggle('is-active', state.streaming)
     elements.streamBtn.disabled = !state.connected || state.streaming
     elements.stopStreamBtn.disabled = !state.streaming
+    elements.sendHeartRateOnceBtn.disabled = !state.connected
+    elements.startHeartRateStreamBtn.textContent = state.heartRateStreaming ? '发送中' : '开始连续发送'
+    elements.startHeartRateStreamBtn.classList.toggle('is-active', state.heartRateStreaming)
+    elements.startHeartRateStreamBtn.disabled = !state.connected || state.heartRateStreaming
+    elements.stopHeartRateStreamBtn.disabled = !state.heartRateStreaming
+    elements.toggleHeartRateSampleBtn.textContent = state.heartRateSampleMode ? '关闭真实样本' : '模拟真实样本'
+    elements.toggleHeartRateSampleBtn.classList.toggle('is-active', state.heartRateSampleMode)
 
     elements.togglePathModeBtn.textContent = state.pathEditMode ? '关闭路径编辑' : '开启路径编辑'
     elements.togglePathModeBtn.classList.toggle('is-active', state.pathEditMode)
@@ -166,6 +188,7 @@
     elements.applyTilesBtn.disabled = state.resourceLoading
     elements.resetTilesBtn.disabled = state.resourceLoading
     elements.lastSendStatus.textContent = `最近发送: ${state.lastSentText}`
+    elements.lastHeartRateStatus.textContent = `最近发送: ${state.lastHeartRateSentText}`
     elements.resourceDetail.textContent = state.lastResourceDetailText
 
     if (state.connected && state.streaming) {
@@ -178,6 +201,18 @@
       elements.realtimeStatus.textContent = '桥接未连接'
     }
 
+    if (state.connected && state.heartRateStreaming) {
+      elements.heartRateStatus.textContent = state.heartRateSampleMode
+        ? `桥接已连接,正在以 ${elements.heartRateHzSelect.value} Hz 发送真实心率样本`
+        : `桥接已连接,正在以 ${elements.heartRateHzSelect.value} Hz 连续发送心率`
+    } else if (state.connected) {
+      elements.heartRateStatus.textContent = state.heartRateSampleMode ? '真实心率样本待命' : '心率模拟待命'
+    } else if (state.socketConnecting) {
+      elements.heartRateStatus.textContent = '桥接连接中'
+    } else {
+      elements.heartRateStatus.textContent = '桥接未连接'
+    }
+
     if (state.playbackRunning) {
       elements.playbackStatus.textContent = `路径回放中,速度 ${elements.speedInput.value} km/h`
     } else if (state.pathEditMode) {
@@ -212,6 +247,8 @@
     socket.addEventListener('close', () => {
       state.connected = false
       state.socketConnecting = false
+      stopStream()
+      stopHeartRateStream()
       setSocketBadge(false)
       updateUiState()
       log('桥接已断开')
@@ -220,6 +257,8 @@
     socket.addEventListener('error', () => {
       state.connected = false
       state.socketConnecting = false
+      stopStream()
+      stopHeartRateStream()
       setSocketBadge(false)
       updateUiState()
       log('桥接连接失败')
@@ -685,6 +724,79 @@
     return Math.max(0.2, (Number(elements.speedInput.value) || 6) / 3.6)
   }
 
+  function getHeartRateBpm() {
+    return Math.max(40, Math.min(220, Math.round(Number(elements.heartRateInput.value) || 120)))
+  }
+
+  function getSampleHeartRateBpm() {
+    const now = Date.now()
+    if (!state.heartRateSampleStartedAt) {
+      state.heartRateSampleStartedAt = now
+    }
+
+    const elapsedSeconds = (now - state.heartRateSampleStartedAt) / 1000
+    const template = elements.heartRateSampleTemplateSelect.value || 'jog'
+
+    let cycleSeconds = 360
+    let bpm = 120
+    const jitter = Math.sin(elapsedSeconds * 1.7) * 1.8 + Math.sin(elapsedSeconds * 0.47) * 1.2
+
+    if (template === 'recovery') {
+      cycleSeconds = 300
+      const phase = elapsedSeconds % cycleSeconds
+      if (phase < 80) {
+        bpm = 82 + phase * 0.08
+      } else if (phase < 190) {
+        bpm = 89 + Math.sin((phase - 80) / 20) * 3
+      } else {
+        bpm = 90 - (phase - 190) * 0.06 + Math.sin((phase - 190) / 18) * 2
+      }
+    } else if (template === 'tempo') {
+      cycleSeconds = 320
+      const phase = elapsedSeconds % cycleSeconds
+      if (phase < 50) {
+        bpm = 102 + phase * 0.42
+      } else if (phase < 230) {
+        bpm = 124 + Math.sin((phase - 50) / 14) * 5 + Math.sin((phase - 50) / 36) * 3
+      } else {
+        bpm = 126 - (phase - 230) * 0.18 + Math.sin((phase - 230) / 12) * 3
+      }
+    } else if (template === 'interval') {
+      cycleSeconds = 260
+      const phase = elapsedSeconds % cycleSeconds
+      if (phase < 40) {
+        bpm = 100 + phase * 0.35
+      } else {
+        const wavePhase = phase - 40
+        const intervalCycle = wavePhase % 44
+        if (intervalCycle < 20) {
+          bpm = 140 + intervalCycle * 1.2
+        } else if (intervalCycle < 32) {
+          bpm = 164 - (intervalCycle - 20) * 0.45
+        } else {
+          bpm = 158 - (intervalCycle - 32) * 2.7
+        }
+      }
+    } else {
+      const phase = elapsedSeconds % cycleSeconds
+      if (phase < 60) {
+        bpm = 96 + phase * 0.35
+      } else if (phase < 150) {
+        bpm = 118 + Math.sin((phase - 60) / 18) * 6
+      } else if (phase < 240) {
+        bpm = 138 + Math.sin((phase - 150) / 10) * 9
+      } else if (phase < 300) {
+        bpm = 158 + Math.sin((phase - 240) / 7) * 8
+      } else {
+        bpm = 124 - (phase - 300) * 0.22 + Math.sin((phase - 300) / 15) * 4
+      }
+    }
+
+    const nextBpm = Math.max(72, Math.min(182, Math.round(bpm + jitter)))
+    elements.heartRateInput.value = String(nextBpm)
+    return nextBpm
+  }
+
   function sendCurrentPoint() {
     if (!state.socket || state.socket.readyState !== WebSocket.OPEN) {
       log('未连接桥接,无法发送')
@@ -705,6 +817,22 @@
     updateUiState()
   }
 
+  function sendCurrentHeartRate() {
+    if (!state.socket || state.socket.readyState !== WebSocket.OPEN) {
+      log('未连接桥接,无法发送心率')
+      return
+    }
+
+    const payload = {
+      type: 'mock_heart_rate',
+      timestamp: Date.now(),
+      bpm: state.heartRateSampleMode ? getSampleHeartRateBpm() : getHeartRateBpm(),
+    }
+    state.socket.send(JSON.stringify(payload))
+    state.lastHeartRateSentText = `${formatClockTime(payload.timestamp)} @ ${payload.bpm} bpm`
+    updateUiState()
+  }
+
   function startStream() {
     stopStream()
     state.streaming = true
@@ -725,6 +853,53 @@
     updateUiState()
   }
 
+  function startHeartRateStream() {
+    stopHeartRateStream()
+    state.heartRateStreaming = true
+    if (state.heartRateSampleMode && !state.heartRateSampleStartedAt) {
+      state.heartRateSampleStartedAt = Date.now()
+    }
+    const intervalMs = Math.max(150, 1000 / (Number(elements.heartRateHzSelect.value) || 1))
+    sendCurrentHeartRate()
+    state.heartRateStreamTimer = window.setInterval(sendCurrentHeartRate, intervalMs)
+    updateUiState()
+    log(`开始连续发送心率 (${Math.round(1000 / intervalMs)} Hz)`)
+  }
+
+  function stopHeartRateStream() {
+    state.heartRateStreaming = false
+    if (state.heartRateStreamTimer) {
+      window.clearInterval(state.heartRateStreamTimer)
+      state.heartRateStreamTimer = 0
+      log('已停止连续发送心率')
+    }
+    updateUiState()
+  }
+
+  function applyHeartRatePreset() {
+    const sampleBpm = [88, 102, 118, 136, 154, 170]
+    const current = getHeartRateBpm()
+    let nextIndex = sampleBpm.findIndex((value) => value > current)
+    if (nextIndex === -1) {
+      nextIndex = 0
+    }
+
+    elements.heartRateInput.value = String(sampleBpm[nextIndex])
+    log(`已应用心率分区样本: ${sampleBpm[nextIndex]} bpm`)
+  }
+
+  function toggleHeartRateSampleMode() {
+    state.heartRateSampleMode = !state.heartRateSampleMode
+    state.heartRateSampleStartedAt = state.heartRateSampleMode ? Date.now() : 0
+    if (state.heartRateSampleMode) {
+      const bpm = getSampleHeartRateBpm()
+      log(`已开启真实心率样本 (${elements.heartRateSampleTemplateSelect.value || 'jog'}): ${bpm} bpm`)
+    } else {
+      log('已关闭真实心率样本')
+    }
+    updateUiState()
+  }
+
   function syncPathLine() {
     pathLine.setLatLngs(pathPoints)
     elements.pathCountText.textContent = String(pathPoints.length)
@@ -1128,6 +1303,14 @@
   })
   elements.streamBtn.addEventListener('click', startStream)
   elements.stopStreamBtn.addEventListener('click', stopStream)
+  elements.sendHeartRateOnceBtn.addEventListener('click', () => {
+    sendCurrentHeartRate()
+    log('已发送当前心率')
+  })
+  elements.startHeartRateStreamBtn.addEventListener('click', startHeartRateStream)
+  elements.stopHeartRateStreamBtn.addEventListener('click', stopHeartRateStream)
+  elements.applyHeartRatePresetBtn.addEventListener('click', applyHeartRatePreset)
+  elements.toggleHeartRateSampleBtn.addEventListener('click', toggleHeartRateSampleMode)
   elements.togglePathModeBtn.addEventListener('click', () => {
     state.pathEditMode = !state.pathEditMode
     elements.pathHint.textContent = state.pathEditMode

+ 10 - 2
tools/mock-gps-sim/public/style.css

@@ -2,24 +2,30 @@
   box-sizing: border-box;
 }
 
+html,
 body {
+  height: 100%;
   margin: 0;
   font-family: "Segoe UI", "PingFang SC", sans-serif;
   background: #edf3ea;
   color: #163126;
+  overflow: hidden;
 }
 
 .layout {
   display: grid;
   grid-template-columns: 400px 1fr;
-  min-height: 100vh;
+  height: 100vh;
+  overflow: hidden;
 }
 
 .panel {
+  height: 100vh;
   padding: 20px;
   background: rgba(250, 252, 248, 0.96);
   border-right: 1px solid rgba(22, 49, 38, 0.08);
   overflow-y: auto;
+  overscroll-behavior: contain;
 }
 
 .panel__header h1 {
@@ -221,7 +227,9 @@ body {
 }
 
 .map-shell {
-  min-height: 100vh;
+  position: relative;
+  height: 100vh;
+  overflow: hidden;
 }
 
 #map {

+ 22 - 10
tools/mock-gps-sim/server.js

@@ -60,6 +60,12 @@ function isMockGpsPayload(payload) {
     && Number.isFinite(payload.lon)
 }
 
+function isMockHeartRatePayload(payload) {
+  return payload
+    && payload.type === 'mock_heart_rate'
+    && Number.isFinite(payload.bpm)
+}
+
 async function handleProxyRequest(request, response) {
   const requestUrl = new URL(request.url || '/', `http://127.0.0.1:${PORT}`)
   const targetUrl = requestUrl.searchParams.get('url')
@@ -111,19 +117,25 @@ wss.on('connection', (socket) => {
       return
     }
 
-    if (!isMockGpsPayload(parsed)) {
+    if (!isMockGpsPayload(parsed) && !isMockHeartRatePayload(parsed)) {
       return
     }
 
-    const serialized = JSON.stringify({
-      type: 'mock_gps',
-      timestamp: Number.isFinite(parsed.timestamp) ? parsed.timestamp : Date.now(),
-      lat: Number(parsed.lat),
-      lon: Number(parsed.lon),
-      accuracyMeters: Number.isFinite(parsed.accuracyMeters) ? Number(parsed.accuracyMeters) : 6,
-      speedMps: Number.isFinite(parsed.speedMps) ? Number(parsed.speedMps) : 0,
-      headingDeg: Number.isFinite(parsed.headingDeg) ? Number(parsed.headingDeg) : 0,
-    })
+    const serialized = isMockGpsPayload(parsed)
+      ? JSON.stringify({
+        type: 'mock_gps',
+        timestamp: Number.isFinite(parsed.timestamp) ? parsed.timestamp : Date.now(),
+        lat: Number(parsed.lat),
+        lon: Number(parsed.lon),
+        accuracyMeters: Number.isFinite(parsed.accuracyMeters) ? Number(parsed.accuracyMeters) : 6,
+        speedMps: Number.isFinite(parsed.speedMps) ? Number(parsed.speedMps) : 0,
+        headingDeg: Number.isFinite(parsed.headingDeg) ? Number(parsed.headingDeg) : 0,
+      })
+      : JSON.stringify({
+        type: 'mock_heart_rate',
+        timestamp: Number.isFinite(parsed.timestamp) ? parsed.timestamp : Date.now(),
+        bpm: Math.max(1, Math.round(Number(parsed.bpm))),
+      })
 
     wss.clients.forEach((client) => {
       if (client.readyState === client.OPEN) {