Salta al contenuto principale

Come creare un monitor della frequenza cardiaca con Capacitor

Crea un monitor del battito cardiaco con Capacitor e Bluetooth Low Energy: scansiona i sensori 0x180D, analizza BPM e intervalli RR e salva le letture in Health.

App Capacitor che mostra la frequenza cardiaca in tempo reale da un pettorale Bluetooth

Per creare un monitor della frequenza cardiaca con Capacitor, connettiti a un sensore Bluetooth Low Energy che espone il servizio standard Heart Rate Service (0x180D), e sottoscrivi la caratteristica Heart Rate Measurement (0x2A37), e decodifica ogni notifica in battiti al minuto. Questo tutorial lo fa con @capgo/capacitor-bluetooth-low-energy su Capacitor 8, inclusi intervalli RR, contatto del sensore, riconnessione e salvataggio delle letture su Apple Health o Health Connect.

Come funzionano i sensori cardio BLE

Bluetooth Low Energy divide i dispositivi in due ruoli. Il tuo telefono è quello centrale: scansiona, si connette e richiede i dati. La fascia toracica è la periferica: si annuncia e fornisce i dati.

Una periferica 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 Heart Rate Servizio Contenitore per gli elementi sottostanti
0x2A37 Misurazione della frequenza cardiaca Notifica BPM, contatto sensore, energia consumata, intervalli RR
0x2A38 Posizione del sensore corporeo Lettura Petto, polso, dito, mano, lobo dell'orecchio, piede
0x2A39 Heart Rate Control Point Write 0x01 reimposta il contatore di energia spesa
0x180F Servizio Batteria Servizio Livello batteria della fascia
0x2A19 Livello batteria Leggi / Notifica Da 0 a 100 percento

Un UUID a 16 bit è un'abbreviazione della forma completa a 128 bit 0000XXXX-0000-1000-8000-00805F9B34FB. Il plugin Capgo accetta entrambi i formati.

Prerequisiti

  • Un'app Capacitor 8 (qualsiasi framework: Angular, React, Vue, Svelte o TypeScript puro)
  • Un iPhone o un telefono Android fisico. Simulatori ed emulatori non hanno una radio Bluetooth utilizzabile
  • Una fascia cardio BLE come Polar H10, Garmin HRM-Pro o Wahoo TICKR. Inumidisci gli elettrodi e indossala: molte fasce non si attivano senza contatto con la pelle

If you are starting from scratch:

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 Bluetooth a ios/App/App/Info.plist, e il bluetooth-central la modalità in background se vuoi le letture anche con 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 integra i permessi necessari nel tuo manifest: BLUETOOTH_SCAN (con neverForLocation), BLUETOOTH_CONNECT e BLUETOOTH_ADVERTISE per Android 12+ e i permessi legacy BLUETOOTH, BLUETOOTH_ADMIN e di posizione per Android 11 e precedenti. Su Android 12+ devi comunque richiederli a runtime, cosa che requestPermissions() fa.

Dichiara inoltre android.hardware.bluetooth_le come richiesto, nascondendo l'app su Google Play per i dispositivi senza BLE. Se il battito cardiaco è una funzione opzionale nella tua app, sovrascrivila 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 misurazione del battito cardiaco

Inizia da questa parte, perché è qui che la maggior parte delle implementazioni fallisce. Il valore caratteristico è un array di byte:

Byte Significato
0 Flag
1 o 1-2 Frequenza cardiaca: UINT8 se il bit 0 del flag è 0, UINT16 little-endian se 1
2 successivi (opzionale) Energia consumata in kJ, presente se il bit 3 del flag è 1
resto (opzionale) Intervalli RR, UINT16 little-endian, unità 1/1024 s, presenti se il bit 4 del flag è 1

I bit 1 e 2 indicano il contatto con la pelle: il bit 2 specifica se il sensore supporta il contact detection, il bit 1 indica 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 };
}

Il true argomento per getUint16 significa little-endian, come richiesto dalla specifica Bluetooth. Code che sposta il primo byte a sinistra (byte1 << 8 | byte2) legge i valori a 16 bit al contrario.

Un rapido test di sanity:

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

Connettiti al sensore

Il seguente modulo cerca i dispositivi che pubblicizzano il Heart Rate Service, si connette al primo trovato e trasmette le letture parse 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 così:

  • Filtra la scansione per servizio. startScan({ services: ['180D'] }) restituisce solo i sensori di frequenza cardiaca, quindi non devi confrontare i nomi dei dispositivi, che variano tra modelli e versioni del firmware.
  • Registra i listener prima di iscriverti, altrimenti le prime notifiche potrebbero arrivare prima che il tuo handler esista.
  • Interrompi la scansione una volta connesso. La scansione consuma batteria e rallenta le connessioni su alcuni telefoni Android.
  • Gli ID dispositivo variano in base alla piattaforma: un indirizzo MAC su Android, un UUID per app su iOS. Salva il valore ottenuto e riusalo solo sullo stesso dispositivo.

