Um einen Herzfrequenzmonitor mit Capacitor zu bauen, verbinden Sie sich mit einem Bluetooth Low Energy-Sensor, der die standardmäßige Herzfrequenz-Dienst (0x180D) anbietet, abonnieren Sie sich für den Herzfrequenz-Messwert (0x2A37Dieses Tutorial zeigt, wie man das erhaltene Signal in Schläge pro Minute umwandelt. @capgo/capacitor-bluetooth-low-energy auf Capacitor 8, einschließlich RR-Intervalle, Sensorkontakt, Wiederverbindung und Speicherung von Lesungen in Apple Health oder Health Connect.
Wie BLE Herzfrequenzsensoren funktionieren
Bluetooth Low Energy teilt Geräte in zwei Rollen auf. Ihr Telefon ist die zentrals: es scannt, verbinden und Daten anfragen. Der Brustgurt ist das peripheral: es bewirbt und Daten bereitstellt.
Ein Peripheral offenbart Dienste, und jeder Dienst enthält Charakteristika, kleine Werte, die Sie lesen, schreiben oder abonnieren können. Standarddienste haben 16-Bit-UUIDs, die vom Bluetooth-SIG zugewiesen wurden:
| UUID | Name | Zugriff | Was es Ihnen bietet |
|---|---|---|---|
0x180D |
Herzfrequenz-Dienst | Dienst | Behälter für die folgenden Elemente |
0x2A37 |
Kontainer für die folgenden Elemente | Notify | Schlagfrequenz, Sensorkontakt, verbrauchte Energie, RR-Intervalle |
0x2A38 |
BPM, Sensorkontakt, Energie verbraucht, RR-Intervalle | Read | Brust, Handgelenk, Finger, Hand, Ohrläppchen, Fuß |
0x2A39 |
Brust, Handgelenk, Finger, Hand, Ohrläppchen, Fuß | Write | 0x01 setzt den Energieverbrauchszähler zurück |
0x180F |
Batterie-Service | Dienstleistung | Batteriestand des Gurtbands |
0x2A19 |
Batteriestand | Lesen / Benachrichtigen | 0 bis 100 Prozent |
Ein 16-Bit-UUID ist eine Abkürzung für die vollständige 128-Bit-Form 0000XXXX-0000-1000-8000-00805F9B34FBDas Capgo-Plugin akzeptiert beide Formen.
Voraussetzungen
- Ein Capacitor-8-App (jeder Framework: Angular, React, Vue, Svelte oder plain TypeScript)
- Ein physisches iPhone oder Android-Smartphone. Simulatoren und Emulatoren haben keinen nutzbaren Bluetooth-Radio
- Ein BLE-Herzfrequenzgurt wie ein Polar H10, Garmin HRM-Pro oder Wahoo TICKR. Feuchte die Elektroden an und trage ihn, viele Gurte bleiben ohne Hautkontakt schlafen
Wenn Sie von vorneherein beginnen:
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
Installieren Sie das Bluetooth Low Energy-Plugin
bun add @capgo/capacitor-bluetooth-low-energy
bunx cap sync
iOS-Konfiguration
Fügen Sie die Bluetooth-Nutzungsstrings zu ios/App/App/Info.plist, und die bluetooth-central Hintergrundmodus, wenn Sie Lesungen während des Ausgeschaltens des Bildschirms wünschen:
<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>
Android-Konfiguration
Das Plugin kombiniert die erforderlichen Berechtigungen in Ihrem Manifest: BLUETOOTH_SCAN (mit neverForLocation), BLUETOOTH_CONNECT und BLUETOOTH_ADVERTISE für Android 12+, zusätzlich den BLUETOOTH, BLUETOOTH_ADMIN und Standortberechtigungen für Android 11 und darunter. Auf Android 12+ müssen Sie sie nochmals zur Laufzeit anfordern. requestPermissions() does.
Es deklariert außerdem android.hardware.bluetooth_le wie erforderlich, was Ihre App auf Google Play für Geräte ohne BLE versteckt. Wenn Herzfrequenz eine optionale Funktion in Ihrer App ist, überschreiben Sie sie 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>
Entschlüsseln Sie die Herzfrequenzmessung
Erledigen Sie diese Aufgabe zuerst, da sie dort, wo die meisten Implementierungen schiefgehen, ist. Der Charakteristikwert ist ein Byte-Array:
| Bytes | Bedeutung |
|---|---|
| 0 | Flaggen |
| 1 oder 1-2 | Herzfrequenz: UINT8, wenn die Flagge 0 ist, UINT16 little-endian, wenn 1 |
| nächste 2 (optional) | Energie aufgewendet in kJ, vorhanden, wenn die Flagge 3 ist |
| ruhe (optional) | RR-Intervalle, 16-Bit-Integer, kleinstes Endiart, Einheit 1/1024 s, anwesend, wenn Flag-Bit 4 1 ist |
Die Flag-Bits 1 und 2 beschreiben die Hautkontakt: Bit 2 sagt aus, ob der Sensor Kontaktdetektion unterstützt, Bit 1 sagt aus, ob Kontakt detektiert wird.
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 };
}
Der true Argument getUint16 bedeutet kleinstes Endiart, da die Bluetooth-Spezifikation dies erfordert. Code , das die ersten Byte links verschiebt (byte1 << 8 | byte2) liest 16-Bit-Werte rückwärts.
Ein schneller Sanity-Test:
parseHeartRate([0x16, 0x48, 0x00, 0x04]);
// flags 0x16: 8-bit value, contact supported and detected, RR present
// => { bpm: 72, contactDetected: true, rrIntervalsMs: [1000] }
Verbinden Sie sich mit dem Sensor
Die folgende Modul scannt nach Geräten, die das Herzfrequenz-Dienst anbieten, verbindet sich mit dem ersten und strömt die analysierten Werte an einen 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 = [];
}
Warum es so geschrieben ist:
- Filtern Sie den Scan nach Dienst.
startScan({ services: ['180D'] })erstellt nur Herzfrequenzsensoren, daher passen Sie nicht auf Gerätenamen ab, die sich zwischen Modellen und Firmware-Versionen unterscheiden. - Registrieren Sie Listener vor der Abonnierung.sonst können die ersten Benachrichtigungen eintreffen, bevor Ihr Handler existiert.
- Beenden Sie die Scannung sobald verbunden. Das Scannen verbraucht die Batterie und verlangsamt die Verbindungen bei einigen Android-Smartphones.
- Die Geräte-IDs unterscheiden sich pro Plattform:eine MAC-Adresse auf Android, eine pro-App-UUID auf iOS. Speichern Sie, was Sie erhalten, und verwenden Sie es nur auf demselben Gerät.
Lesen Sie die Sensorposition und die 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,
};
}
Beide sind optional in der Spezifikation, daher wrappt die Lesen und handeln Sie fehlende Werte.
Bauen Sie die Anzeige:
Ein minimaler UI mit einem Verbindungsbutton, dem lebendigen BPM und einer Kontaktwarnung:
<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;
}
});
Starten Sie das Scannen von einem Tastenknopf, nicht beim Seitenaufruf. Beide iOS und Android zeigen Benutzereinwilligungspflichten zum ersten Mal, und die Benutzer akzeptieren sie häufiger, wenn sie verstehen, warum.
Laufen Sie es auf einem Gerät aus:
bun run build
bunx cap run android
bunx cap run ios
Verwenden Sie RR-Intervalle für HRV
RR-Intervalle sind die Zeit zwischen den Schlägen. Sie ermöglichen Ihnen, die Herzfrequenzvariabilität zu berechnen, die Apps für Erholungs- und Stresswerte verwenden. Die RMSSD über einen rollenden Fenster ist die gängige Metrik:
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));
}
Sammeln Sie mindestens eine Minute von Intervallen im Ruhezustand und streichen Sie Ausreißer (z. B. Intervalle, die sich um mehr als 20 Prozent von dem vorherigen unterscheiden) aus, die durch Bewegungsartefakte verursacht werden.
Speichern Sie die Lesungen in Apple Health oder Health Connect
Um Lesungen anderen Apps zur Verfügung zu stellen, schreiben Sie sie mit @capgo/capacitor-healthVerwenden Sie den Durchschnitt über einen kurzen Zeitraum anstatt jede Benachrichtigung zu schreiben:
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 benötigt die HealthKit-Fähigkeit und die Verwendungssätze auf iOS. Health Connect benötigt die in der Manifestdatei deklarierten Berechtigungen und eine Datenschutzrichtlinien-Seite auf Android. Unsere Google Fit zu Health Connect-Migrationshilfe umfasst die Einrichtung.
Beachten Sie, dass der Monitor während der Übung läuft
- iOS: mit
bluetooth-centralaufUIBackgroundModes, Benachrichtigungen landen weiterhin, wenn die App im Hintergrund läuft und das Display gesperrt ist. iOS kann die Verbindung unter Memory-Druck noch beenden, daher solltest du das Wiederverbindungs-Logik behalten. - Android: Eine verbundene GATT-Sitzung liefert Benachrichtigungen, solange dein Prozess existiert. Für Workouts, die länger als ein paar Minuten dauern und das Display ausgeschaltet ist, kann Android die App einfrieren oder töten. Der einfache Fix ist, das Display während einer aktiven Sitzung mit
@capgo/capacitor-keep-awake(KeepAwake.keepAwake()undKeepAwake.allowSleep():. Für echte Hintergrundaufnahmen füge deinen eigenen Vordergrunddienst mitandroid:foregroundServiceType="connectedDevice"und dieFOREGROUND_SERVICE_CONNECTED_DEVICEZulassung
Troubleshooting
Keine Geräte gefunden: Das Band ist nicht getragen oder die Elektroden sind trocken. Viele Bänder erlauben nur eine Verbindung, daher löse sie von einem Uhr, einem Fahrradcomputer oder einer anderen App zuerst.
requestPermissions returns abgelehnt auf Android 11 oder niedriger: BLE-Scanning auf diesen Versionen benötigt die Standortfreigabe und die Standortdienste. Überprüfen Sie isLocationEnabled() und aufrufen openLocationSettings().
Keine Ergebnisse werden auf Android 12+ gefunden, funktioniert jedoch auf 11Scanning findet nichts auf Android 12+, aber funktioniert auf 11 openAppSettings().
Herzfrequenz ist immer 0Der Herzschlag ist immer 0 contactDetected.
: Keine Hautkontakt. Überprüfen SieSie ignorieren die Flag-Bit 0 oder lesen 16-Bit-Werte big-endian. Verwenden Sie den Parser oben.
Benachrichtigungen stoppen nachdem das Handy auf Android gesperrt wirdBatterieoptimierung. Halten Sie das Bildschirmlicht während der Sitzungen eingeschaltet oder verwenden Sie einen Vordergrunddienst.
Die App ist auf bestimmten Geräten im Google Play Store nicht verfügbar.des Plugins uses-feature für BLE ist es standardmäßig erforderlich. Überprüfen Sie es wie im Abschnitt zur Android-Konfiguration.
Nächste Schritte
Das gleiche Muster funktioniert auch für andere Standard-GATT-Profile: Fahrradgeschwindigkeit und Gang (0x1816Laufgeschwindigkeit und Gang (0x1814Laufgeschwindigkeit und Gang (0x1818Laufgeschwindigkeit und Gang ( Bluetooth Low Energy Plugin-Dokumentation 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 anstatt auf eine Überprüfung warten