← Back to projects

Project 01 · Embedded Systems

Wearable ECG Holter Monitor

Wearable single-lead ECG acquisition node streaming real-time heart waveforms over Wi-Fi to a Django/Channels backend, cleaned by an 8-stage DSP pipeline, and rendered live in a browser dashboard.

System Overview

A patient wears an ESP32 + AD8232 node on their torso. The device continuously digitises the single-lead ECG at 250 Hz and streams 250-sample batches over Wi-Fi to a Django/Channels server, which runs an 8-stage DSP filter and fans the cleaned waveform out to every connected browser in real time.

ESP32 + AD8232 Acquisition
Wi-Fi WebSocket
{"ecg":[…]}
Django + Channels Processing
WebSocket
{"filtered":[…]}
Browser Dashboard Visualisation
250 Hz Sample rate
12-bit ADC resolution
8 DSP stages
~1 s Batch latency
1000 Sample buffer
Tier Technology Role
Acquisition ESP32 · AD8232 · ArduinoWebsockets · ArduinoJson Digitise ECG, batch & transmit over Wi-Fi
Processing Django 5 · Channels · Daphne · NumPy · SciPy · PyWavelets Receive, filter, and broadcast each batch
Visualisation Chart.js 4.4.1 · HTML5 / JavaScript Rolling-buffer live waveform in the browser

Hardware & Firmware

Three electrodes (RA, LA, RL reference) feed the AD8232 analog front-end, which amplifies, common-mode-rejects, and band-passes the ECG signal into the ESP32's input range. The AD8232 output drives GPIO36 (ADC1_CH0 — the input-only VP pin), sampled at 12-bit resolution. Wiring is deliberately minimal: only three wires reach the ESP32, since the firmware reads a single analog pin.

electrodes RA LA RL AD8232 analog front-end RA/LA/RL OUTPUT 3.3V GND analog ECG ESP32 GPIO36 3V3 GND ADC1_CH0 · 0–4095
Analog ECG → GPIO36 3.3 V power Ground

The firmware collects 250 samples at 4 ms intervals (~250 Hz), serialises them as {"ecg":[…]}, and sends the JSON over a persistent WebSocket. If the connection drops, it reconnects transparently before the next batch.

websocket_ecg_send_data.ino · ESP32 / C++
#define ECG_PIN 36
#define SAMPLE_COUNT 250
#define SAMPLE_DELAY_MS 4        // ~250 Hz sampling rate

void loop() {
  if (!client.available()) {     // auto-reconnect if dropped
    connectToWebSocket();
    return;
  }
  int ecg[SAMPLE_COUNT];
  for (int i = 0; i < SAMPLE_COUNT; i++) {
    ecg[i] = analogRead(ECG_PIN);   // 12-bit: 0..4095
    delay(SAMPLE_DELAY_MS);
  }
  client.send(serialize(ecg));    // -> {"ecg":[...]}
  client.poll();
}

Signal Processing Pipeline

Every incoming batch passes through filter_ecg_signal() in ecg/filters.py on the server before being broadcast. The pipeline is built on NumPy, SciPy (butter, sosfiltfilt, iirnotch, savgol_filter, welch), and PyWavelets. Any stage that errors out fails gracefully so a single bad batch never breaks the live stream.

  1. 0
    Outlier Clipping Robust clip to median ± 5 MAD — removes gross spikes without being skewed by extreme values
  2. 1
    Baseline Wander Removal Segmented polynomial detrend (long batches) + 0.5 Hz Butterworth high-pass, zero-phase SOS
  3. 2
    Three-Band Filtering Wide 0.5–45 Hz · QRS 8–20 Hz · P/T 0.5–10 Hz — separate Butterworth band-passes per ECG feature
  4. 3
    Adaptive QRS Weighting Sliding energy mask recombines bands: 0.3 × wide + 0.5 × QRS × mask + 0.2 × P/T × (1 − mask)
  5. 4
    Powerline Removal Auto-detects 50/60 Hz via Welch PSD; cascaded iirnotch filters on fundamentals and 2nd/3rd harmonics
  6. 5
    Wavelet Denoising sym6 decomposition with level-dependent soft thresholding — suppresses broadband noise, preserves ECG morphology
  7. 6
    Adaptive Smoothing SNR-based: noisy → Savitzky–Golay (~40 ms); moderate → 35 Hz low-pass; clean → pass-through
  8. 7
    R-Peak-Preserving Outlier Replace Rolling median/MAD replaces residual outliers; detected R-peak regions are explicitly protected from flattening
  9. 8
    Re-centre & Clip Centre on 2048 (ADC midpoint), clip to integer range 0–4095 — matches the dashboard's fixed y-axis

