curious life · the journal · essay

The Polar H10, raw.

The best consumer electrocardiograph ever shipped hides behind a fitness app. Here is how to take everything it gives in a browser tab: no SDK, no app, no account.

September 2026 · Curious Life · everything below runs live on the pulse

what the strap gives

The H10 speaks two languages. The first is the standard Bluetooth heart rate service, which every strap on earth speaks: a heart rate and, crucially, the RR intervals, the true gap between consecutive beats measured by the strap itself at the R-wave. The second is Polar's own PMD service (Polar Measurement Data), and this is where the treasure sits: the raw ECG stream at 130 Hz, the actual electrical trace of the heart in microvolts, and a 50 Hz accelerometer feed. Polar publishes the protocol openly in their SDK repository; no agreement is signed and no key is issued. Everything below was built by reading those documents and the bytes themselves.

the door

Web Bluetooth works today in Chrome on desktop and Android. One call opens the chooser; the standard service works with any heart rate strap, and the Polar service is requested alongside for the H10's extra streams. The chooser needs a user gesture; reconnects afterward do not.

const dev = await navigator.bluetooth.requestDevice({
  filters: [{ services: [0x180d] }],   // any heart-rate sensor
  optionalServices: ['fb005c80-02e7-f387-1cad-8acd2d8df0c8'],
});
const gatt = await dev.gatt.connect();
the beats, and whose clock they arrive on

The heart rate characteristic notifies about once a second. A flags byte says how to read it: bit 0 selects 8- or 16-bit heart rate, bit 3 inserts an energy field, and bit 4 says RR intervals follow, each a 16-bit count of 1/1024-second ticks.

const hrs = await gatt.getPrimaryService(0x180d);
const hrc = await hrs.getCharacteristic(0x2a37);
hrc.addEventListener('characteristicvaluechanged', (e) => {
  const dv = e.target.value;
  const flags = dv.getUint8(0);
  let i = 1;
  const hr = flags & 0x01 ? dv.getUint16(i, true) : dv.getUint8(i);
  i += flags & 0x01 ? 2 : 1;
  if (flags & 0x08) i += 2;            // skip energy expended
  if (flags & 0x10) while (i + 1 < dv.byteLength) {
    const rrMs = dv.getUint16(i, true) * 1000 / 1024;
    i += 2;                            // one true beat gap, in ms
  }
});
await hrc.startNotifications();

Here is the trap almost every heart rate page falls into. The radio reports beats in batches: whatever happened since the last packet, delivered on the radio's own one-second timer. Play a sound at packet arrival and you are sonifying the radio, not the heart. At 55 bpm the thumps land near 1.00 s apart while the heart is walking 1.09 s, and when two beats share a packet you get a double-thump the heart never made. The honest fix is a chained clock: each RR extends a running sum, so every beat has a true timestamp on the strap's own timeline, and playback replays those stamps a constant breath behind. Exact spacing, honest lag, and when the queue runs dry there is silence, never an invented beat.

the raw ecg

The PMD service has a control characteristic and a data characteristic. Subscribe to both, then write one command to the control: start measurement, type ECG, with the settings as tag-length-value pairs. The H10 supports exactly one ECG configuration, 130 Hz at 14-bit resolution, so the command is a constant.

const pmd = await gatt.getPrimaryService('fb005c80-02e7-f387-1cad-8acd2d8df0c8');
const ctl = await pmd.getCharacteristic('fb005c81-02e7-f387-1cad-8acd2d8df0c8');
const dat = await pmd.getCharacteristic('fb005c82-02e7-f387-1cad-8acd2d8df0c8');
await ctl.startNotifications();
await dat.startNotifications();
await ctl.writeValueWithResponse(new Uint8Array([
  0x02, 0x00,              // start streaming, type 0 = ECG
  0x00, 0x01, 0x82, 0x00,  // sample rate: 0x0082 = 130 Hz
  0x01, 0x01, 0x0E, 0x00,  // resolution: 0x000E = 14 bit
]));

Data frames then arrive on the data characteristic, roughly one per second: a type byte, an 8-byte device timestamp, a frame type byte, then samples, each a 3-byte signed little-endian microvolt value.

dat.addEventListener('characteristicvaluechanged', (e) => {
  const dv = e.target.value;
  if (dv.getUint8(0) !== 0x00) return; // 0x00 = ECG frame
  const n = Math.floor((dv.byteLength - 10) / 3);
  for (let k = 0; k < n; k++) {
    const off = 10 + k * 3;
    let uv = dv.getUint8(off) | (dv.getUint8(off + 1) << 8) |
             (dv.getUint8(off + 2) << 16);
    if (uv & 0x800000) uv -= 0x1000000; // sign-extend: microvolts
    // sample k: uv µV, exactly 1/130 s after the one before
  }
});

The same batching truth applies: frames arrive in one-second lumps, but within a frame the samples are exactly 1/130 s apart. Chain them on a running sample clock and you have a laboratory-grade trace; timestamp them at arrival and you have a seismograph of your Bluetooth stack. The accelerometer starts the same way, type 0x02, with 50 Hz, 16-bit, 8 G as its settings, and gives you posture and motion to judge the trace by.

finding the r

With the raw trace in hand you can beat the batches entirely: detect the R-peaks yourself and the kick is near-live with true phase. The classic recipe, Pan and Tompkins, still wins: square the sample-to-sample derivative, integrate over about 100 ms, and track two running estimates, one for signal peaks and one for noise peaks, each updated by an eighth on every new peak. The decision bar hangs between them. That matters because R-amplitude breathes: it swells and shrinks 20 to 30 percent with respiration, and any fixed threshold drops the small half of each breath. A 360 ms refractory window steps over the T-wave, which arrives 250 to 400 ms after the R and fools shorter windows into double-counting.

And because the strap also reports its own RR count, the detector can be audited: if its count of beats per five seconds disagrees with the strap's by more than 30 percent, the detector is disqualified and the replayed RR stream takes over on the same constant lag. The instrument checks itself against the instrument.

Two more signals hide in what you already have. The sequence of R-peak amplitudes is a respiration trace, ECG-derived respiration, breath read from the heart's own electrical shape with no extra sensor. And the RR sequence itself, resampled and passed through a wavelet or Fourier analysis, opens into the heart rate variability spectrum, the slow weather of the autonomic nervous system.

your data is yours

Nothing in any of this requires a server. On the pulse, where all of the above runs live, the session record is kept on the device and exports as plain JSON: every beat stamp, every RR, the raw ECG if it ran. No account, no upload, no API key. The strap measures your heart; the record of it should answer to you. That is not a feature, it is the point: an instrument you can read down to the bytes is an instrument you can trust, and this page exists so you can check ours.

see it live the open instrument the journal
references
  1. Polar Electro. Polar BLE SDK and measurement data specifications. The PMD protocol documents, published openly.
  2. Bluetooth SIG. Heart Rate Service 1.0. The standard profile every strap speaks: flags, RR format, the 1/1024-second tick.
  3. Pan, J., & Tompkins, W. J. (1985). A real-time QRS detection algorithm. IEEE Transactions on Biomedical Engineering, 32(3), 230–236.
  4. MDN. Web Bluetooth API. Browser support and the gesture rules for the device chooser.