Saltare al contenuto principale

Come costruire un monitor del battito cardiaco con Capacitor

Costruisci un monitor del battito cardiaco con Capacitor e Bluetooth Low Energy: scansione dei sensori 0x180D, analisi BPM e intervalli RR, e salvataggio delle letture per la salute.

Crediti dell'articolo

Martin Donadieu

Autore

Valeria

Recensore

Jordan

Editore

How to Build a Heart Rate Monitor with Capacitor

Per costruire un monitor di frequenza cardiaca con Capacitor, connettersi a un sensore Bluetooth Low Energy che espone il servizio di frequenza cardiaca standard ("0x180De sottoscrivere il caratteristico di misurazione della frequenza cardiaca (0x2A37Decodificare ogni notifica in battiti per minuto. Questo tutorial lo fa. @capgo/capacitor-bluetooth-low-energy su Capacitor 8, inclusi intervalli RR, contatto del sensore, riconnessione e salvataggio delle letture in Apple Health o Health Connect.

Come funziona un sensore di frequenza cardiaca BLE

Bluetooth Low Energy divide i dispositivi in due ruoli. Il tuo telefono è il centralE' un petto che scandaglia, si collega e chiede i dati. Il pettorale è il perifericoAdverte e serve i dati.

Un periferico espone servizi, e ogni servizio contiene caratteristiche, piccoli valori che puoi leggere, scrivere o sottoscrivere. I servizi standard hanno UUID a 16 bit assegnati dal Bluetooth SIG:

UUID Nome Accesso Cosa ti offre
0x180D Servizio di Frequenza Cardiaca Servizio Contenitore per gli elementi sotto
0x2A37 Contenitore per gli elementi sotto Notify BPM, contatto del sensore, energia spesa, intervalli RR
0x2A38 Posizione del Sensore del Corpo Read Petto, polso, dito, mano, lobo auricolare, piede
0x2A39 Petto, polso, dito, mano, lobo auricolare, piede Write 0x01 Scrivi (resetta il contatore dell'energia spesa)
0x180F Servizio batteria Servizio Livello di batteria della cintura
0x2A19 Nivelamento della batteria della cintura Nivelamento della batteria Leggi / Notifica

Un UUID a 16 bit è un abbreviazione della forma completa a 128 bit 0000XXXX-0000-1000-8000-00805F9B34FBIl plugin Capgo accetta entrambe le forme.

Prerequisites

  • Una Capacitor 8 app (qualsiasi framework: Angular, React, Vue, Svelte o TypeScript puro)
  • Un'app __CAPGO_KEEP_0__ 8 (qualsiasi framework: Angular, React, Vue, Svelte o TypeScript puro)
  • Un telefono cellulare fisico iPhone o Android. I simulatori e gli emulatori non hanno un radio Bluetooth utilizzabile.

If sei iniziare da zero:

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

Installa il plugin Bluetooth Low Energy

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

Configurazione iOS

Aggiungi le stringhe di utilizzo del Bluetooth a ios/App/App/Info.plist, e il bluetooth-central modalità di background se desideri lettture mentre lo schermo è bloccato:

<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>

Configurazione Android

Il plugin combina le autorizzazioni che necessita nel tuo manifesto: BLUETOOTH_SCAN (con neverForLocation), BLUETOOTH_CONNECT e BLUETOOTH_ADVERTISE per Android 12+, più le autorizzazioni legacy BLUETOOTH, BLUETOOTH_ADMIN e le autorizzazioni di localizzazione per Android 11 e inferiori. Su Android 12+ devi ancora richiederle in esecuzione, che requestPermissions() fa.

Inoltre dichiara 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>

Decodifica la Misura del Ritmo Cardiaco

Fai questo passo prima, perché è dove la maggior parte delle implementazioni va storta.

Byte(s) Byte(s)
0 Significato
Flag Frequenza cardiaca: UINT8 se il bit di flag 0 è 0, UINT16 little-endian se 1
prossimo 2 (facoltativo) Energia spesa in kJ, presente se il bit di flag 3 è 1
riposo (facoltativo) intervallo RR, UINT16 little-endian, unità 1/1024 s, presente se il bit di flag 4 è 1

Il bit 2 dei flag descrive il contatto cutaneo: il bit 2 dice se il sensore supporta la detezione del contatto, il bit 1 dice se il contatto è rilevato.

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

La true l'argomento a getUint16 significa little-endian, poiché la specifica Bluetooth richiede. Code che sposta il primo byte a sinistra (byte1 << 8 | byte2) legge i valori a 16 bit all'indietro.

Un rapido test di sanità:

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

Connetti al sensore

Il seguente modulo scandisce i dispositivi che pubblicizzano il Servizio Frequenza Cardiaca, si connette al primo e trasmette i dati elaborati 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 = [];
}

Perché è scritto in questo modo:

  • Filtrare la scansione per servizio. startScan({ services: ['180D'] }) non restituisce solo sensori di frequenza cardiaca, quindi non corrisponde ai nomi di dispositivo, che differiscono tra modelli e versioni di firmware.
  • Registra gli ascoltatori prima di sottoscrivere.Altrimenti, le prime notifiche possono arrivare prima che il tuo gestore esista.
  • Interrompi lo scanning una volta collegato. Lo scanning consuma la batteria e rallenta le connessioni su alcuni telefoni Android.
  • Il numero di dispositivo differisce tra piattaforme:un indirizzo MAC su Android, un UUID per-app su iOS. Memorizza ciò che ottieni e ripristinalo sullo stesso dispositivo.

Leggi la posizione del sensore e la batteria.

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

Entrambi sono facoltativi nella specifica, quindi avvolgi le letture e gestisci i valori mancanti.

Costruisci la schermata.

Una UI minimale con un pulsante di connessione, il BPM in tempo reale e un avviso di contatto:

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

Inizia lo scanning da un tocco del pulsante, non al caricamento della pagina. Entrambe le piattaforme mostrano le richieste di autorizzazione la prima volta, e gli utenti le accettano più spesso quando capiscono il motivo.

Avvia su un dispositivo:

bun run build
bunx cap run android
bunx cap run ios

Utilizza gli intervalli RR per l'HRV

Gli intervalli RR sono il tempo tra i battiti. Consentono di calcolare la variabilità della frequenza cardiaca, che le app utilizzano per i punteggi di recupero e stress. Il RMSSD su una finestra rotante è il metro comune:

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

Raccogli almeno un minuto di intervalli a riposo, e elimina gli outlier (ad esempio intervalli che differiscono di più del 20% dal precedente) causati da artefatti di movimento.

Salva le letture in Apple Health o Health Connect

Per rendere le letture disponibili ad altre app, scrivila con @capgo/capacitor-healthMedia su una finestra breve piuttosto che scrivere ogni notifica:

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 richiede la capacità di HealthKit e le stringhe di utilizzo su iOS. Health Connect richiede le autorizzazioni dichiarate nel manifesto e una schermata di politica di privacy su Android. Il nostro Guida di migrazione da Google Fit a Health Connect copre quella configurazione.

Tenere il monitor in esecuzione durante gli allenamenti

  • iOS: con bluetooth-central in UIBackgroundModes, le notifiche continuano ad arrivare quando l'app è in background e lo schermo è bloccato. L'iOS può ancora interrompere la connessione sotto pressione di memoria, quindi mantieni la logica di riavvio.
  • Android: una sessione GATT connessa continua a fornire notifiche mentre il tuo processo vive. Per allenamenti più lunghi di pochi minuti con lo schermo spento, l'Android può congelare o uccidere l'app. La soluzione semplice è mantenere lo schermo acceso durante una sessione attiva con @capgo/capacitor-keep-awake (KeepAwake.keepAwake() e KeepAwake.allowSleep(). Per il registrazione di sfondo reale, aggiungi il tuo servizio di foreground con android:foregroundServiceType="connectedDevice" e il FOREGROUND_SERVICE_CONNECTED_DEVICE permesso.

Troubleshooting

Nessun dispositivo trovato: the strap is not worn or the electrodes are dry. Many straps also allow only one connection, so disconnect them from a watch, a bike computer or another app first.

requestPermissions ritornati negati su Android 11 o inferioreVerifica che le impostazioni di localizzazione siano abilitate e che il servizio di posizione sia acceso. isLocationEnabled() e chiama openLocationSettings().

Scansiona non trova nulla su Android 12+ ma funziona su 11: la scansione trova nulla su Android 12+ ma funziona su 11 openAppSettings().

: l'utente ha negato "Dispositivi vicini". Inviali ail battito cardiaco è sempre 0 contactDetected.

: nessun contatto cutaneo. ControllaStai ignorando il bit di flag 0 o leggendo valori 16-bit in big-endian. Utilizza il parser sopra.

Notifications stop after the phone locks on AndroidLe notifiche smettono di funzionare dopo che il telefono si blocca su Android

L'app non è presente su alcuni dispositivi in Google Play.: il plugin’s uses-feature per il BLE è richiesto di default. Sostituiscilo come mostrato nella sezione di configurazione Android.

Next steps

La stessa sequenza funziona per altri profili GATT standard: Velocità e Cadenza Ciclistica (0x1816Velocità e Cadenza di Corsa (0x1814Velocità e Cadenza Ciclistica (0x1818), Velocità e Cadenza di Corsa ( ), e Potenza Ciclistica ( for writes, MTU requests and peripheral mode. Once the native app is in the stores, parsing fixes and UI changes are JavaScript, so you can ship them with Capgo live updates invece di attendere la revisione.

Aggiornamenti in tempo reale per le app Capacitor

Quando un bug del layer web è attivo, invia la correzione attraverso Capgo invece di attendere giorni per l'approvazione della store. Gli utenti ricevono l'aggiornamento in background mentre le modifiche native rimangono nel normale percorso di revisione.

Sostegno umano da parte di Martin

Avvia subito

Ultimi articoli dal nostro Blog

Capgo offre le migliori informazioni che ti servono per creare un'app mobile davvero professionale.