Untuk membangun monitor detak jantung dengan Capacitor, hubungkan ke sensor Bluetooth Low Energy yang mengekspos Heart Rate Service standar (0x180D), berlangganan ke karakteristik Heart Rate Measurement (0x2A37), dan dekoding setiap notifikasi menjadi detak per menit. Tutorial ini melakukannya dengan @capgo/capacitor-bluetooth-low-energy di Capacitor 8, termasuk interval RR, kontak sensor, koneksi ulang, dan menyimpan pembacaan ke Apple Health atau Health Connect.
Cara kerja sensor detak jantung BLE
Bluetooth Low Energy membagi perangkat menjadi dua peran. Ponsel Anda adalah central: ia memindai, terhubung, dan meminta data. Tali dada adalah peripheral: ia mengiklankan dan menyajikan data.
Sebuah peripheral mengekspos layanan, dan setiap service menyimpan characteristics, nilai kecil yang bisa dibaca, ditulis, atau disubscribe. Service standar memiliki UUID 16-bit yang ditetapkan oleh Bluetooth SIG:
| UUID | Nama | Akses | Apa yang Anda dapatkan |
|---|---|---|---|
0x180D |
Layanan Heart Rate | Layanan | Wadah untuk item di bawah |
0x2A37 |
Pengukuran Detak Jantung | Notifikasi | BPM, kontak sensor, energi yang dikeluarkan, interval RR |
0x2A38 |
Lokasi Sensor Tubuh | Baca | Dada, pergelangan tangan, jari, tangan, cuping telinga, kaki |
0x2A39 |
Titik Kontrol Detak Jantung | Tulis | 0x01 mereset penghitung energi yang dikeluarkan |
0x180F |
Layanan Baterai | Layanan | Level baterai strap |
0x2A19 |
Level baterai | Baca / Notify | 0 hingga 100 persen |
UUID 16-bit adalah singkatan dari format 128-bit lengkap 0000XXXX-0000-1000-8000-00805F9B34FB. Plugin Capgo menerima kedua format tersebut.
Prerequisites
- Aplikasi Capacitor 8 (framework apa pun: Angular, React, Vue, Svelte, atau TypeScript murni)
- Ponsel iPhone atau Android fisik. Simulator dan emulator tidak memiliki radio Bluetooth yang dapat digunakan
- Sabuk detak jantung BLE seperti Polar H10, Garmin HRM-Pro, atau Wahoo TICKR. Basahi elektrodanya dan kenakan, banyak sabuk tetap tidur tanpa kontak kulit
Jika Anda memulai dari nol:
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
Instal plugin Bluetooth Low Energy
bun add @capgo/capacitor-bluetooth-low-energy
bunx cap sync
Konfigurasi iOS
Tambahkan string penggunaan Bluetooth ke ios/App/App/Info.plist, dan bluetooth-central mode background jika Anda ingin pembacaan saat layar terkunci:
<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>
Konfigurasi Android
Plugin ini menggabungkan izin yang dibutuhkan ke dalam manifest Anda: BLUETOOTH_SCAN (dengan neverForLocation), BLUETOOTH_CONNECT dan BLUETOOTH_ADVERTISE untuk Android 12+, ditambah izin legacy BLUETOOTH, BLUETOOTH_ADMIN dan izin lokasi untuk Android 11 ke bawah. Pada Android 12+ Anda tetap perlu memintanya saat runtime, yang requestPermissions() melakukan.
Plugin ini juga mendeklarasikan android.hardware.bluetooth_le sebagai required, yang menyembunyikan aplikasi Anda di Google Play untuk perangkat tanpa BLE. Jika heart rate adalah fitur opsional di aplikasi Anda, override di 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>
Decode Heart Rate Measurement
Lakukan bagian ini dulu, karena di sinilah sebagian besar implementasi salah. Nilai characteristic-nya berupa byte array:
| Byte | Arti |
|---|---|
| 0 | Flags |
| 1 atau 1-2 | Detak jantung: UINT8 jika bit flag 0 bernilai 0, UINT16 little-endian jika bernilai 1 |
| 2 berikutnya (opsional) | Energi yang dikeluarkan dalam kJ, ada jika bit flag 3 bernilai 1 |
| sisanya (opsional) | Interval RR, UINT16 little-endian, satuan 1/1024 s, ada jika bit flag 4 bernilai 1 |
Bit flag 1 dan 2 menjelaskan kontak kulit: bit 2 menunjukkan apakah sensor mendukung deteksi kontak, bit 1 menunjukkan apakah kontak terdeteksi.
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 };
}
Panduan true argumen untuk getUint16 berarti little-endian, seperti yang disyaratkan spesifikasi Bluetooth. Code yang menggeser byte pertama ke kiri (byte1 << 8 | byte2) membaca nilai 16-bit secara terbalik.
Tes sanity cepat:
parseHeartRate([0x16, 0x48, 0x00, 0x04]);
// flags 0x16: 8-bit value, contact supported and detected, RR present
// => { bpm: 72, contactDetected: true, rrIntervalsMs: [1000] }
Hubungkan ke sensor
Modul berikut memindai perangkat yang mengiklankan Heart Rate Service, terhubung ke perangkat pertama, dan melakukan streaming pembacaan yang sudah di-parse ke callback.
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 = [];
}
Mengapa ditulis seperti ini:
- Filter scan berdasarkan service.
startScan({ services: ['180D'] })hanya mengembalikan sensor heart rate, jadi Anda tidak perlu mencocokkan nama perangkat, yang berbeda antara model dan versi firmware. - Daftarkan listener sebelum subscribe, jika tidak, notifikasi pertama bisa tiba sebelum handler Anda dibuat.
- Hentikan scanning setelah terhubung. Scanning menguras baterai dan memperlambat koneksi di beberapa ponsel Android.
- Device ID berbeda di setiap platform: alamat MAC di Android, UUID per-app di iOS. Simpan apa pun yang Anda dapatkan dan gunakan ulang hanya di perangkat yang sama.
Baca lokasi sensor dan baterai
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,
};
}
Keduanya opsional di spesifikasi, jadi bungkus pembacaannya dan tangani nilai yang tidak tersedia.
Buat layarnya
UI minimalis dengan tombol koneksi, BPM live, dan peringatan kontak:
<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;
}
});
Mulai scan dari ketukan tombol, bukan saat halaman dimuat. iOS dan Android menampilkan prompt izin saat pertama kali, dan pengguna lebih sering menyetujuinya kalau paham alasannya.
Jalankan di perangkat:
bun run build
bunx cap run android
bunx cap run ios
Gunakan interval RR untuk HRV
Interval RR adalah waktu antar detak jantung. Dari situ Anda bisa menghitung heart rate variability, yang dipakai aplikasi untuk skor pemulihan dan stres. RMSSD pada rolling window adalah metrik yang umum:
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));
}
Kumpulkan interval minimal satu menit saat istirahat, dan buang outlier (misalnya interval yang berbeda lebih dari 20 persen dari sebelumnya) akibat artefak gerakan.
Simpan hasil pembacaan ke Apple Health atau Health Connect
Agar hasil pembacaan tersedia untuk aplikasi lain, tulis dengan @capgo/capacitor-health. Rata-ratakan dalam window singkat, bukan menulis setiap notifikasi:
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(),
});
}
HealthKit membutuhkan capability HealthKit dan usage string di iOS. Health Connect membutuhkan izin yang dideklarasikan di manifest dan layar kebijakan privasi di Android. Panduan migrasi Google Fit ke Health Connect mencakup setup tersebut.
Biarkan monitor tetap berjalan selama workout
- iOS: dengan
bluetooth-centraldiUIBackgroundModes, notifikasi terus masuk saat aplikasi di background dan layar terkunci. iOS mungkin masih memutus koneksi saat tekanan memori tinggi, jadi pertahankan logika reconnect. - Android: sesi GATT yang terhubung terus mengirim notifikasi selama proses Anda masih berjalan. Untuk workout lebih dari beberapa menit dengan layar mati, Android bisa membekukan atau mematikan aplikasi. Solusi sederhananya adalah menjaga layar tetap menyala selama sesi aktif dengan
@capgo/capacitor-keep-awake(KeepAwake.keepAwake()danKeepAwake.allowSleep()). Untuk rekaman background yang sebenarnya, tambahkan foreground service Anda sendiri denganandroid:foregroundServiceType="connectedDevice"danFOREGROUND_SERVICE_CONNECTED_DEVICEizinnya.
Pemecahan Masalah
Perangkat tidak ditemukan: strap tidak terpasang atau elektrodanya kering. Banyak strap juga hanya mengizinkan satu koneksi, jadi putuskan koneksinya dari jam tangan, bike computer, atau aplikasi lain dulu.
requestPermissions mengembalikan denied di Android 11 atau lebih rendah: scanning BLE pada versi tersebut butuh izin lokasi dan layanan lokasi harus aktif. Cek isLocationEnabled() dan panggil openLocationSettings().
Scanning tidak menemukan apa pun di Android 12+ tapi berfungsi di 11: pengguna menolak “Perangkat di sekitar”. Arahkan mereka ke openAppSettings().
Detak jantung selalu 0: tidak ada kontak dengan kulit. Cek contactDetected.
Nilai di atas 255 bpm atau angka acak: Anda mengabaikan flag bit 0 atau membaca nilai 16-bit big-endian. Gunakan parser di atas.
Notifikasi berhenti setelah ponsel terkunci di Android: optimasi baterai. Jaga layar tetap menyala selama sesi atau gunakan foreground service.
Aplikasi tidak muncul di beberapa perangkat di Google Play: milik plugin uses-feature untuk BLE diperlukan secara default. Override seperti yang ditunjukkan di bagian konfigurasi Android.
Langkah berikutnya
Pola yang sama berlaku untuk profil GATT standar lainnya: Cycling Speed and Cadence (0x1816), Kecepatan Lari dan Cadence (0x1814), dan Cycling Power (0x1818). Hanya parser yang berubah. Lihat Dokumentasi plugin Bluetooth Low Energy untuk write, request MTU, dan peripheral mode. Setelah aplikasi native ada di store, perbaikan parsing dan perubahan UI cukup JavaScript, jadi Anda bisa mengirimnya dengan Live updates Capgo alih-alih menunggu review.