要使用 Capacitor 构建心率监测器,请连接一个提供标准 Heart Rate Service 的 Bluetooth Low Energy 传感器(0x180D),并订阅 Heart Rate Measurement 特性(0x2A37), 并将每条通知解码为每分钟心跳数 (BPM)。本教程将通过 @capgo/capacitor-bluetooth-low-energy 在 Capacitor 8 上,包括 RR 间期、传感器接触状态、重连,以及将读数保存到 Apple Health 或 Health Connect。
BLE 心率传感器的工作原理
蓝牙低功耗 (BLE) 将设备分为两种角色。你的手机是 中心设备 (central):它负责扫描、连接并请求数据。胸带是 外围设备 (peripheral):它负责广播并提供数据。
外围设备会暴露 服务 (services),而每个服务包含 特征值 (characteristics),可读取、写入或订阅的小数据值。标准服务使用 Bluetooth SIG 分配的 16 位 UUID:
| UUID | 名称 | 访问权 | 提供的功能 |
|---|---|---|---|
0x180D |
心率服务 | 服务 | 下方条目容器 |
0x2A37 |
心率测量 | 通知 | BPM、传感器接触、能量消耗、RR 间期 |
0x2A38 |
身体传感器位置 | 读取 | 胸部、手腕、手指、手、耳垂、脚 |
0x2A39 |
心率控制点 | 写入 | 0x01 重置能量消耗计数器 |
0x180F |
电池服务 | 服务 | 表带电池电量 |
0x2A19 |
电池电量 | 读取 / 通知 | 0 到 100% |
16 位 UUID 是 128 位完整形式的简写 0000XXXX-0000-1000-8000-00805F9B34FB。Capgo 插件同时支持这两种形式。
前提条件
- 一个 Capacitor 8 应用(任意框架:Angular、React、Vue、Svelte 或纯 TypeScript)
- 一台实体 iPhone 或 Android 手机。模拟器和仿真器没有可用的蓝牙无线电
- 一条 BLE 心率带,例如 Polar H10、Garmin HRM-Pro 或 Wahoo TICKR。请润湿电极并佩戴,许多心率带在没有皮肤接触时会保持休眠状态
如果你是从零开始:
bun create vite heart-rate --template vanilla-ts
cd heart-rate
bun add @capacitor/core @capacitor/ios @capacitor/android
bun add -d @capacitor/cli
bunx cap init "Heart Rate" com.example.heartrate --web-dir dist
bun run build
bunx cap add ios
bunx cap add android
安装蓝牙低功耗插件
bun add @capgo/capacitor-bluetooth-low-energy
bunx cap sync
iOS 配置
将蓝牙用途字符串添加到 ios/App/App/Info.plist,以及 bluetooth-central 如需在锁屏状态下获取读数,请启用后台模式:
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Connect to your heart rate sensor during workouts.</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>Connect to your heart rate sensor during workouts.</string>
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-central</string>
</array>
Android 配置
插件会把需要的权限合并到你的 manifest 中: BLUETOOTH_SCAN (将 neverForLocation), BLUETOOTH_CONNECT 以及 BLUETOOTH_ADVERTISE 适用于 Android 12+,以及旧版 BLUETOOTH, BLUETOOTH_ADMIN 和 Android 11 及更低版本的位置权限。在 Android 12+ 上,你仍需在运行时请求它们,这 requestPermissions() 。
它还会声明 android.hardware.bluetooth_le as required, which hides your app on Google Play for devices without BLE. If heart rate is an optional feature in your app, override it in android/app/src/main/AndroidManifest.xml:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-feature
android:name="android.hardware.bluetooth_le"
android:required="false"
tools:replace="android:required" />
</manifest>
解码心率测量值
先做这一步,因为大多数实现都会在这里出错。特征值(characteristic value)是一个字节数组:
| 字节 | 含义 |
|---|---|
| 0 | 参数 |
| 1 或 1-2 | 心率:flag bit 0 为 0 时为 UINT8,为 1 时为 UINT16 小端序 |
| 后 2 个(可选) | 能量消耗(kJ),flag bit 3 为 1 时存在 |
| 剩余部分(可选) | RR 间期,UINT16 小端序,单位 1/1024 s,flag bit 4 为 1 时存在 |
Flag bit 1 和 2 描述皮肤接触:bit 2 表示传感器是否支持接触检测,bit 1 表示是否检测到接触。
export interface HeartRateMeasurement {
bpm: number;
contactDetected: boolean | null; // null when the sensor does not report contact
energyExpendedKj?: number;
rrIntervalsMs: number[];
}
export function parseHeartRate(bytes: number[]): HeartRateMeasurement {
const data = Uint8Array.from(bytes);
const view = new DataView(data.buffer);
const flags = data[0];
let offset = 1;
const is16Bit = (flags & 0x01) !== 0;
const bpm = is16Bit ? view.getUint16(offset, true) : view.getUint8(offset);
offset += is16Bit ? 2 : 1;
const contactSupported = (flags & 0x04) !== 0;
const contactDetected = contactSupported ? (flags & 0x02) !== 0 : null;
let energyExpendedKj: number | undefined;
if (flags & 0x08) {
energyExpendedKj = view.getUint16(offset, true);
offset += 2;
}
const rrIntervalsMs: number[] = [];
if (flags & 0x10) {
for (; offset + 1 < data.length; offset += 2) {
rrIntervalsMs.push(Math.round((view.getUint16(offset, true) / 1024) * 1000));
}
}
return { bpm, contactDetected, energyExpendedKj, rrIntervalsMs };
}
本指南中介绍的 true 的参数 getUint16 表示小端序,符合 Bluetooth 规范的要求。Code 将第一个字节左移(byte1 << 8 | byte2) 会反向读取 16 位数值。
快速验证测试:
parseHeartRate([0x16, 0x48, 0x00, 0x04]);
// flags 0x16: 8-bit value, contact supported and detected, RR present
// => { bpm: 72, contactDetected: true, rrIntervalsMs: [1000] }
连接传感器
以下模块会扫描广播 Heart Rate Service 的设备,连接第一个找到的设备,并将解析后的读数流式传输到回调函数。
import { BluetoothLowEnergy } from '@capgo/capacitor-bluetooth-low-energy';
import type { PluginListenerHandle } from '@capacitor/core';
import { parseHeartRate, type HeartRateMeasurement } from './heart-rate-parser';
const HEART_RATE_SERVICE = '180D';
const HEART_RATE_MEASUREMENT = '2A37';
const BODY_SENSOR_LOCATION = '2A38';
const BATTERY_SERVICE = '180F';
const BATTERY_LEVEL = '2A19';
// iOS reports short UUIDs ("2A37"), Android reports the full 128-bit form
const isUuid = (value: string, short: string) =>
value.toUpperCase() === short || value.toUpperCase().startsWith(`0000${short}-`);
let listeners: PluginListenerHandle[] = [];
let connectedId: string | null = null;
export async function startHeartRate(onReading: (m: HeartRateMeasurement) => void) {
await BluetoothLowEnergy.initialize({ mode: 'central' });
const { available } = await BluetoothLowEnergy.isAvailable();
if (!available) throw new Error('This device has no Bluetooth LE');
const perms = await BluetoothLowEnergy.requestPermissions();
if (perms.bluetooth !== 'granted') throw new Error('Bluetooth permission denied');
const { enabled } = await BluetoothLowEnergy.isEnabled();
if (!enabled) {
await BluetoothLowEnergy.openBluetoothSettings();
throw new Error('Bluetooth is off. Turn it on and tap Connect again.');
}
listeners.push(
await BluetoothLowEnergy.addListener('characteristicChanged', (event) => {
if (isUuid(event.characteristic, HEART_RATE_MEASUREMENT)) {
onReading(parseHeartRate(event.value));
}
}),
await BluetoothLowEnergy.addListener('deviceDisconnected', ({ deviceId }) => {
if (deviceId === connectedId) void reconnect(deviceId);
}),
);
try {
const device = await scanForSensor(10_000);
try {
await connectTo(device.deviceId);
} catch (error) {
// connect() may have succeeded before a later step failed: release the half-open link
await BluetoothLowEnergy.disconnect({ deviceId: device.deviceId }).catch(() => undefined);
throw error;
}
} catch (error) {
// Don't leave listeners behind, or each retry would handle every event again
await Promise.all(listeners.map((l) => l.remove()));
listeners = [];
throw error;
}
}
function scanForSensor(timeoutMs: number) {
return new Promise<{ deviceId: string; name: string | null }>((resolve, reject) => {
let handle: PluginListenerHandle | undefined;
let settled = false;
const finish = (error: Error | null, device?: { deviceId: string; name: string | null }) => {
if (settled) return;
settled = true;
clearTimeout(timer);
void handle?.remove();
void BluetoothLowEnergy.stopScan().catch(() => undefined);
if (error) reject(error);
else resolve(device!);
};
const timer = setTimeout(
() => finish(new Error('No heart rate sensor found. Is the strap worn and moist?')),
timeoutMs,
);
BluetoothLowEnergy.addListener('deviceScanned', ({ device }) => finish(null, device))
.then((h) => {
handle = h;
if (settled) {
void h.remove();
return;
}
return BluetoothLowEnergy.startScan({ services: [HEART_RATE_SERVICE] });
})
.catch((error) => finish(error instanceof Error ? error : new Error(String(error))));
});
}
async function connectTo(deviceId: string) {
await BluetoothLowEnergy.connect({ deviceId });
await BluetoothLowEnergy.discoverServices({ deviceId });
await BluetoothLowEnergy.startCharacteristicNotifications({
deviceId,
service: HEART_RATE_SERVICE,
characteristic: HEART_RATE_MEASUREMENT,
});
connectedId = deviceId;
localStorage.setItem('hr-sensor', deviceId);
}
async function reconnect(deviceId: string, attempt = 0) {
// stopHeartRate() clears connectedId, which ends any pending retry chain
if (attempt > 5 || connectedId !== deviceId) return;
try {
await connectTo(deviceId);
} catch {
setTimeout(() => void reconnect(deviceId, attempt + 1), 2000 * (attempt + 1));
}
}
export async function stopHeartRate() {
if (connectedId) {
await BluetoothLowEnergy.stopCharacteristicNotifications({
deviceId: connectedId,
service: HEART_RATE_SERVICE,
characteristic: HEART_RATE_MEASUREMENT,
}).catch(() => undefined);
const id = connectedId;
connectedId = null; // prevents the disconnect listener from reconnecting
await BluetoothLowEnergy.disconnect({ deviceId: id });
}
await Promise.all(listeners.map((l) => l.remove()));
listeners = [];
}
为什么这样写:
- 按服务过滤扫描。
startScan({ services: ['180D'] })仅返回心率传感器,因此无需匹配设备名称,因为不同型号和固件版本的设备名称各不相同。 - 在订阅之前注册监听器,否则首批通知可能在 handler 就绪前到达。
- 连接成功后停止扫描。 在部分 Android 手机上,扫描会消耗电量并降低连接速度。
- 设备 ID 因平台而异:Android 上是 MAC 地址,iOS 上是每个应用各自的 UUID。保存拿到的值,并且只在同一设备上复用。
读取传感器位置与电量
const LOCATIONS = ['Other', 'Chest', 'Wrist', 'Finger', 'Hand', 'Ear lobe', 'Foot'];
export async function readSensorInfo(deviceId: string) {
const location = await BluetoothLowEnergy.readCharacteristic({
deviceId,
service: HEART_RATE_SERVICE,
characteristic: BODY_SENSOR_LOCATION,
}).catch(() => null);
const battery = await BluetoothLowEnergy.readCharacteristic({
deviceId,
service: BATTERY_SERVICE,
characteristic: BATTERY_LEVEL,
}).catch(() => null);
return {
location: location ? LOCATIONS[location.value[0]] ?? 'Unknown' : 'Unknown',
batteryPercent: battery ? battery.value[0] : null,
};
}
规范中这两项均为可选,因此请封装读取操作并处理缺失值。
构建界面
一个包含连接按钮、实时 BPM 和接触警告的极简 UI:
<main>
<h1>Heart rate</h1>
<p id="bpm" aria-live="polite">--</p>
<p id="status"></p>
<button id="connect" type="button">Connect sensor</button>
</main>
import { startHeartRate } from './heart-rate';
const bpmEl = document.getElementById('bpm')!;
const statusEl = document.getElementById('status')!;
document.getElementById('connect')!.addEventListener('click', async () => {
statusEl.textContent = 'Searching...';
try {
await startHeartRate((m) => {
bpmEl.textContent = m.contactDetected === false ? '--' : `${m.bpm} bpm`;
statusEl.textContent = m.contactDetected === false ? 'Check strap contact' : 'Connected';
});
} catch (e) {
statusEl.textContent = (e as Error).message;
}
});
通过点击按钮启动扫描,而不是在页面加载时自动开始。iOS 和 Android 首次都会弹出权限弹窗,用户明白原因后更容易接受。
在设备上运行:
bun run build
bunx cap run android
bunx cap run ios
使用 RR 间期计算 HRV
RR 间期是两次心跳之间的时间间隔。通过它们可以计算心率变异性 (HRV),应用常用它来得出恢复和压力评分。常见指标是滑动窗口内的 RMSSD:
export function rmssd(rr: number[]): number | null {
if (rr.length < 2) return null;
let sum = 0;
for (let i = 1; i < rr.length; i++) sum += (rr[i] - rr[i - 1]) ** 2;
return Math.sqrt(sum / (rr.length - 1));
}
在静息状态下至少采集一分钟的间期,并剔除运动伪影造成的异常值(例如与前一个间期相差超过 20% 的间期)。
将读数保存到 Apple Health 或 Health Connect
若要让其他 App 也能读取这些读数,请用以下方式写入: @capgo/capacitor-health在短时间窗口内取平均,而不是每次通知都写入:
import { Health } from '@capgo/capacitor-health';
await Health.requestAuthorization({ write: ['heartRate'] });
export async function saveAverage(bpm: number, start: Date, end: Date) {
await Health.saveSample({
dataType: 'heartRate',
value: bpm,
unit: 'bpm',
startDate: start.toISOString(),
endDate: end.toISOString(),
});
}
在 iOS 上,HealthKit 需要 HealthKit capability 和用途说明字符串。Android 上,Health Connect 需要在 manifest 中声明权限,并提供隐私政策页面。我们的 Google Fit 迁移至 Health Connect 指南 涵盖了这些配置步骤。
在运动期间保持监测器运行
- iOS:使用
bluetooth-central中的UIBackgroundModes,即使应用处于后台且屏幕已锁定,通知仍会持续到达。在内存压力下,iOS 仍可能终止连接,因此请保留重连逻辑。 - Android:已建立的 GATT 会话会在进程存活期间持续推送通知。对于屏幕关闭且持续几分钟以上的锻炼,Android 可能会冻结或杀死应用。简单的解决方法是在活动会话期间保持屏幕常亮,使用
@capgo/capacitor-keep-awake(KeepAwake.keepAwake()以及KeepAwake.allowSleep())。若要实现真正的后台录制,请添加自定义前台服务,使用android:foregroundServiceType="connectedDevice"以及FOREGROUND_SERVICE_CONNECTED_DEVICE权限。
故障排除
未找到设备:未佩戴心率带或电极片干燥。许多心率带仅支持单连接,请先断开与手表、码表或其他 App 的连接。
requestPermissions 在 Android 11 及以下版本返回拒绝:这些版本的 BLE 扫描需要位置权限,并需开启位置服务。请检查 isLocationEnabled() 并调用 openLocationSettings().
Android 12+ 扫描无结果,但在 Android 11 上正常:用户拒绝了“附近设备”权限。请引导用户前往 openAppSettings().
心率始终为 0:未接触皮肤。请检查 contactDetected.
数值超过 255 bpm 或为随机数:你忽略了 flag bit 0,或者按大端序(big-endian)读取 16 位值。请使用上面的解析器。
Android 手机锁屏后通知停止:电池优化。会话期间保持屏幕常亮,或使用前台服务。
部分设备在 Google Play 上找不到该应用:插件的 uses-feature BLE 默认是必需的。请按 Android 配置章节所示覆盖。
后续步骤
同样的模式也适用于其他标准 GATT profile:骑行速度与踏频(0x1816),跑步速度与步频(0x1814),以及骑行功率(0x1818)。只有解析器会变。请参阅 Bluetooth Low Energy 插件文档 用于写入、MTU 请求和外设模式。原生应用上架后,解析修复和 UI 变更都是 JavaScript,因此你可以把它们 Capgo 实时更新 发布,而无需等待审核。