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-centralinUIBackgroundModes, 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()eKeepAwake.allowSleep(). Per il registrazione di sfondo reale, aggiungi il tuo servizio di foreground conandroid:foregroundServiceType="connectedDevice"e ilFOREGROUND_SERVICE_CONNECTED_DEVICEpermesso.
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.