# Heart Rate Recipe

Canonical write-up for `heart-rate.html` / `heart-rate.ts`. Stream BPM from any standard Bluetooth Heart Rate sensor on iPhone.

## Preconditions

- **iOS 15.0+** running Safari with the WebBLE Safari Web Extension installed.
- Extension enabled under **Settings → Safari → Extensions → WebBLE → Allow**.
- Per-site permission set to **"Always Allow on Every Website"** (required so Safari forwards BLE-origin messages to the extension without re-prompting).
- `requestDevice` must be called from a user gesture (button click, tap) per the [Web Bluetooth security model](https://webbluetoothcg.github.io/web-bluetooth/#device-discovery).

## Spec citation

- **Bluetooth SIG Heart Rate Service 1.0** — Service UUID `0x180D`, Heart Rate Measurement characteristic UUID `0x2A37`.
- **Flags byte** (byte 0 of the Measurement value):
  - Bit 0 — Heart Rate Value Format: `0` = UINT8 at offset 1, `1` = UINT16 little-endian at offset 1.
  - Bit 1 — Sensor Contact Status (if bit 2 is set).
  - Bit 2 — Sensor Contact Supported.
  - Bit 3 — Energy Expended present (UINT16 LE follows).
  - Bit 4 — RR-Interval present (array of UINT16 LE, units of 1/1024 s).
- W3C Web Bluetooth Community Group spec, §6.4 *Device Discovery*: filters MUST contain `services`, `name`, or `namePrefix`.

## Exact `requestDevice` filter

```js
{ filters: [{ services: ['heart_rate'] }] }
```

`'heart_rate'` is an [assigned name](https://www.bluetooth.com/specifications/assigned-numbers/) that Safari's Web Bluetooth polyfill resolves to UUID `0000180d-0000-1000-8000-00805f9b34fb`. The numeric form `0x180D` is equivalent.

## GATT sequence

1. `navigator.bluetooth.requestDevice({ filters: [{ services: ['heart_rate'] }] })` — user picker.
2. `device.gatt.connect()` — establish GATT link.
3. `server.getPrimaryService('heart_rate')` — resolve `0x180D`.
4. `service.getCharacteristic('heart_rate_measurement')` — resolve `0x2A37`.
5. `char.startNotifications()` — subscribe (writes `0x0001` to the CCCD).
6. Listen for `characteristicvaluechanged` events; parse the flags byte and decode BPM.

## Expected output

Continuous log lines like:

```
70 bpm
71 bpm
70 bpm
```

Most chest straps push 1 Hz; optical wrist sensors push 1–2 Hz. If the sensor reports >255 BPM (e.g. lab equipment) the peripheral sets bit 0 and encodes BPM as UINT16.

## Common errors

| Error (`name`) | Cause | Remediation |
|---|---|---|
| `NotFoundError` | User cancelled the picker, or no advertising peripheral matched the filter. | Wake the sensor (HR straps advertise only when worn and damp). |
| `SecurityError` | Not a user gesture, or origin lacks "Always Allow on Every Website". | Wrap `requestDevice` in a click handler; re-check Safari extension permissions. |
| `NetworkError` | GATT disconnected mid-operation. | Reconnect with `gatt.connect()`; listen for `gattserverdisconnected` on the device. |
| `NotSupportedError` | Peripheral does not expose the Heart Rate Measurement characteristic. | Confirm the device advertises `0x180D` — some fitness bands use proprietary services only. |

## Verbatim HTML snippet

```html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Heart Rate — Web Bluetooth on iPhone</title>
</head>
<body>
  <aside data-agent-notes>
    <strong>Preconditions</strong>
    <ul>
      <li>iOS 15.0+ Safari with the WebBLE extension installed.</li>
      <li>Extension enabled: Settings → Safari → Extensions → WebBLE → Allow.</li>
      <li>Per-site permission: "Always Allow on Every Website".</li>
    </ul>
    <strong>Spec</strong>: Bluetooth SIG Heart Rate Service 1.0 (Service UUID <code>0x180D</code>, Characteristic <code>0x2A37</code>). Flags byte bit 0 selects UINT8 (0) vs UINT16 (1) BPM encoding.
    <p>Canonical write-up: <a href="./heart-rate.md">heart-rate.md</a>.</p>
  </aside>

  <h1>Heart Rate (0x180D / 0x2A37)</h1>
  <button id="start">Pair heart rate sensor</button>
  <pre id="log" aria-live="polite"></pre>

  <script type="module">
    import '@ios-web-bluetooth/core/auto';

    const log = (line) => {
      const pre = document.getElementById('log');
      pre.textContent += line + '\n';
    };

    document.getElementById('start').addEventListener('click', async () => {
      try {
        const device = await navigator.bluetooth.requestDevice({
          filters: [{ services: ['heart_rate'] }]
        });
        const server = await device.gatt.connect();
        const service = await server.getPrimaryService('heart_rate');
        const char = await service.getCharacteristic('heart_rate_measurement');
        await char.startNotifications();
        char.addEventListener('characteristicvaluechanged', (ev) => {
          const v = ev.target.value;
          const bpm = v.getUint8(0) & 0x01 ? v.getUint16(1, true) : v.getUint8(1);
          log(`${bpm} bpm`);
        });
        log(`connected: ${device.name ?? device.id}`);
      } catch (err) {
        log(`error: ${err.name}: ${err.message}`);
      }
    });
  </script>
</body>
</html>
```

## Canonical JS snippet (parity with `docs-src/recipes.md`)

```js
const device = await navigator.bluetooth.requestDevice({
  filters: [{ services: ['heart_rate'] }]
});
const server = await device.gatt.connect();
const service = await server.getPrimaryService('heart_rate');
const char = await service.getCharacteristic('heart_rate_measurement');
await char.startNotifications();
char.addEventListener('characteristicvaluechanged', (ev) => {
  const v = ev.target.value;
  const bpm = v.getUint8(0) & 0x01 ? v.getUint16(1, true) : v.getUint8(1);
  console.log(`${bpm} bpm`);
});
```

**Spec:** Bluetooth SIG Heart Rate Service 1.0.
