Pour construire un moniteur de fréquence cardiaque avec Capacitor, se connecter à un capteur Bluetooth Low Energy qui expose le service de fréquence cardiaque standard (0x180Ds'abonner à la caractéristique de mesure de la fréquence cardiaque (0x2A37et décoder chaque notification en battements par minute. Ce tutoriel le fait avec @capgo/capacitor-bluetooth-low-energy sur Capacitor 8, notamment les intervalles RR, le contact du capteur, la reconnexion et l'enregistrement des lectures dans Apple Health ou Health Connect.
Comment fonctionnent les capteurs de fréquence cardiaque BLE
Bluetooth Low Energy divise les appareils en deux rôles. Votre téléphone est le central : il scanne, se connecte et demande des données. La ceinture de poitrine est le peripheral : il annonce et fournit des données.
Un périphérique expose des services , et chaque service contient des caractéristiquesLes valeurs petites que vous pouvez lire, écrire ou souscrire à. Les services standards ont des UUID de 16 bits attribués par le Bluetooth SIG :
| UUID | Nom | Accès | Ce que cela vous donne |
|---|---|---|---|
0x180D |
Service de Fréquence Cardiaque | Service | Conteneur pour les éléments ci-dessous |
0x2A37 |
Mesure de la Fréquence Cardiaque | Notifier | FRE, contact du capteur, énergie dépensée, intervalles RR |
0x2A38 |
Emplacement du Capteur du Corps | Lire | Poitrine, poignet, doigt, main, lobe de l'oreille, pied |
0x2A39 |
Point de contrôle du rythme cardiaque | Écrire | 0x01 réinitialise le compteur d'énergie dépensée |
0x180F |
Service de la batterie | Service | Niveau de batterie de la ceinture |
0x2A19 |
Niveau de la batterie | Niveau de batterie | Lire / Avertir |
Un UUID de 16 bits est un abréviation du format complet de 128 bits. 0000XXXX-0000-1000-8000-00805F9B34FBLe plugin Capgo accepte les deux formes.
Prerequisites
- Un Capacitor 8 d'applications (n'importe quel framework : Angular, React, Vue, Svelte ou TypeScript pur)
- Un téléphone iPhone ou Android physique. Les simulateurs et les émulateurs n'ont pas de radio Bluetooth utilisable
- Un baudrateur de fréquence cardiaque BLE comme un Polar H10, Garmin HRM-Pro ou Wahoo TICKR. Humidifiez les électrodes et portez-le, beaucoup de baudrateurs restent endormis sans contact cutané
Si vous commencez à zéro :
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
Installez le plugin Bluetooth Low Energy
bun add @capgo/capacitor-bluetooth-low-energy
bunx cap sync
Configuration iOS
Ajoutez les chaînes d'utilisation Bluetooth à ios/App/App/Info.plistet le bluetooth-central mode de fond si vous voulez des lectures pendant que l'écran est verrouillé :
<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>
Configuration Android
Le plugin combine les permissions dont il a besoin dans votre fichier de manifeste. BLUETOOTH_SCAN (avec) neverForLocation), BLUETOOTH_CONNECT et BLUETOOTH_ADVERTISE pour Android 12+, plus les permissions legacy BLUETOOTH, BLUETOOTH_ADMIN et les autorisations de localisation pour Android 11 et inférieur. Sur Android 12+, vous devez toujours les demander en temps réel, ce qui requestPermissions() fait.
Il déclare également android.hardware.bluetooth_le comme requis, ce qui masque votre application sur Google Play pour les appareils sans BLE. Si le rythme cardiaque est une fonctionnalité facultative dans votre application, le remplacez dans 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>
Décodez la mesure du rythme cardiaque
Faites d'abord cette partie, car c'est là que la plupart des implémentations se trompent. La valeur caractéristique est un tableau de bytes :
| Octets | Sens |
|---|---|
| 0 | Drapeaux |
| 1 ou 1-2 | Frequance cardiaque : UINT8 si le bit de flag 0 est 0, UINT16 petit-éndien si 1 |
| 2 prochaines (facultatif) | Énergie dépensée en kJ, présent si le bit de flag 3 est 1 |
| repos (facultatif) | Intervalle RR, UINT16 petit-éndien, unité 1/1024 s, présent si le bit de flag 4 est 1 |
Les bits 1 et 2 décrivent le contact cutané : le bit 2 indique si le capteur supporte la détection du contact, le bit 1 indique si le contact est détecté.
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 };
}
Le true l'argument à getUint16 signifie petit-éndien, car la spécification Bluetooth le requiert. Code qui décale le premier octet vers la gauche (byte1 << 8 | byte2lit lit les valeurs 16-bit à l'envers.
Un test de cohérence rapide :
parseHeartRate([0x16, 0x48, 0x00, 0x04]);
// flags 0x16: 8-bit value, contact supported and detected, RR present
// => { bpm: 72, contactDetected: true, rrIntervalsMs: [1000] }
Se connecter au capteur
Le module suivant scanne les appareils qui annoncent le Service de Taux de Cœur, se connecte au premier et envoie des lectures analysées à 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 = [];
}
Pourquoi il est écrit ainsi :
- Filtrer la recherche par service.
startScan({ services: ['180D'] })ne retourne que les capteurs de rythme cardiaque, vous ne devez donc pas correspondre aux noms d'appareil, qui diffèrent entre modèles et versions de logicière. - Enregistrer les écouteurs avant de vous abonner, sinon les premières notifications peuvent arriver avant que votre gestionnaire existe.
- Arrêter la recherche une fois connecté. La recherche consomme la batterie et ralentit les connexions sur certains téléphones Android.
- Les identifiants des appareils diffèrent par plateforme: une adresse MAC sur Android, un UUID par application sur iOS. Enregistrez ce que vous obtenez et réutilisez-le sur le même appareil uniquement.
Lire la localisation du capteur et la batterie
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,
};
}
Les deux sont optionnels dans la spécification, donc enveloppez les lectures et gérez les valeurs manquantes.
Construire l'écran
A minimal UI with a connect button, the live BPM, and a contact warning:
<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;
}
});
Démarrer l'analyse à partir d'un clic sur le bouton, et non à la charge de la page. Les deux iOS et Android affichent des invites de permission la première fois, et les utilisateurs les acceptent plus souvent lorsqu'ils comprennent pourquoi.
Exécuter-le sur un appareil :
bun run build
bunx cap run android
bunx cap run ios
Utiliser les intervalles RR pour l'HRV
Les intervalles RR sont le temps entre les battements. Ils vous permettent de calculer la variabilité du rythme cardiaque, que les applications utilisent pour les scores de récupération et de stress. La métrique RMSSD sur une fenêtre roulante est la métrique courante :
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));
}
Collectez au moins une minute d'intervalle à l'état de repos, et éliminez les valeurs aberrantes (par exemple, les intervalles qui diffèrent de plus de 20 % de la précédente) causées par des artefacts de mouvement.
Enregistrer les lectures dans Apple Health ou Health Connect
Pour rendre les lectures disponibles pour d'autres applications, écrivez-les avec @capgo/capacitor-healthMoyennez sur une courte fenêtre plutôt que d'écrire chaque notification :
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 nécessite la capacité HealthKit et les chaînes d'utilisation sur iOS. Health Connect nécessite les permissions déclarées dans le manifeste et une page de politique de confidentialité sur Android. Notre Guide de migration de Google Fit vers Health Connect couvre cette configuration.
Keep the monitor running during workouts
- iOS: avec
bluetooth-centralenUIBackgroundModes, les notifications continuent d'arriver lorsque l'application est en arrière-plan et l'écran est verrouillé. iOS peut toujours interrompre la connexion sous pression de mémoire, il faut donc maintenir la logique de reconnexion. - Android: une session GATT connectée continue de livrer des notifications tant que votre processus est en vie. Pour les séances d'entraînement supérieures à quelques minutes avec l'écran éteint, Android peut geler ou tuer l'application. La solution simple est de maintenir l'écran allumé pendant une session active avec
@capgo/capacitor-keep-awake(KeepAwake.keepAwake()etKeepAwake.allowSleep()). Pour un enregistrement de fond réel, ajoutez votre propre service de fond avecandroid:foregroundServiceType="connectedDevice"et leFOREGROUND_SERVICE_CONNECTED_DEVICEpermis.
Troubleshooting
Aucun appareil trouvé: le bandoulier n'est pas porté ou les électrodes sont secs. Beaucoup de bandouliers n'autorisent qu'une seule connexion, il faut donc les déconnecter d'un montre, d'un ordinateur de vélo ou d'une autre application avant.
requestPermissions refusés sur Android 11 ou inférieur: La balise BLE nécessite la permission de localisation et les services de localisation activés. isLocationEnabled() et appelez openLocationSettings().
: aucun appareil trouvé sur Android 12+ mais fonctionne sur 11: l'utilisateur a refusé « Appareils proches ». Envoyez-les à openAppSettings().
Le rythme cardiaque est toujours 0: pas de contact cutané. Vérifiez contactDetected.
Valeurs supérieures à 255 bpm ou nombres aléatoires: vous ignorez le bit de flag 0 ou vous lisez des valeurs 16-bit big-endian. Utilisez le parseur ci-dessus.
Notifications stop after the phone locks on Android: Optimisation de la batterie. Gardez l'écran allumé pendant les sessions ou utilisez un service en avant-plan.
L'application manque sur certains appareils dans Google Play.: le plugin’s uses-feature Le plugin pour BLE est requis par défaut. Le définissez comme indiqué dans la section de configuration Android.
Étapes suivantes
Le même modèle fonctionne pour d'autres profils GATT standard : Vitesse et Cadence Cyclistes ("0x1816Vitesse de course et Cadence0x1814Vitesse et cadence de course (0x1818Vitesse et cadence de cyclisme ( ) et Puissance de cyclisme ( pour les écritures, les requêtes MTU et le mode périphérique. Une fois l'application native est dans les magasins, les correctifs de parsing et les modifications de l'interface sont JavaScript, vous pouvez donc les expédier avec Capgo mises à jour en direct au lieu d'attendre la révision.