Backend — Django + Channels

Standard Django's request/response cycle cannot hold a long-lived bidirectional connection open, so the project runs on the ASGI stack: Django Channels adds WebSocket support and Daphne serves the application. Both the ESP32 device and every dashboard browser connect to the same ws/ecg/ endpoint and are placed into a single Channels group (ecg_group), letting one producer fan out to many viewers. The in-memory channel layer is used for development; a Redis layer is required for multi-process deployment.

ecg/consumers.py · Python / Channels
class ECGConsumer(AsyncWebsocketConsumer):
    async def receive(self, text_data):
        raw = json.loads(text_data).get("ecg", [])
        filtered = filter_ecg_signal(raw)        # 8-stage DSP
        await self.channel_layer.group_send(
            "ecg_group",
            {"type": "send_ecg", "data": filtered},
        )

    async def send_ecg(self, event):
        await self.send(json.dumps({"filtered": event["data"]}))

Frontend Dashboard

A single-page Chart.js 4.4.1 dashboard keeps a rolling 1000-sample buffer (~4 s at 250 Hz). Each incoming batch is appended to the right while the oldest samples drop off the left, producing the scrolling-trace effect of a bedside monitor. The y-axis is pinned to 0–4095 so the waveform scale stays stable, and animation is disabled for instantaneous redraws. Because every tab subscribes to the same ecg_group, the live trace appears identically on every machine that can reach the server — making the monitor genuinely remote.

ecg/templates/ecg.html · JavaScript
const BUFFER_SIZE = 1000;
let ecgBuffer = Array(BUFFER_SIZE).fill(0);
const ws = new WebSocket(`ws://${location.host}/ws/ecg/`);

ws.onmessage = (event) => {
  const { filtered } = JSON.parse(event.data);
  // drop oldest, append newest -> scrolling trace
  ecgBuffer = ecgBuffer.slice(filtered.length).concat(filtered);
  ecgChart.data.datasets[0].data = ecgBuffer;
  ecgChart.update();
};

End-to-End Data Flow

The journey of a single batch from the patient's chest to the clinician's screen:

  1. ESP32 + AD8232

    Samples the AD8232 output 250× at 4 ms intervals — about one second of signal per batch.

  2. ESP32 → Server

    Serialises {"ecg":[250]} and sends it over the Wi-Fi WebSocket to ws://<server>:8000/ws/ecg/.

  3. ECGConsumer.receive()

    Parses the batch and runs filter_ecg_signal() — the 8-stage DSP pipeline.

  4. group_send → ecg_group

    The cleaned array fans out to every socket in the group via send_ecg().

  5. Browser Dashboard

    Each client appends {"filtered":[250]} to its rolling buffer and redraws the Chart.js trace.

DirectionShapePayload
ESP32 → server{"ecg":[…]}250 raw ADC ints (0–4095)
server → browser{"filtered":[…]}250 cleaned ADC ints (0–4095)

Limitations & Future Work

The build is a working proof of concept covering the full acquisition → processing → visualisation loop. Hardening it toward production would mean:

Security

  • Move hard-coded Wi-Fi / server credentials to an un-committed secrets store
  • Set DEBUG = False, restrict ALLOWED_HOSTS, load SECRET_KEY from env
  • Add authentication and TLS (wss:// / HTTPS) — ECG is sensitive data

Scalability

  • Swap the in-memory channel layer for Redis to run multiple workers
  • Per-patient groups (ecg_<id>) instead of one shared stream
  • Smaller batches to cut the ~1 s fill latency

Data & Clinical

  • Persist sessions for playback, export and offline review
  • Heart-rate, R-R interval and arrhythmia detection on existing R-peaks
  • Multi-lead acquisition for broader diagnostic value

Hardware

  • Battery management, charging and low-power sleep between batches
  • Published CAD / STL files for a reproducible enclosure