Capacitor로 심박수 모니터를 만들려면 표준 Heart Rate Service(0x180D)를 노출하는 Bluetooth Low Energy 센서에 연결하고, Heart Rate Measurement 특성(0x2A37)에 구독하여 각 알림을 분당 박동수(BPM)로 디코딩합니다. 이 튜토리얼은 @capgo/capacitor-bluetooth-low-energy 를 Capacitor 8에서 사용하여 RR 간격, 센서 접촉, 재연결 및 Apple Health 또는 Health Connect에 측정값 저장 기능을 구현합니다.
BLE 심박수 센서 작동 원리
Bluetooth Low Energy는 기기를 두 가지 역할로 나눕니다. 스마트폰은 central역할을 수행하며 스캔, 연결 및 데이터 요청을 담당합니다. 가슴 스트랩은 peripheral: 데이터를 광고하고 제공합니다.
주변 기기는 서비스를 지원합니다을(를) 노출하며, 각 서비스는 특성(characteristics), 읽거나 쓰거나 구독할 수 있는 작은 값입니다. 표준 서비스에는 Bluetooth SIG가 할당한 16비트 UUID가 있습니다:
| UUID | 이름 | 액세스 | 제공 기능 |
|---|---|---|---|
0x180D |
심박수 서비스 | 서비스 | 아래 항목들을 포함하는 컨테이너 |
0x2A37 |
심박수 측정 | 알림 | BPM, 센서 접촉 여부, 에너지 소모량, RR 간격 |
0x2A38 |
신체 센서 위치 | 읽기 | 가슴, 손목, 손가락, 손, 귓불, 발 |
0x2A39 |
심박수 제어 포인트 | 쓰기 | 0x01 소모 에너지 카운터를 초기화합니다 |
0x180F |
배터리 서비스 | 서비스 | 스트랩 배터리 수준 |
0x2A19 |
배터리 수준 | 읽기 / 알림 | 0~100% |
16-bit UUID는 128-bit 전체 형식의 약식 표기입니다 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 화면이 잠겨 있는 동안에도 측정값을 얻으려면 background mode를 사용하세요:
<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 필수로, 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 디코딩
가장 많은 구현 오류가 발생하는 부분이니 이 단계부터 진행하세요. characteristic 값은 byte array입니다:
| 바이트(Byte) | 의미 |
|---|---|
| 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 사양이 요구하는 little-endian을 의미합니다. 첫 번째 바이트를 왼쪽으로 시프트하는 Code (byte1 << 8 | byte2) 은 16-bit 값을 역순으로 읽습니다.
간단한 sanity test:
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와 사용 문자열이 필요합니다. Android에서는 매니페스트에 권한을 선언하고 개인정보 처리방침 화면이 필요합니다. 저희 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권한.
문제 해결
장치를 찾을 수 없음: 스트랩이 착용되지 않았거나 전극이 마른 상태입니다. 많은 스트랩은 하나의 연결만 허용하므로, 먼저 시계, 사이클 컴퓨터 또는 다른 앱에서 연결을 해제하세요.
requestPermissions Android 11 이하에서 denied를 반환합니다: 해당 버전에서는 BLE 스캔에 위치 권한과 위치 서비스가 켜져 있어야 합니다. 확인하세요 isLocationEnabled() 하고 openLocationSettings().
Android 12 이상에서는 스캔 시 아무것도 감지되지 않지만 11에서는 작동합니다: 사용자가 “근처 기기”를 거부했습니다. 다음으로 이동하세요 openAppSettings().
심박수가 항상 0으로 표시됩니다: 피부 접촉이 없습니다. 다음을 확인하세요 contactDetected.
255bpm을 초과하거나 무작위 값이 표시됩니다: 플래그 비트 0을 무시하거나 16비트 값을 big-endian으로 읽고 있습니다. 위의 파서를 사용하세요.
Android에서 화면이 잠기면 알림이 중단됩니다: 배터리 최적화 설정입니다. 세션 중 화면을 깨워 두거나 foreground service를 사용하세요.
일부 기기에서 Google Play에 앱이 표시되지 않습니다: 플러그인의 uses-feature BLE에 대한 설정이 기본적으로 필요합니다. Android 구성 섹션에 표시된 대로 오버라이드하세요.
다음 단계
이 패턴은 다른 표준 GATT 프로파일에도 동일하게 적용됩니다: 사이클링 속도 및 페달링 수(0x1816), 러닝 속도 및 페달링 수(0x1814), 그리고 사이클링 파워(0x1818). 파서만 변경하면 됩니다. Bluetooth Low Energy 플러그인 문서 에서 쓰기, MTU 요청 및 주변기기 모드를 확인하세요. 네이티브 앱이 스토어에 등록되면 파싱 수정과 UI 변경은 JavaScript이므로 Capgo Live Update 사용하기 로 배포할 수 있어 리뷰 대기 없이 바로 반영할 수 있습니다.