Leggi posizione e batteria del sensore

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 opzionali nella specifica, quindi avvolgi le letture e gestisci i valori mancanti.

Crea la schermata

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

Avvia la scansione da un tap sul pulsante, non al caricamento della pagina. Sia iOS che Android mostrano prompt di autorizzazione al primo utilizzo, e gli utenti li accettano più spesso quando capiscono il motivo.

Eseguilo su un dispositivo:

bun run build
bunx cap run android
bunx cap run ios

Usa gli intervalli RR per l'HRV

Gli intervalli RR sono il tempo tra le battite. Permettono di calcolare la variabilità della frequenza cardiaca, che le app usano per i punteggi di recupero e stress. RMSSD su una finestra mobile è la metrica 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 ed elimina gli outlier (ad esempio intervalli che differiscono di oltre il 20% rispetto al precedente) causati da artefatti di movimento.

Salva le letture su Apple Health o Health Connect

Per rendere le letture disponibili ad altre app, scrivilo con @capgo/capacitor-health. Fai la media su una finestra breve anziché 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 capability HealthKit e le stringhe di utilizzo su iOS. Health Connect richiede i permessi dichiarati nel manifest e una schermata di privacy policy su Android. Il nostro guida alla migrazione da Google Fit a Health Connect copre quella configurazione.

Mantieni il monitor attivo durante gli allenamenti

  • iOS: con bluetooth-central nella UIBackgroundModes, le notifiche continuano ad arrivare quando l'app è in background e lo schermo è bloccato. iOS potrebbe comunque terminare la connessione sotto pressione di memoria, quindi mantieni la logica di riconnessione.
  • Android: una sessione GATT connessa continua a consegnare notifiche finché il processo è attivo. Per allenamenti di pochi minuti con lo schermo spento, Android può mettere in pausa o terminare l'app. La soluzione semplice è mantenere lo schermo acceso durante una sessione attiva con @capgo/capacitor-keep-awake (KeepAwake.keepAwake() e KeepAwake.allowSleep()). Per la registrazione in background vera e propria, aggiungi un tuo foreground service con android:foregroundServiceType="connectedDevice" e il FOREGROUND_SERVICE_CONNECTED_DEVICE permesso.

Risoluzione problemi

Nessun dispositivo trovato: il cinturino non è indossato o gli elettrodi sono asciutti. Molti cinturini consentono solo una connessione alla volta, quindi scollegali prima da un orologio, un computer da bici o un'altra app.

requestPermissions restituisce denied su Android 11 o versioni precedenti: Lo scanning BLE su quelle versioni richiede il permesso di posizione e i servizi di posizione attivi. Verifica isLocationEnabled() e chiama openLocationSettings().

La scansione non trova nulla su Android 12+ ma funziona su 11: l'utente ha negato l'accesso a “Nearby devices”. Reindirizzalo a openAppSettings().

La frequenza cardiaca è sempre 0: nessun contatto con la pelle. Controlla contactDetected.

Valori superiori a 255 bpm o numeri casuali: stai ignorando il bit 0 del flag o stai leggendo valori a 16 bit in big-endian. Usa il parser sopra.

Le notifiche si interrompono quando il telefono si blocca su Android: ottimizzazione batteria. Mantieni lo schermo attivo durante le sessioni o usa un foreground service.

L'app non è disponibile su alcuni dispositivi in Google Play: del plugin uses-feature per BLE è richiesto di default. Sovrascrivilo come mostrato nella sezione di configurazione Android.

Prossimi passi

Lo stesso pattern funziona per altri profili GATT standard: Cycling Speed and Cadence (0x1816, Running Speed and Cadence (0x1814) e Cycling Power (0x1818). Cambia solo il parser. Vedi il Documentazione plugin Bluetooth Low Energy per le scritture, le richieste MTU e la modalità peripheral. Una volta che l'app nativa è sugli store, i fix di parsing e le modifiche alla UI sono JavaScript, quindi puoi rilasciarli con Live updates di Capgo invece di aspettare la revisione.

Live updates per le app Capacitor

Quando un bug nel web-layer è live, rilascia la fix tramite Capgo invece di aspettare giorni l'approvazione degli store. Gli utenti ricevono l'update in background, mentre le modifiche native restano nel normale processo di review.

Ultimi articoli dal blog

Capgo ti offre gli insight migliori che ti servono per creare un'app mobile davvero professionale.