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-centralnellaUIBackgroundModes, 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()eKeepAwake.allowSleep()). Per la registrazione in background vera e propria, aggiungi un tuo foreground service conandroid:foregroundServiceType="connectedDevice"e ilFOREGROUND_SERVICE_CONNECTED_DEVICEpermesso.
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.