ESP32 ECG Monitor — Firmware, BLE & Full-Stack Dashboard by Abel PelleESP32 ECG Monitor — Firmware, BLE & Full-Stack Dashboard by Abel Pelle

ESP32 ECG Monitor — Firmware, BLE & Full-Stack Dashboard

Abel  Pelle

Abel Pelle

ESP32-ECG

A from-scratch single-lead ECG heart monitor — an ESP32 reads an AD8232 analog front-end, detects heartbeats on-device, shows a live trace on an OLED, and streams to both a self-hosted web dashboard (over WiFi) and a phone (over Bluetooth LE).
Warning
This is not a medical device. It's a hobby/educational project. The signal, the beat detection, and any number it shows are not diagnostic and must not be used for any medical purpose. Read Safety before attaching electrodes.
Live trace History & HRV Electrode guide
The installable PWA dashboard: real-time waveform + BPM, 24 h history with HRV and time-in-zone, and a built-in electrode-placement guide.

Contents

What it does

Acquires a single-lead ECG (AD8232) on an ADC1 pin; 50 Hz mains hum is removed in firmware with a biquad notch.
Detects beats on-device with a Pan–Tompkins front-end (5-point derivative → square → moving-window integral) and a 0.20 s refractory — robust ~48–180 bpm without double-counting the T-wave.
Scores signal quality twice — on the device (an SNR-based NOISY flag) and again on the server (a good / noisy / poor / no_signal classifier) so motion artifacts and off-body noise never masquerade as a heartbeat.
Shows BPM, a scrolling waveform, signal / WiFi / BLE status, and OTA progress on a 128×32 OLED.
Streams live to the web dashboard via Server-Sent Events, and to a phone via a standard BLE Heart Rate service (plus a custom status characteristic).
Stores history, HRV (RMSSD), and events in SQLite; every raw waveform is kept so any capture can be re-rendered and re-classified later.
Updates wirelessly (ArduinoOTA, password-gated) and pins the server's TLS CA.

How it all connects


Two independent transports share one device: WiFi → server → web dashboard (for history, multi-viewer, anywhere access) and BLE → phone (for direct, local, no-infrastructure viewing). The OLED works standalone with neither.

How it works

Analog front-end. The AD8232 amplifies/filters the ~1 mV ECG and outputs an analog waveform; its lead-off comparators (LO+/LO−) flag a detached electrode.
Sampling (ESP32, dual-core). One FreeRTOS core samples the ADC at a fixed rate and runs a biquad 50 Hz notch + the beat detector; the other core drives the OLED and the network/BLE stacks, so display/WiFi never starve the sampler.
Beat detection. A Pan–Tompkins-style chain (derivative → square → moving-window integral) emphasizes the QRS and kills slow T-waves/baseline wander at the source; a short refractory window yields stable 48–180 bpm. An on-device SNR metric raises a NOISY flag when the trace is junk.
Reporting. Every ~second the device POSTs {bpm, quality, rr_intervals, waveform, …} to /api/ingest over HTTPS (TLS CA pinned), authenticated by a per-device key. In parallel it publishes BPM over BLE.
Server gating. The server re-classifies each window with scale-invariant metrics (QRS kurtosis for spikiness, RR coefficient-of-variation for rhythm) into good/noisy/poor/no_signal. It cross-checks the lead-off flag against the actual waveform (so muscle motion doesn't false-trip "offline") and corroborates BPM against the RR-derived and waveform peak rates (so noise can't invent an impossible rate). Only good windows feed HRV/history.
Dashboards. The web app subscribes to /api/live (SSE) for the real-time trace and reads /api/history, /api/events for the charts. A phone can connect straight to the BLE Heart Rate service with any compatible app.

Hardware

Bill of materials

Core build:
Part Notes ESP32 dev board any common esp32dev (WROOM-32) AD8232 ECG module single-lead analog front-end, 3.5 mm electrode jack SSD1306 OLED, 128×32, I²C SH1106 also works (U8g2 has a constructor) ECG electrode pads + 3-lead snap cable RA / LA / RL
Optional low-noise battery supply (recommended for clean captures): 1S LiPo or 18650, TP4056 charger, AP2112K-3.3 / RT9013 LDO, slide switch, a low-Vf Schottky, and a few caps (1 µF / 10 µF / 220–470 µF bulk). Full notes in docs/hardware/wiring.md, docs/hardware/diy-powerbank.md, and docs/hardware/bench-psu.md.

Pinout

Authoritative table: docs/hardware/wiring.md.
AD8232 → ESP32
ESP32 AD8232 Notes 3V3 3.3V GND GND GPIO34 OUTPUT ECG signal — ADC1, input-only (ADC2 fails when WiFi is on) GPIO32 LO+ lead-off detect (digital in) GPIO33 LO− lead-off detect (digital in) — SDN leave unconnected (module stays enabled)
SSD1306 OLED (I²C) → ESP32 — I²C is remapped, so firmware calls Wire.begin(23, 22):
ESP32 OLED 3V3 VCC GND GND GPIO23 SDA GPIO22 SCL
Electrodes via the 3.5 mm jack — RA = right arm, LA = left arm, RL = right leg (driven reference). The in-app guide shows exact placement:

Schematics

Core wiring Electrode placement
Battery + noise add-ons Clean analog front-end upgrade

My current setup

