Langsung ke konten utama

Cara Membuat Heart Rate Monitor dengan Capacitor

Buat heart rate monitor dengan Capacitor dan Bluetooth Low Energy: scan sensor 0x180D, parse BPM dan interval RR, lalu simpan pembacaan ke Health.

Aplikasi Capacitor yang menampilkan detak jantung secara langsung dari tali dada Bluetooth

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-central di UIBackgroundModes, 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() dan KeepAwake.allowSleep()). Untuk rekaman background yang sebenarnya, tambahkan foreground service Anda sendiri dengan android:foregroundServiceType="connectedDevice" dan FOREGROUND_SERVICE_CONNECTED_DEVICE izinnya.

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.

Live updates untuk aplikasi Capacitor

Saat bug web-layer sudah live, kirim perbaikannya lewat Capgo alih-alih menunggu berhari-hari untuk persetujuan app store. Pengguna mendapat update di background, sementara perubahan native tetap mengikuti jalur review normal.

Terbaru dari Blog kami

Capgo memberikan insight terbaik yang Anda butuhkan untuk membuat aplikasi mobile yang benar-benar profesional.