Zum Hauptinhalt springen

So baust du einen Herzfrequenzmesser mit Capacitor

Erstelle einen Herzfrequenzmonitor mit Capacitor und Bluetooth Low Energy: Suche nach 0x180D-Sensoren, lies BPM- und RR-Intervalle aus und speichere die Messwerte in Health.

Capacitor-App, die die Herzfrequenz von einem Bluetooth-Brustgurt in Echtzeit anzeigt

Um einen Herzfrequenzmonitor mit Capacitor zu erstellen, verbinde dich mit einem Bluetooth Low Energy-Sensor, der den Standard Heart Rate Service (0x180D) bereitstellt, abonniere das Heart Rate Measurement-Feature (0x2A37) und dekodiere jede Benachrichtigung in Schläge pro Minute. Dieses Tutorial macht genau das mit @capgo/capacitor-bluetooth-low-energy auf Capacitor 8, einschließlich RR-Intervalle, Sensor-Kontakt, Reconnection und dem Speichern der Messwerte in Apple Health oder Health Connect.

So funktionieren BLE-Herzfrequenzsensoren

Bluetooth Low Energy unterteilt Geräte in zwei Rollen. Dein Telefon ist das zentrale Gerät: Es scannt, verbindet sich und fordert Daten an. Der Brustgurt ist das periphere Gerät: Es bewirbt und bereitstellt Daten.

Ein Peripheriegerät stellt Servicesbereit, und jeder Service enthält Characteristics, kleine Werte, die du lesen, schreiben oder abonnieren kannst. Standard-Services haben 16-Bit-UUIDs, die vom Bluetooth SIG zugewiesen werden:

UUID Name Auskunft Was es dir liefert
0x180D Heart-Rate-Service Service Container für die folgenden Elemente
0x2A37 Herzfrequenzmessung Notify BPM, Sensorkontakt, verbrauchte Energie, RR-Intervalle
0x2A38 Position des Körpersensors Lies Brust, Handgelenk, Finger, Hand, Ohrläppchen, Fuß
0x2A39 Herzfrequenz-Kontrollpunkt Schreiben 0x01 setzt den Zähler für den Energieverbrauch zurück
0x180F Batterie-Service Service Akkustand des Armbands
0x2A19 Batteriestand Lesen / Benachrichtigen 0 bis 100 Prozent

Eine 16-Bit-UUID ist die Kurzform der vollständigen 128-Bit-Form 0000XXXX-0000-1000-8000-00805F9B34FB. Das Capgo-Plugin akzeptiert beide Formate.

Voraussetzungen

  • Eine Capacitor 8-App (beliebiges Framework: Angular, React, Vue, Svelte oder reines TypeScript)
  • Ein physisches iPhone oder Android-Gerät. Simulatoren und Emulatoren haben kein nutzbares Bluetooth-Radio
  • Ein BLE-Herzfrequenzband, z. B. Polar H10, Garmin HRM-Pro oder Wahoo TICKR. Feuchte die Elektroden und trage es – viele Bänder bleiben ohne Hautkontakt im Ruhezustand

Wenn du von Grund auf beginnst:

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

Bluetooth Low Energy-Plugin installieren

bun add @capgo/capacitor-bluetooth-low-energy
bunx cap sync

iOS-Konfiguration

Füge die Bluetooth-Nutzungstexte hinzu ios/App/App/Info.plist, und die bluetooth-central Hintergrundmodus, wenn du Messwerte bei gesperrtem Bildschirm erhalten möchtest:

<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 fügt die benötigten Berechtigungen in dein Manifest ein: BLUETOOTH_SCAN (mit neverForLocation), BLUETOOTH_CONNECT und BLUETOOTH_ADVERTISE für Android 12+, sowie die Legacy- BLUETOOTH, BLUETOOTH_ADMIN und Standortberechtigungen für Android 11 und älter. Auf Android 12+ musst du sie zur Laufzeit anfordern, was requestPermissions() übernimmt.

Es deklariert außerdem android.hardware.bluetooth_le wie erforderlich, was deine App auf Google Play für Geräte ohne BLE ausblendet. Wenn die Herzfrequenz in deiner App eine optionale Funktion ist, überschreibe es 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>

Herzfrequenzmessung dekodieren

Mache diesen Teil zuerst, denn hier scheitern die meisten Implementierungen. Der Characteristic Value ist ein Byte-Array:

Byte Bedeutung
0 Flags
1 oder 1-2 Herzfrequenz: UINT8, wenn Flag-Bit 0 = 0, UINT16 little-endian, wenn 1
nächste 2 (optional) Verbrauchte Energie in kJ, vorhanden, wenn Flag-Bit 3 = 1
Rest (optional) RR-Intervalle, UINT16 little-endian, Einheit 1/1024 s, vorhanden, wenn Flag-Bit 4 = 1

Die Flag-Bits 1 und 2 beschreiben den Hautkontakt: Bit 2 gibt an, ob der Sensor die Kontakterkennung unterstützt, Bit 1, ob Kontakt erkannt 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 für getUint16 bedeutet little-endian, wie es die Bluetooth-Spezifikation verlangt. Code verschiebt das erste Byte nach links (byte1 << 8 | byte2) liest 16-Bit-Werte rückwärts.

Ein kurzer Sanity-Check:

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

Mit dem Sensor verbinden

