Saltar al contenido principal

Cómo construir un monitoreo de ritmo cardíaco con Capacitor

Construya un monitoreo de ritmo cardíaco con Capacitor y Bluetooth Low Energy: escanea sensores 0x180D, analice BPM y intervalos RR, y guarde lecturas en Salud.

Cómo construir un monitoreo de ritmo cardíaco con Capacitor

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-central en UIBackgroundModes, 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() y KeepAwake.allowSleep()Para un registro de fondo verdadero, agrega tu propio servicio de fondo con android:foregroundServiceType="connectedDevice" y el FOREGROUND_SERVICE_CONNECTED_DEVICE permiso.

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.

Actualizaciones en vivo para aplicaciones Capacitor

Cuando un error de capa web está en vivo, envía la corrección a través de Capgo en lugar de esperar días para la aprobación de la tienda. Los usuarios obtienen la actualización en segundo plano mientras que los cambios nativos siguen en el camino de revisión normal.

Últimas noticias de nuestro Blog

Capgo te da las mejores perspectivas que necesitas para crear una aplicación móvil verdaderamente profesional.