Project 01 · Embedded Systems
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.
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.
| 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 |
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.
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.
#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();
}
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.
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.
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"]}))
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.
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();
};
The journey of a single batch from the patient's chest to the clinician's screen:
Samples the AD8232 output 250× at 4 ms intervals — about one second of signal per batch.
Serialises {"ecg":[250]} and sends it over the Wi-Fi WebSocket to ws://<server>:8000/ws/ecg/.
Parses the batch and runs filter_ecg_signal() — the 8-stage DSP pipeline.
The cleaned array fans out to every socket in the group via send_ecg().
Each client appends {"filtered":[250]} to its rolling buffer and redraws the Chart.js trace.
| Direction | Shape | Payload |
|---|---|---|
| ESP32 → server | {"ecg":[…]} | 250 raw ADC ints (0–4095) |
| server → browser | {"filtered":[…]} | 250 cleaned ADC ints (0–4095) |
The build is a working proof of concept covering the full acquisition → processing → visualisation loop. Hardening it toward production would mean:
DEBUG = False, restrict ALLOWED_HOSTS, load SECRET_KEY from envwss:// / HTTPS) — ECG is sensitive dataecg_<id>) instead of one shared stream