Saltar al contenido principal

Cómo crear un monitor de ritmo cardíaco con Capacitor

Crea un monitor de frecuencia cardíaca con Capacitor y Bluetooth Low Energy: escanea sensores 0x180D, analiza intervalos BPM y RR, y guarda las lecturas en Health.

Capacitor app showing live heart rate from a Bluetooth chest strap

Para construir un monitor de frecuencia cardíaca con Capacitor, conéctate a un sensor Bluetooth Low Energy que exponga el servicio estándar Heart Rate Service (0x180D), suscríbete a la característica Heart Rate Measurement (0x2A37) y decodifica cada notificación en latidos por minuto. Este tutorial lo hace con @capgo/capacitor-bluetooth-low-energy en 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. Tu teléfono es el central: escanea, se conecta y solicita datos. La correa de pecho es el periférico: anuncia y sirve datos.

Un periférico expone servicios, y cada servicio contiene características, valores pequeños que puedes leer, escribir o a los que puedes suscribirte. Los servicios estándar tienen UUID de 16 bits asignados por la Bluetooth SIG:

UUID Nombre Acceso Lo que ofrece
0x180D Servicio de frecuencia cardíaca Servicio Contenedor de los siguientes elementos
0x2A37 Medición de la frecuencia cardíaca Notificar BPM, contacto del sensor, energía gastada, intervalos RR
0x2A38 Ubicación del sensor corporal Consulta la Pecho, muñeca, dedo, mano, lóbulo de la oreja, pie
0x2A39 Punto de control de la frecuencia cardíaca Escribir 0x01 reinicia el contador de energía gastada
0x180F Servicio de batería Servicio Nivel de batería de la correa
0x2A19 Nivel de batería Leer / Notificar 0 a 100 por ciento

Un UUID de 16 bits es la abreviatura de la forma completa de 128 bits 0000XXXX-0000-1000-8000-00805F9B34FB. El plugin Capgo acepta ambas formas.

Requisitos

  • Una app Capacitor 8 (cualquier framework: Angular, React, Vue, Svelte o TypeScript puro)
  • Un iPhone o teléfono Android físico. Los simuladores y emuladores no tienen radio Bluetooth utilizable
  • Una correa de frecuencia cardíaca BLE como Polar H10, Garmin HRM-Pro o Wahoo TICKR. Humedece los electrodos y ponla; muchas correas permanecen en modo de espera sin contacto con la piel

Si estás 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

Instala el plugin Bluetooth Low Energy

bun add @capgo/capacitor-bluetooth-low-energy
bunx cap sync

Configuración de iOS

Añade las cadenas de uso de Bluetooth a ios/App/App/Info.plist, y la bluetooth-central modo en segundo plano si quieres lecturas con la pantalla 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 fusiona los permisos que necesita en tu manifest: BLUETOOTH_SCAN (con neverForLocation), BLUETOOTH_CONNECT y BLUETOOTH_ADVERTISE para Android 12+, además de los permisos legacy BLUETOOTH, BLUETOOTH_ADMIN y de ubicación para Android 11 y anteriores. En Android 12+ aún debes solicitarlos en tiempo de ejecución, lo cual requestPermissions() hace.

También declara android.hardware.bluetooth_le según sea necesario, lo que oculta tu app en Google Play para dispositivos sin BLE. Si la frecuencia cardíaca es una función opcional en tu app, sobrescríbelo en 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>

Decodifica el Heart Rate Measurement

Haz esta parte primero, porque es donde la mayoría de las implementaciones fallan. El valor característico es un array de bytes:

Byte(s) Significado
0 Flags
1 o 1-2 Frecuencia cardíaca: UINT8 si el bit 0 de la flag es 0, UINT16 little-endian si es 1
siguientes 2 (opcional) Energía gastada en kJ, presente si el bit 3 de la flag es 1
resto (opcional) Intervalos RR, UINT16 little-endian, unidad 1/1024 s, presente si el bit 4 de la flag es 1

Los bits 1 y 2 de la bandera describen el contacto con la piel: el bit 2 indica si el sensor admite la detección de contacto, y 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 argumento para getUint16 significa little-endian, como exige la especificación de Bluetooth. Code que desplaza el primer byte a la izquierda (byte1 << 8 | byte2) lee valores de 16 bits al revés.

Una prueba rápida de sanidad:

parseHeartRate([0x16, 0x48, 0x00, 0x04]);
// flags 0x16: 8-bit value, contact supported and detected, RR present
// => { bpm: 72, contactDetected: true, rrIntervalsMs: [1000] }

Conéctate al sensor

El siguiente módulo escanea dispositivos que anuncian el servicio Heart Rate, se conecta al primero y transmite lecturas analizadas a un 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 = [];
}