Das folgende Modul scannt nach Geräten, die den Heart Rate Service anbieten, verbindet sich mit dem ersten gefundenen und streamt die geparsten 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 das so geschrieben ist:

  • Scan nach Service filtern. startScan({ services: ['180D'] }) Gibt nur Herzfrequenzsensoren zurück, damit man nicht nach Gerätenamen filtern muss, die je nach Modell und Firmware variieren.
  • Listener registrieren, bevor man abonniert, da die ersten Notifications sonst eintreffen könnten, bevor der Handler bereit ist.
  • Scanning stoppen, sobald verbunden. Scanning verbraucht Akku und verlangsamt auf einigen Android-Smartphones die Verbindungen.
  • Device IDs unterscheiden sich je nach Plattform: eine MAC-Adresse unter Android, eine app-spezifische UUID unter iOS. Speichere, was du bekommst, und verwende es nur auf demselben Gerät wieder.

Sensorposition und Akku lesen

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 in der Spec optional, also die Lesevorgänge einwickeln und fehlende Werte behandeln.

Den Screen bauen

Eine minimale UI mit einem Verbindungsbutton, dem Live-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;
  }
});

Den Scan per Button-Tap starten, nicht beim Page Load. Sowohl iOS als auch Android zeigen beim ersten Mal Permission-Prompts, und Nutzer akzeptieren sie öfter, wenn sie den Grund verstehen.

Auf einem Gerät ausführen:

bun run build
bunx cap run android
bunx cap run ios

RR-Intervalle für HRV nutzen

RR-Intervalle sind die Zeit zwischen den Herzschlägen. Sie ermöglichen die Berechnung der Herzratenvariabilität, die Apps für Erholungs- und Stresswerte nutzen. RMSSD über ein rollierendes 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));
}

Sammle mindestens eine Minute Intervalle in Ruhe und entferne Ausreißer (z. B. Intervalle, die sich um mehr als 20 Prozent vom vorherigen unterscheiden), die durch Bewegungsartefakte verursacht werden.

Messwerte in Apple Health oder Health Connect speichern

Um Messwerte für andere Apps verfügbar zu machen, schreibe sie mit @capgo/capacitor-health. Mittle über ein kurzes Fenster, statt 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(),
  });
}

Für HealthKit sind unter iOS die HealthKit-Capability und Usage-Strings erforderlich. Unter Android benötigt Health Connect im Manifest deklarierte Berechtigungen sowie einen Privacy-Policy-Screen. Unser Migrationsguide für Google Fit zu Health Connect deckt dieses Setup ab.

Monitor während des Workouts aktiv halten

  • iOS: mit bluetooth-central in einem UIBackgroundModeskommen Benachrichtigungen weiterhin an, wenn die App im Hintergrund läuft und der Bildschirm gesperrt ist. iOS kann die Verbindung bei Speicherdruck trotzdem beenden, also behalte die Reconnect-Logik bei.
  • Android: Eine verbundene GATT-Session liefert Benachrichtigungen, solange dein Prozess läuft. Bei Workouts, die länger als ein paar Minuten dauern und bei denen der Bildschirm aus ist, kann Android die App einfrieren oder beenden. Die einfache Lösung ist, den Bildschirm während einer aktiven Session mit @capgo/capacitor-keep-awake (KeepAwake.keepAwake() und KeepAwake.allowSleep()). Für echtes Background-Recording brauchst du einen eigenen Foreground Service mit android:foregroundServiceType="connectedDevice" und FOREGROUND_SERVICE_CONNECTED_DEVICE Berechtigung.

Fehlerbehebung

Keine Geräte gefunden: Das Armband wird nicht getragen oder die Elektroden sind trocken. Viele Armbänder erlauben zudem nur eine Verbindung – trenne es daher zuerst von einer Uhr, einem Fahrradcomputer oder einer anderen App.

requestPermissions gibt auf Android 11 oder niedriger denied zurück: BLE-Scanning auf diesen Versionen benötigt die Standortberechtigung und aktivierte Standortdienste. Prüfe isLocationEnabled() und aufrufen openLocationSettings().

Scanning findet auf Android 12+ nichts, funktioniert aber auf 11: Der Nutzer hat „Nahe Geräte“ abgelehnt. Leite ihn weiter zu openAppSettings().

Herzfrequenz ist immer 0: Kein Hautkontakt. Prüfe contactDetected.

Werte über 255 bpm oder Zufallszahlen: du ignorierst Flag-Bit 0 oder liest 16-Bit-Werte im Big-Endian-Format. Nutze den Parser oben.

Benachrichtigungen stoppen auf Android nach dem Sperren des Handys: Akku-Optimierung. Halte den Bildschirm während der Sessions aktiv oder nutze einen Foreground Service.

Die App fehlt auf einigen Geräten im Google Play Store: des Plugins uses-feature Standardmäßig ist dies für BLE erforderlich. Überschreibe es, wie im Abschnitt zur Android-Konfiguration beschrieben.

Nächste Schritte

Dasselbe Muster funktioniert für andere Standard-GATT-Profile: Cycling Speed and Cadence (0x1816), Laufgeschwindigkeit und Schrittfrequenz (0x1814), und Cycling Power (0x1818). Nur der Parser ändert sich. Siehe den Bluetooth Low Energy Plugin-Dokumentation für Schreibvorgänge, MTU-Anfragen und Peripherie-Modus. Sobald die native App in den Stores ist, sind Parsing-Fixes und UI-Änderungen JavaScript, sodass du sie mit Capgo Live Updates veröffentlichen kannst, anstatt auf die Freigabe zu warten.

Live-Updates für Capacitor-Apps

Wenn ein Bug im Web-Layer live ist, liefere den Fix über Capgo aus, statt Tage auf die App-Store-Freigabe zu warten. Nutzer bekommen das Update im Hintergrund, während native Änderungen im normalen Review-Prozess bleiben.

Neues aus unserem Blog

Capgo gibt dir die besten Insights, um eine wirklich professionelle Mobile App zu erstellen.