Capacitorで心拍数モニターを構築するには、標準のHeart Rate Service(0x180D)を公開しているBluetooth Low Energyセンサーに接続し、Heart Rate Measurement characteristicを購読します(0x2A37)し、各通知を毎分拍数(BPM)にデコードします。本チュートリアルでは、これを実装します @capgo/capacitor-bluetooth-low-energy Capacitor 8 上で、RR 間隔、センサー接触、再接続、および Apple Health や Health Connect への測定値の保存を含む
BLE心拍センサーの仕組み
Bluetooth Low Energyはデバイスを2つの役割に分類します。スマートフォンは centralです。スキャン、接続、データ要求を行います。胸ストラップは peripheralです。アドバタイズし、データを配信します。
peripheralは servicesを公開し、各serviceには characteristicsが含まれます。これは読み取り、書き込み、購読可能な小さな値です。標準的なserviceには、Bluetooth SIGによって16ビットのUUIDが割り当てられています:
| UUID | 名称 | アクセス権 | 得られるもの |
|---|---|---|---|
0x180D |
Heart Rate Service | サービス | 以下の項目を格納するコンテナ |
0x2A37 |
心拍数測定 | 通知 | BPM、センサー接触、消費エネルギー、RR間隔 |
0x2A38 |
身体センサーの位置 | 読む | 胸部、手首、指、手、耳たぶ、足 |
0x2A39 |
Heart Rate Control Point | Write | 0x01 消費エネルギーカウンターをリセットします |
0x180F |
Battery Service | サービス | ストラップのバッテリー残量 |
0x2A19 |
バッテリー残量 | 読み取り / 通知 | 0〜100パーセント |
16ビットUUIDは、完全な128ビット形式の略称です 0000XXXX-0000-1000-8000-00805F9B34FB。Capgo プラグインは両方の形式を受け入れます。
前提条件
- Capacitor 8 アプリ(Angular、React、Vue、Svelte、またはプレーン TypeScript など、任意のフレームワーク)
- 実機の iPhone または Android スマートフォン。シミュレーターやエミュレーターには使用可能な Bluetooth ラジオが搭載されていません
- Polar H10、Garmin HRM-Pro、Wahoo TICKR などの BLE 心拍数ストラップ。電極を濡らして装着してください。多くのストラップは肌に触れていないとスリープ状態のままです
ゼロから始める場合:
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
Bluetooth Low Energyプラグインのインストール
bun add @capgo/capacitor-bluetooth-low-energy
bunx cap sync
iOSの設定
Bluetooth 使用文字列を以下に追加します 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 の設定
プラグインは、必要な権限をマニフェストにマージします: BLUETOOTH_SCAN ( neverForLocation), BLUETOOTH_CONNECT と BLUETOOTH_ADVERTISE Android 12以降用、およびレガシー版 BLUETOOTH, BLUETOOTH_ADMIN およびAndroid 11以前の位置情報権限。Android 12以降でも実行時にリクエストが必要で、これは requestPermissions() が判断基準です。
また、以下を宣言しています android.hardware.bluetooth_le を必須としており、BLE非対応デバイスではGoogle Playでアプリが非表示になります。心拍数が任意の機能である場合は、でオーバーライドしてください 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>
Heart Rate Measurementのデコード
この部分を最初に実行してください。多くの実装でここが誤りになるためです。特性値はバイト配列です:
| バイト数 | 意味 |
|---|---|
| 0 | フラグ |
| 1または1-2 | 心拍数: フラグビット0が0の場合はUINT8、1の場合はUINT16リトルエンディアン |
| 次の2バイト (任意) | 消費エネルギー (kJ)、フラグビット3が1の場合に存在 |
| 残り (任意) | RR間隔、UINT16リトルエンディアン、単位1/1024秒、フラグビット4が1の場合に存在 |
フラグのビット1と2は皮膚接触を表します。ビット2はセンサーが接触検出に対応しているか、ビット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'] })は心拍数センサーのみを返すため、モデルやファームウェアバージョンごとに異なるデバイス名でマッチングする必要はありません。 - サブスクライブ前にリスナーを登録、そうしないと最初の通知がハンドラーが存在する前に届く可能性があります。
- 接続が確立したらスキャンを停止します。 スキャンはバッテリーを消耗させ、一部の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
HRVにはRR間隔を使用
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));
}
安静時に少なくとも1分間の間隔を収集し、動きによるアーティファクト(例: 前回の値から20%以上異なる間隔)による外れ値を除外します。
測定値をApple HealthまたはHealth Connectに保存
他のアプリで測定値を利用可能にするには、以下で書き込みます: @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とusage stringsが必要です。AndroidではHealth Connectにマニフェストで権限を宣言し、プライバシーポリシー画面が必要です。私たちの 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権限。
トラブルシューティング
デバイスが見つかりません:ストラップが装着されていないか、電極が乾いています。多くのストラップは1つの接続のみを許可するため、まず時計、サイクルコンピューター、または他のアプリから切断してください。
requestPermissions は Android 11 以下で denied を返します:これらのバージョンでは、BLE スキャンに位置情報権限と位置情報サービスのオンが必要です。確認してください isLocationEnabled() を呼び出す openLocationSettings().
Android 12以降ではスキャンしても何も見つからないが、11では動作する:ユーザーが「近くのデバイス」を拒否しました。ユーザーを以下に誘導してください openAppSettings().
心拍数が常に0になる:肌に接触していません。以下を確認してください contactDetected.
255 bpmを超える値やランダムな数値:フラグのビット0を無視しているか、16ビット値をビッグエンディアンで読み取っています。上記のパーサーを使用してください。
Androidで画面ロック後に通知が止まる:バッテリー最適化です。セッション中は画面をオンに保つか、フォアグラウンドサービスを使用してください。
Google Playで一部のデバイスにアプリが表示されない:プラグインの uses-feature がBLEにはデフォルトで必要です。Android設定セクションに示すようにオーバーライドしてください。
次のステップ
同じパターンは他の標準 GATT プロファイルにも適用できます:Cycling Speed and Cadence (0x1816)、走行速度とケイデンス(0x1814)、および Cycling Power (0x1818 )。変更するのはパーサーだけです。詳しくは Bluetooth Low Energy プラグインのドキュメント for writes, MTU requests and peripheral mode. Once the native app is in the stores, parsing fixes and UI changes are JavaScript, so you can ship them with Capgo Live Updates レビューを待つ代わりに。