ESP32 + AD8232 + SSD1306, USB-powered (board's onboard 3.3 V regulator feeds the AD8232 and OLED). No battery yet — the LDO/battery path below is the next hardware step.
Powered from a laptop on battery (or a USB power bank) while electrodes are attached, for mains isolation — never a wall charger (see Safety).
One provisioned device reporting over WiFi → Cloudflare tunnel → a Dockerized Bun server on a VPS, behind a login. Firmware flashes wirelessly over OTA.
50 Hz mains notch in firmware; signal-quality gating on both device and server.

Firmware

PlatformIO project at firmware/monitor/ (Arduino framework, esp32dev).

Dual-core: acquisition/notch/detection on one core, networking + display on the other.
Beat detector (include/ecg.h): derivative → square → moving-window integral, 0.20 s refractory (48–180 bpm). A host unit test (firmware/monitor/test/test_ecg.cpp) exercises T-waves, baseline wander, EMG, mains, and the SNR metric — compile and run on any PC with g++.
BLE GATT: standard Heart Rate service 0x180D / measurement 0x2A37, plus a custom status characteristic (BPM, quality, lead-off, SNR) — coexists with WiFi.
OTA: OTA_PASS=… pio run -e esp32dev_ota -t upload (mDNS heart-monitor.local) after one USB bootstrap flash.
TLS: the server CA is pinned in include/ca_cert.h (setCACert).
secrets.h (WiFi creds, device key, OTA password) is gitignored — only the .example template is committed.

Server

Bun + Hono + bun:sqlite, TypeScript.

Signal classifier (src/signal.ts): scale-invariant gating + lead-off / BPM corroboration (see How it works). Only good feeds HRV/history.
Auth: signed-cookie sessions for the web app and Bearer tokens (POST /api/token) for native apps. Multi-user — a primary admin plus extra accounts via an AUTH_USERS env; passwords stored as salted scrypt hashes.
Storage: SQLite with full waveform retention so any historical beat re-renders.

Frontend

SolidJS + Vite, built as an installable PWA (vite-plugin-pwa). Views: Live (BPM + scrolling trace), History (samples, HRV, click-to-render any stored waveform, time-in-zone), Events, and a Guide (electrode placement).

Security

Device → server auth via a per-device key (SHA-256 hashed at rest); dashboard via scrypt-hashed passwords and signed, HTTP-only session cookies.
TLS CA pinning on the firmware; password-gated OTA.
All real secrets (WiFi password, device key, OTA password, .env, the SQLite DB) are gitignored and never committed — this repo ships only .example templates.

Roadmap & TODO

Done so far: TS migration (Bun/Hono/bun:sqlite + Solid PWA), on-device + server signal-quality gating, Pan-Tompkins detector, TLS CA pinning, password-gated OTA, BLE Heart Rate streaming, multi-user dashboard auth. Full detail in docs/roadmap.md.
Software / server / app
BLE provisioning — set WiFi SSID/pass + device key over BLE into NVS, drop the compiled-in secrets.h.
Device-key management UI — add/revoke devices, per-device hashed keys, rotation (schema already has devices).
Multi-device support — device picker + per-device views (device_id is currently pinned to 1).
HRV analytics — Poincaré plot, RMSSD/SDNN/pNN50 trends, resting-HR trend.
Per-event waveform snapshots in the events feed.
CSRF tokens on login/logout, cookie/security-header audit.
Native Android app (Kotlin/Compose, dual BLE + WiFi transport) — scaffolding started.
CI (GitHub Actions: bun test + firmware host test), SQLite backups, offline alerting, structured logging, CSV/JSON export.
Firmware
Runtime config — mains 50/60 Hz, sample rate, detector thresholds, snrMin tunable via NVS/BLE instead of recompiling.
Robustness — brown-out + watchdog handling, reconnect/backoff audit.
AP-roaming polish — re-scan + pick strongest BSSID on reconnect.
Hardware (when parts arrive)
Clean analog front-end — LDO + RC band-limit + decoupling caps + clip-on ferrites (docs/hardware/ecg-frontend-upgrade.svg).
ADS1115 16-bit ADC — the big SNR win; abstract the firmware sample source behind one interface so it drops in.
Battery power — LiPo/18650 + TP4056 + protection + Schottky for flash-safe USB; battery-sense divider on GPIO35 → battery %.
Enclosure / wearable form factor with lead strain relief.

Repository layout

Path What firmware/monitor/ ESP32 firmware: acquisition, detection, OLED, WiFi ingest, BLE, OTA firmware/bringup/ Minimal bring-up sketches for the sensor/display server/ Bun + Hono + bun:sqlite ingest/API/dashboard server frontend/ SolidJS installable PWA (the dashboard UI) docs/hardware/ Wiring tables + schematics (SVG/PNG), battery & supply guides docs/ Design spec, build plan, roadmap

Safety

This monitor is isolated from mains only when it runs on battery. Do not have electrodes attached to your body while the device is plugged into any USB/charger that traces back to mains earth — that creates a leakage-current path through you. Charge with electrodes detached, and capture on battery (or a laptop on its own battery).
Again: this is not a medical device and provides no diagnosis.

License

Like this project

Posted Aug 7, 2026

Built a full-stack ESP32 ECG monitor with on-device beat detection, OLED, BLE/Wi-Fi streaming, a Bun API, SQLite history, and a SolidJS PWA.