Por qué está escrito así:

  • Filtrar el escaneo por servicio. startScan({ services: ['180D'] }) solo devuelve sensores de frecuencia cardíaca, por lo que no necesitas coincidir con nombres de dispositivo, que varían entre modelos y versiones de firmware.
  • Registrar listeners antes de suscribirse, de lo contrario, las primeras notificaciones pueden llegar antes de que exista tu handler.
  • Detén el escaneo una vez conectado. El escaneo agota la batería y ralentiza las conexiones en algunos teléfonos Android.
  • Los IDs de dispositivo difieren según la plataforma: una dirección MAC en Android, un UUID por app en iOS. Almacena lo que obtengas y reutilízalo solo en el mismo dispositivo.

Lee la ubicación y la batería del sensor

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,
  };
}

Ambos son opcionales en la especificación, así que envuelve las lecturas y maneja los valores ausentes.

Construye la pantalla

Una UI 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;
  }
});

Inicia el escaneo desde un toque en el botón, no al cargar la página. Tanto iOS como Android muestran prompts de permiso la primera vez, y los usuarios los aceptan con más frecuencia cuando entienden por qué.

Ejecútalo en un dispositivo:

bun run build
bunx cap run android
bunx cap run ios

Usa los intervalos RR para HRV

Los intervalos RR son el tiempo entre latidos. Permiten calcular la variabilidad de la frecuencia cardíaca, que las apps usan para puntuar la recuperación y el estrés. El RMSSD en una ventana móvil es la métrica común:

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));
}

Recoge al menos un minuto de intervalos en reposo y descarta valores atípicos (por ejemplo, intervalos que difieran más de un 20 % del anterior) causados por artefactos de movimiento.

Guarda las lecturas en Apple Health o Health Connect

Para hacer las lecturas disponibles para otras apps, escríbelas con @capgo/capacitor-health. Promedia en 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 requiere la capacidad HealthKit y 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 cubre esa configuración.

Mantén el monitor activo durante los entrenamientos

  • iOS: con bluetooth-central en UIBackgroundModes, las notificaciones siguen llegando cuando la app está en segundo plano y la pantalla está bloqueada. iOS aún puede cerrar la conexión bajo presión de memoria, así que mantén la lógica de reconexión.
  • Android: una sesión GATT conectada sigue entregando notificaciones mientras tu proceso esté vivo. Para entrenamientos de más de unos minutos con la pantalla apagada, Android puede congelar o matar la app. La solución simple es mantener la pantalla encendida durante una sesión activa con @capgo/capacitor-keep-awake (KeepAwake.keepAwake() y KeepAwake.allowSleep()). Para grabación real en segundo plano, añade tu propio servicio en primer plano con android:foregroundServiceType="connectedDevice" y la FOREGROUND_SERVICE_CONNECTED_DEVICE permiso.

Solución de problemas

No se encontraron dispositivos: la correa no está puesta o los electrodos están secos. Muchas correas solo permiten una conexión, así que desconéctalas primero de un reloj, ciclocomputador u otra app.

requestPermissions devuelve denegado en Android 11 o inferiorEl escaneo BLE en esas versiones requiere permisos de ubicación y tener los servicios de ubicación activados. Revisa isLocationEnabled() y llama a openLocationSettings().

El escaneo no encuentra nada en Android 12+ pero funciona en 11: el usuario denegó “Dispositivos cercanos”. Envíalo a openAppSettings().

La frecuencia cardíaca siempre es 0: no hay contacto con la piel. Revisa contactDetected.

Valores superiores a 255 bpm o números aleatorios: estás ignorando el bit de flag 0 o leyendo valores de 16 bits en big-endian. Usa el parser de arriba.

Las notificaciones se detienen al bloquear el teléfono en Android: optimización de batería. Mantén la pantalla activa durante las sesiones o usa un servicio en primer plano.

La app no aparece en algunos dispositivos en Google Play: del plugin uses-feature para BLE es requerido por defecto. Sobrescríbelo como se muestra en la sección de configuración de Android.

Próximos pasos

El mismo patrón funciona para otros perfiles GATT estándar: Cycling Speed and Cadence (0x1816), Velocidad de carrera y cadencia (0x1814), y Cycling Power (0x1818). Solo cambia el parser. Consulta la documentación del plugin de Bluetooth Low Energy para escrituras, solicitudes de MTU y modo periférico. Una vez que la app nativa está en las tiendas, las correcciones de parsing y los cambios de UI son JavaScript, así que puedes publicarlos con actualizaciones Live Update de Capgo en lugar de esperar a la revisión.

Live updates para apps Capacitor

Cuando un bug de la capa web está en producción, publica el fix a través de Capgo en lugar de esperar días a la aprobación de la app store. Los usuarios reciben la actualización en segundo plano mientras los cambios nativos siguen el flujo de revisión habitual.

Lo último de nuestro blog

Capgo te da la información clave que necesitas para crear una app móvil realmente profesional.