Para construir un monitoreo de ritmo cardíaco con Capacitor, conecte a un sensor de Bluetooth Low Energy que expone el servicio de ritmo cardíaco estándar (0x180Dse suscriba a la característica de medición de ritmo cardíaco (0x2A37y descomponga cada notificación en pulsaciones por minuto. Este tutorial hace eso con @capgo/capacitor-bluetooth-low-energy On Capacitor 8, incluyendo intervalos RR, contacto del sensor, reconexión y guardado de lecturas en Apple Health o Health Connect.
Cómo funcionan los sensores de ritmo cardíaco BLE
Bluetooth Low Energy divide los dispositivos en dos roles. Su teléfono es el central: escanea, se conecta y solicita datos. La banda de pecho es el periférico: anuncia y proporciona datos.
Un periférico expone servicios, y cada servicio contiene características, pequeños valores que se pueden leer, escribir o suscribir. Los servicios estándar tienen UUIDs de 16 bits asignados por el Bluetooth SIG:
| UUID | Nombre | Acceso | ¿Qué te ofrece? |
|---|---|---|---|
0x180D |
Servicio de Frecuencia Cardíaca | Servicio | Contenedor para los elementos a continuación |
0x2A37 |
Medición de la Frecuencia Cardíaca | Notificar | Frecuencia cardíaca, contacto del sensor, energía expendeda, intervalos RR |
0x2A38 |
Ubicación del sensor corporal | Leer | Pecho, muñeca, dedo, mano, lóbulo auricular, pie |
0x2A39 |
Punto de control de la frecuencia cardíaca | Escribir | 0x01 reinicia el contador de energía gastada |
0x180F |
Servicio de la batería | Servicio | context: Servicio de la batería (Capgo marketing website) |
0x2A19 |
Nivel de batería del cinturón | Nivel de batería | Leer / Notificar |
Un UUID de 16 bits es una forma abreviada de la forma completa de 128 bits. 0000XXXX-0000-1000-8000-00805F9B34FBEl plugin Capgo acepta tanto formas.
Requisitos previos
- Un proyecto Capacitor de 8 (cualquier framework: Angular, React, Vue, Svelte o TypeScript puro)
- Un teléfono físico de iPhone o Android. Los simuladores y emuladores no tienen un radio Bluetooth usable
- Un cinturón de ritmo cardíaco BLE como Polar H10, Garmin HRM-Pro o Wahoo TICKR. Humedezca los electrodos y úselo, muchos cinturones permanecen dormidos sin contacto con la piel
Si está empezando desde cero:
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
Instale el plugin de Bluetooth Low Energy
bun add @capgo/capacitor-bluetooth-low-energy
bunx cap sync
Configuración de iOS
Agregue las cadenas de uso de Bluetooth a ios/App/App/Info.plisty el bluetooth-central modo de fondo si desea lecturas mientras la pantalla está bloqueada:
<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>
Configuración de Android
El plugin combina las permisos que necesita en su archivo de manifest: BLUETOOTH_SCAN (con neverForLocation), BLUETOOTH_CONNECT y BLUETOOTH_ADVERTISE para Android 12+, más el legado BLUETOOTH, BLUETOOTH_ADMIN y permisos de ubicación para Android 11 y versiones inferiores. En Android 12+ todavía necesitas solicitarlos en tiempo de ejecución, requestPermissions() y
También declara 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>
Desencriptar la Medición de Frecuencia Cardíaca
Haga esto primero, porque es donde la mayoría de las implementaciones van mal. El valor característico es un array de bytes:
| y | y |
|---|---|
| 0 | y |
| 1 o 1-2 | Frecuencia cardíaca: UINT8 si el bit de flag 0 es 0, UINT16 little-endian si 1 |
| siguientes 2 (opcional) | Energía expendeda en kJ, presente si el bit de la bandera 3 es 1 |
| descanso (opcional) | Intervalos RR, UINT16 little-endian, unidad 1/1024 s, presente si el bit de flag 4 es 1 |
Los bits 1 y 2 describen el contacto con la piel: el bit 2 indica si el sensor admite la detección de contacto, el bit 1 indica si se detecta contacto.
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 };
}
El true el argumento a getUint16 significa little-endian, ya que la especificación de Bluetooth lo requiere. Code que desplaza el primer byte a la izquierda (byte1 << 8 | byte2Un rápido test de cordura:
Un rápido test de cordura:
parseHeartRate([0x16, 0x48, 0x00, 0x04]);
// flags 0x16: 8-bit value, contact supported and detected, RR present
// => { bpm: 72, contactDetected: true, rrIntervalsMs: [1000] }
Conéctese al sensor
El siguiente módulo escanea dispositivos que anuncian el Servicio de Frecuencia Cardíaca, se conecta al primero y transmite lecturas procesadas a una función de llamada.
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 = [];
}
¿Por qué se escribe de esta manera:
- Filtrar el escaneo por servicio.
startScan({ services: ['180D'] })solamente devuelve sensores de frecuencia cardíaca, por lo que no coincidirá con nombres de dispositivos, que difieren entre modelos y versiones de firmware. - Registre los oyentes antes de suscribirsede lo contrario, las primeras notificaciones pueden llegar antes de que exista su manejador.
- Detenga el escaneo una vez conectado. El escaneo consume la batería y ralentiza las conexiones en algunos teléfonos Android.
- Los IDs de dispositivo difieren por plataforma: una dirección MAC en Android, un UUID por aplicación en iOS. Almacene lo que obtenga y reutilízelo en el mismo dispositivo solo.
Lea la ubicación del sensor y la batería
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,
};
}
Ambas son opcionales en la especificación, por lo que envuelva las lecturas y maneje valores faltantes.
Construya la pantalla.
Una interfaz mínima con un botón de conexión, el BPM en vivo y una advertencia de contacto:
<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;
}
});
Inicie la escaneo desde un toque del botón, no en la carga de la página. Ambios y Android muestran pestañas de permisos la primera vez, y los usuarios las aceptan más a menudo cuando entienden por qué.
Ejecute en un dispositivo:
bun run build
bunx cap run android
bunx cap run ios
Utilice intervalos RR para HRV
Los intervalos RR son el tiempo entre latidos. Permiten calcular la variabilidad de la frecuencia cardíaca, que las aplicaciones utilizan para puntuaciones de recuperación y estrés. La métrica común es el RMSSD sobre una ventana móvil:
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));
}
Recolecte al menos un minuto de intervalos en reposo, y elimine los valores atípicos (por ejemplo, intervalos que difieren en más del 20 por ciento del anterior) causados por artefactos de movimiento.
Guarde las lecturas en Apple Health o Health Connect
To make readings available to other apps, write them with @capgo/capacitor-healthPromedio sobre una ventana corta en lugar de escribir cada notificación:
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 necesita la capacidad de HealthKit y las cadenas de uso en iOS. Health Connect necesita permisos declarados en el manifiesto y una pantalla de política de privacidad en Android. Nuestro Guía de migración de Google Fit a Health Connect aborda esa configuración.
Keep the monitor running during workouts
- iOS: con
bluetooth-centralenUIBackgroundModes, las notificaciones siguen llegando cuando la aplicación está en segundo plano y la pantalla está bloqueada. iOS puede terminar la conexión bajo presión de memoria, por lo que mantén la lógica de reconexión. - Android: una sesión GATT conectada sigue enviando notificaciones mientras tu proceso vive. Para entrenamientos más largos de unos minutos con la pantalla apagada, Android puede congelar o matar la aplicación. La solución simple es mantener la pantalla encendida durante una sesión activa con
@capgo/capacitor-keep-awake(KeepAwake.keepAwake()yKeepAwake.allowSleep()Para un registro de fondo verdadero, agrega tu propio servicio de fondo conandroid:foregroundServiceType="connectedDevice"y elFOREGROUND_SERVICE_CONNECTED_DEVICEpermiso.
Troubleshooting
No se encontraron dispositivos: la banda no está puesta o los electrodos están secos. Muchas bandas solo permiten una conexión, por lo que desconéctelos de un reloj, un computadora de bicicleta o otra aplicación primero.
requestPermissions se rechazan en Android 11 o inferior: la escaneación de BLE en esas versiones requiere permiso de ubicación y servicios de ubicación. Verifique isLocationEnabled() y llame openLocationSettings().
: no se encuentra nada en Android 12+ pero funciona en 11: el usuario denegó “Dispositivos cercanos”. Envíelos a openAppSettings().
La frecuencia cardíaca es siempre 0: no hay contacto con la piel. Verifique contactDetected.
Valores superiores a 255 bpm o números aleatorios: Ignora el bit de bandera 0 o lee valores de 16 bits en big-endian. Utiliza el parser anterior.
Las notificaciones cesan después de que el teléfono se bloquea en Android.Optimiza la batería. Mantén la pantalla activa durante las sesiones o utilice un servicio de primer plano.
La aplicación no está disponible en algunos dispositivos en Google Play: el de la plugin’ uses-feature Se requiere por defecto. Sobrescribirla como se muestra en la sección de configuración de Android.
Pasos siguientes
El mismo patrón funciona para otros perfiles GATT estándar: Velocidad y Cadencia de Ciclismo ("0x1816Velocidad de Carrera y Cadencia0x1814y Ciclismo de Potencia ("0x1818Velocidad y Cadencia de Correr ( Velocidad y Cadencia de Ciclismo ( para escrituras, solicitudes de MTU y modo periférico. Una vez que la aplicación nativa esté en las tiendas, los arreglos de parsing y los cambios de interfaz de usuario son JavaScript, por lo que puedes enviarlos con Capgo actualizaciones en vivo en lugar de esperar la revisión.