Objective and use case
What you’ll build: Implement a complete ECG acquisition system with real-time QRS detection using the Pan-Tompkins algorithm, I2C sampling from the ADS1115, and BLE streaming via the nRF52832.
Why it matters / Use cases
- Real-time health monitoring applications that require accurate ECG data transmission over BLE.
- Wearable devices that need to process and analyze cardiac signals on-the-fly.
- Remote patient monitoring systems that utilize I2C sensors for data collection.
- Research projects focusing on heart rate variability and other cardiac metrics.
- Integration with mobile health applications for data visualization and analysis.
Expected outcome
- Latency of less than 100 ms from ECG acquisition to BLE transmission.
- Throughput of at least 50 packets/s for real-time data streaming.
- Accuracy of QRS detection exceeding 95% based on test datasets.
- Power consumption metrics showing less than 50 mA during active transmission.
- Successful I2C communication with the ADS1115 at 16-bit resolution.
Audience: Advanced FPGA developers; Level: Intermediate to Advanced
Architecture/flow: FPGA (ULX3S) as I2C master -> ADS1115 for ECG data acquisition -> QRS detection algorithm -> nRF52832 for BLE streaming.
Advanced Hands‑On Practical: ECG QRS Detection and BLE Streaming on Radiona ULX3S (ECP5‑85F) + AD8232 + ADS1115 + nRF52832
Objective: Implement a complete ECG acquisition, real‑time QRS detection (Pan‑Tompkins‑inspired), I2C sampling from ADS1115, and BLE streaming via an nRF52832. The FPGA (ULX3S) acts as the I2C master, executes the QRS pipeline, and frames UART packets to the BLE radio.
This tutorial is designed for advanced FPGA developers who want a real, end‑to‑end embedded signal processing pipeline that interacts with mixed‑signal front‑ends and wireless peripherals.
Prerequisites
- Host OS:
- Ubuntu 22.04 LTS (or compatible Linux)
- Toolchain (tested versions):
- yosys 0.37
- nextpnr-ecp5 0.6
- Project Trellis (ecppack) v1.3
- openFPGALoader 0.12
- Python 3.10+ with bleak 0.22.0 (for BLE validation)
- nRF Connect SDK (Zephyr) v2.5.0
- west 1.1.0, cmake 3.22+, Ninja 1.10+, GCC 12.x, nrfjprog 10.x
- Experience:
- Verilog (synchronous design, CDC, I2C, UART)
- BLE basics and Zephyr
- Fixed‑point DSP and signal conditioning
Materials (exact models)
- FPGA:
- Radiona ULX3S with Lattice ECP5‑85F (LFE5U‑85F‑8) board.
- Analog front‑end:
- Analog Devices AD8232 ECG front‑end (common “AD8232 ECG Module” breakout, 3.3 V).
- ADC:
- Texas Instruments ADS1115 16‑bit ADC I2C breakout (3.3 V version; default I2C addr 0x48).
- BLE:
- Nordic nRF52832 Dev Kit (PCA10040, “nRF52 DK”) or equivalent nRF52832 board with exposed UART pins.
- Passive and wiring:
- 4‑wire I2C (SCL, SDA, 3V3, GND) between ULX3S and ADS1115.
- 3 wires AD8232 → ADS1115 (OUT → AIN0, 3V3, GND).
- UART: ULX3S TX → nRF52 RX, GND common. Optional ULX3S RX for debug.
- Electrodes and cable for AD8232: RA, LA, RL; follow the AD8232 module labeling.
- Logic analyzer (optional but helpful).
- Breadboard/jumper wires. Pull‑ups for I2C (most ADS1115 boards already include 4.7 kΩ pull‑ups).
Note: All modules must operate at 3.3 V logic.
Setup / Connection
At a high level:
– AD8232 produces an ECG analog signal centered around mid‑supply (≈ 1.5 V) with millivolt variations.
– ADS1115 samples this signal as a single‑ended input (AIN0 vs GND).
– ULX3S runs an I2C master to configure and read ADS1115 at 475 SPS, performs QRS detection, and forwards samples and QRS markers over a UART.
– nRF52832 receives UART data and publishes BLE notifications (Nordic UART Service‑like).
Use this pin plan as a concrete example; you may select any spare digital I/Os on the ULX3S, but keep your LPF constraints consistent. Different ULX3S revisions route headers to different balls; if your board differs, update the LPF accordingly.
Wiring Table
| Peripheral | Signal | Connects to | ULX3S Net (top.v port) | Notes |
|---|---|---|---|---|
| AD8232 | OUT | ADS1115 AIN0 | — | Analog connection |
| AD8232 | 3V3 | 3V3 rail | — | Power 3.3 V |
| AD8232 | GND | GND | — | Common ground |
| ADS1115 | VDD | 3V3 rail | — | 3.3 V only |
| ADS1115 | GND | GND | — | Common ground |
| ADS1115 | SDA | ULX3S I/O | i2c_sda | Open‑drain, pull‑ups present on many modules |
| ADS1115 | SCL | ULX3S I/O | i2c_scl | Open‑drain, pull‑ups present on many modules |
| nRF52832 DK | RXD (UARTE0) | ULX3S TX | uart_tx | 3.3 V UART, no flow control |
| nRF52832 DK | GND | GND | — | Common ground |
| ULX3S | 3V3 | ADS1115 VDD, AD8232 VDD | — | Single power domain |
| ULX3S | GND | ADS1115 GND, AD8232 GND | — | Single ground |
- On the ADS1115 breakout, leave A1/A2/A3 unconnected unless you are experimenting with multi‑channel setups. We will use AIN0 only.
- Most ADS1115 boards have on‑board 4.7 kΩ pull‑ups on SDA/SCL. If your board lacks them, add external pull‑ups to 3.3 V.
- nRF52 default UART pins on the PCA10040 are TXD=P0.06, RXD=P0.08. We will connect ULX3S UART TX to nRF52 RXD (P0.08). No RTS/CTS.
Full Code
Verilog RTL (FPGA): I2C ADS1115 interface, QRS detector, UART framing
This example targets:
– 25 MHz system clock on ULX3S
– I2C fast‑mode 400 kHz
– ADS1115 continuous conversion on AIN0 @ 475 SPS, PGA=±2.048 V
– Pan‑Tompkins‑style pipeline (simplified): DC removal (moving average HP), derivative, squaring, moving window integration (150 ms), adaptive threshold
– UART 1 Mbps, 8N1; packets: [0xEC, 0xGx, sample_lo, sample_hi, qrs_flag] repeated
Files:
– rtl/top.v
– rtl/i2c_master.v
– rtl/ads1115_ctrl.v
– rtl/qrs_detector.v
– rtl/uart_tx.v
For brevity, key modules are embedded below in one code block; split into files as noted.
// File: rtl/top.v
module top(
input wire clk_25m,
inout wire i2c_sda,
inout wire i2c_scl,
output wire uart_tx
// Optional: add an LED output
);
// Clocking
localparam CLK_HZ = 25_000_000;
// I2C master wires
wire i2c_scl_o, i2c_scl_oe;
wire i2c_sda_o, i2c_sda_oe;
wire i2c_scl_i, i2c_sda_i;
// Open-drain IO (external pull-ups)
assign i2c_scl = i2c_scl_oe ? 1'b0 : 1'bz;
assign i2c_sda = i2c_sda_oe ? 1'b0 : 1'bz;
assign i2c_scl_i = i2c_scl;
assign i2c_sda_i = i2c_sda;
// Instantiate I2C controller
wire i2c_busy, i2c_ack;
wire i2c_start, i2c_stop, i2c_read, i2c_write;
wire [7:0] i2c_din;
wire [7:0] i2c_dout;
i2c_master #(
.CLK_HZ(CLK_HZ),
.I2C_HZ(400_000)
) i2c0 (
.clk(clk_25m), .rst(1'b0),
.scl_i(i2c_scl_i), .sda_i(i2c_sda_i),
.scl_o(i2c_scl_o), .scl_oe(i2c_scl_oe),
.sda_o(i2c_sda_o), .sda_oe(i2c_sda_oe),
.start(i2c_start), .stop(i2c_stop),
.write(i2c_write), .read(i2c_read),
.din(i2c_din), .dout(i2c_dout),
.busy(i2c_busy), .ack(i2c_ack)
);
// ADS1115 control for continuous conversion on AIN0
wire sample_valid;
wire signed [15:0] sample_adc;
ads1115_ctrl ads0 (
.clk(clk_25m), .rst(1'b0),
.i2c_busy(i2c_busy), .i2c_ack(i2c_ack),
.i2c_start(i2c_start), .i2c_stop(i2c_stop),
.i2c_write(i2c_write), .i2c_read(i2c_read),
.i2c_din(i2c_din), .i2c_dout(i2c_dout),
.sample_valid(sample_valid),
.sample_out(sample_adc)
);
// QRS detection pipeline
wire qrs_flag;
wire signed [15:0] sample_hp; // high-pass filtered
qrs_detector #(
.CLK_HZ(CLK_HZ),
.FS_HZ(475)
) qrs0 (
.clk(clk_25m), .rst(1'b0),
.sample_in(sample_adc),
.valid_in(sample_valid),
.hp_out(sample_hp),
.qrs_detect(qrs_flag)
);
// UART framing, 1 Mbps
wire uart_busy;
reg [7:0] packet [0:4];
reg [2:0] idx = 3'd0;
reg send = 1'b0;
reg [7:0] tx_data;
reg tx_stb = 1'b0;
uart_tx #(
.CLK_HZ(CLK_HZ), .BAUD(1_000_000)
) uart0 (
.clk(clk_25m), .rst(1'b0),
.data(tx_data), .stb(tx_stb), .busy(uart_busy),
.tx(uart_tx)
);
// Very simple packetizer: send 5-byte record per sample
// [0] 0xEC (sync), [1] 0x71 ('q') low nibble used as stream id,
// [2] sample LSB, [3] sample MSB, [4] qrs_flag (0/1)
always @(posedge clk_25m) begin
tx_stb <= 1'b0;
if (sample_valid && !send) begin
packet[0] <= 8'hEC;
packet[1] <= 8'h71;
packet[2] <= sample_adc[7:0];
packet[3] <= sample_adc[15:8];
packet[4] <= {7'd0, qrs_flag};
send <= 1'b1;
idx <= 3'd0;
end else if (send && !uart_busy) begin
tx_data <= packet[idx];
tx_stb <= 1'b1;
if (idx == 3'd4) begin
send <= 1'b0;
end else begin
idx <= idx + 3'd1;
end
end
end
endmodule
// File: rtl/i2c_master.v (byte-level, repeated-start capable, minimal)
module i2c_master #(parameter CLK_HZ=25_000_000, I2C_HZ=400_000) (
input wire clk, rst,
input wire scl_i, sda_i,
output reg scl_o, scl_oe,
output reg sda_o, sda_oe,
input wire start, stop, write, read,
input wire [7:0] din,
output reg [7:0] dout,
output reg busy, ack
);
// Simple master: generates SCL from clk, drives SDA
// This is a compact controller suitable for a single slave and small transactions.
localparam DIV = (CLK_HZ/(I2C_HZ*4)); // 4 phases per SCL period
reg [$clog2(DIV):0] cnt = 0;
reg [1:0] phase = 0; // 0: SCL low setup, 1: SCL high sample, 2: SCL low hold, 3: SCL high idle
reg [3:0] bitn = 0;
reg [7:0] shreg = 0;
reg rw = 0;
reg [2:0] st = 0;
localparam ST_IDLE=0, ST_START=1, ST_BYTE=2, ST_ACK=3, ST_READ=4, ST_STOP=5;
always @(posedge clk) begin
if (rst) begin
scl_o<=1; sda_o<=1; scl_oe<=0; sda_oe<=0; busy<=0; ack<=0; st<=ST_IDLE; phase<=0; cnt<=0;
end else begin
if (cnt==0) begin
cnt <= DIV;
phase <= phase + 2'd1;
end else cnt <= cnt - 1;
case (st)
ST_IDLE: begin
scl_oe<=0; sda_oe<=0; busy<=0; ack<=0;
if (start) begin
busy<=1; st<=ST_START; phase<=0;
end
end
ST_START: begin
// Drive start during SCL high
if (phase==0) begin sda_oe<=1; sda_o<=0; end // pull SDA low
if (phase==1) begin scl_oe<=1; scl_o<=0; st<=ST_BYTE; bitn<=7; shreg<=din; rw<=read; end
end
ST_BYTE: begin
// Write byte on SDA while toggling SCL
if (phase==0) begin sda_oe<=1; sda_o<=shreg[7]; end
if (phase==1) begin // SCL high: data valid
if (bitn==0) st<=ST_ACK;
else begin shreg <= {shreg[6:0],1'b0}; bitn <= bitn-1; end
end
end
ST_ACK: begin
// Release SDA to sample ACK bit
if (phase==0) begin sda_oe<=0; end
if (phase==1) begin ack <= ~sda_i; end
if (phase==2) begin
if (stop) st<=ST_STOP;
else if (write) begin st<=ST_BYTE; bitn<=7; shreg<=din; end
else if (read) begin st<=ST_READ; bitn<=7; shreg<=0; end
else begin st<=ST_IDLE; scl_oe<=0; sda_oe<=0; busy<=0; end
end
end
ST_READ: begin
// Sample bits from slave
if (phase==0) begin sda_oe<=0; end
if (phase==1) begin shreg <= {shreg[6:0], sda_i}; end
if (phase==2 && bitn==0) begin dout<=shreg; st<=ST_ACK; end
else if (phase==2) bitn<=bitn-1;
end
ST_STOP: begin
// Stop: release SDA high while SCL high
if (phase==0) begin sda_oe<=1; sda_o<=0; end
if (phase==1) begin scl_oe<=0; end
if (phase==2) begin sda_oe<=0; busy<=0; st<=ST_IDLE; end
end
endcase
end
end
endmodule
// File: rtl/ads1115_ctrl.v
module ads1115_ctrl (
input wire clk, rst,
input wire i2c_busy, i2c_ack,
output reg i2c_start, i2c_stop, i2c_write, i2c_read,
output reg [7:0] i2c_din,
input wire [7:0] i2c_dout,
output reg sample_valid,
output reg signed [15:0] sample_out
);
// ADS1115 at addr 0x48. Configure: OS=1, MUX=100 (AIN0), PGA=010 (±2.048V), MODE=0 (continuous),
// DR=475SPS (110), COMP disabled.
localparam ADDR = 7'h48;
localparam REG_CONV = 8'h00;
localparam REG_CFG = 8'h01;
localparam CFG_HI = 8'b1100_0010; // OS=1, MUX=100, PGA=010, MODE=0
localparam CFG_LO = 8'b1110_0011; // DR=110(475), COMP=disabled (default hi bits)
reg [4:0] st = 0;
reg [15:0] conv;
reg [7:0] tmp;
// State:
// 0: write config reg (pointer + two bytes)
// 1+: repeated read conversion register in loop
// Each transaction uses: START, SLA+W, reg ptr, RESTART, SLA+R, read two bytes, STOP
always @(posedge clk) begin
if (rst) begin
{i2c_start,i2c_stop,i2c_write,i2c_read} <= 0;
st<=0; sample_valid<=0;
end else begin
i2c_start<=0; i2c_stop<=0; i2c_write<=0; i2c_read<=0; sample_valid<=0;
case (st)
0: if (!i2c_busy) begin
// START + SLA+W
i2c_start<=1; i2c_din <= {ADDR,1'b0}; i2c_write<=1; st<=1;
end
1: if (!i2c_busy) begin
// pointer to config reg
i2c_din<=REG_CFG; i2c_write<=1; st<=2;
end
2: if (!i2c_busy) begin
// write cfg high byte
i2c_din<=CFG_HI; i2c_write<=1; st<=3;
end
3: if (!i2c_busy) begin
// write cfg low byte
i2c_din<=CFG_LO; i2c_write<=1; st<=4;
end
4: if (!i2c_busy) begin
// STOP
i2c_stop<=1; st<=5;
end
// Read loop
5: if (!i2c_busy) begin
// START + SLA+W
i2c_start<=1; i2c_din<={ADDR,1'b0}; i2c_write<=1; st<=6;
end
6: if (!i2c_busy) begin
// pointer to conversion register
i2c_din<=REG_CONV; i2c_write<=1; st<=7;
end
7: if (!i2c_busy) begin
// RESTART + SLA+R
i2c_start<=1; i2c_din<={ADDR,1'b1}; i2c_write<=1; st<=8;
end
8: if (!i2c_busy) begin
// read MSB
i2c_read<=1; st<=9;
end
9: if (!i2c_busy) begin
tmp <= i2c_dout; i2c_read<=1; st<=10; // read LSB
end
10: if (!i2c_busy) begin
conv <= {tmp, i2c_dout};
i2c_stop<=1; st<=11;
end
11: begin
// Produce sample; ADS returns 16-bit two's complement
sample_out <= conv;
sample_valid <= 1'b1;
st<=5; // next sample
end
endcase
end
end
endmodule
// File: rtl/qrs_detector.v
module qrs_detector #(parameter CLK_HZ=25_000_000, FS_HZ=475) (
input wire clk, rst,
input wire signed [15:0] sample_in,
input wire valid_in,
output reg signed [15:0] hp_out,
output reg qrs_detect
);
// Simplified Pan-Tompkins pipeline:
// 1) DC removal via moving average high-pass (N ≈ FS/0.5Hz ~ 950 taps approximated)
// We use a compact IIR-ish HP: y[n] = y[n-1] + x[n] - x[n-N], with N reduced (e.g., 128) to fit BRAM/LUT.
// For ECG mid-supply removal it's acceptable to use a slower baseline wander removal; we choose N=128.
// 2) Derivative: y[n] = (2x[n] + x[n-1] - x[n-3] - 2x[n-4])/8
// 3) Squaring: z[n] = (deriv)^2 (scaled)
// 4) Moving window integration (MWI): ~150 ms window => M = 0.150*FS ≈ 71 taps
// 5) Adaptive threshold and refractory: detect peaks as QRS.
localparam N_HP = 128;
localparam M_MWI = 71;
// 1) High-pass via sliding window: y = x[n] - avg(x[n-N])
// Implemented as x[n] - (sum/N).
reg signed [23:0] sum_hp = 0;
reg signed [15:0] delay_hp [0:N_HP-1];
integer i;
reg [6:0] wr_ptr = 0;
reg signed [15:0] x_cur;
reg signed [15:0] x_old;
// 2) Derivative (5-point)
reg signed [15:0] dly1=0, dly2=0, dly3=0, dly4=0;
reg signed [17:0] deriv;
// 3) Squaring
reg [31:0] square;
// 4) MWI
reg [31:0] mwi_sum = 0;
reg [31:0] mwi_fifo [0:M_MWI-1];
reg [6:0] mwi_ptr = 0;
reg [31:0] mwi_val;
// 5) Thresholding
reg [31:0] thr = 32'd500_000; // initial threshold (tune per gain)
reg [15:0] refr = 0;
localparam REFRACT = 95; // ~200 ms at 475 Hz
always @(posedge clk) begin
if (rst) begin
sum_hp<=0; wr_ptr<=0; dly1<=0; dly2<=0; dly3<=0; dly4<=0;
mwi_sum<=0; mwi_ptr<=0; thr<=32'd500_000; refr<=0; qrs_detect<=0;
for (i=0;i<N_HP;i=i+1) delay_hp[i]<=0;
for (i=0;i<M_MWI;i=i+1) mwi_fifo[i]<=0;
end else if (valid_in) begin
x_cur <= sample_in;
x_old <= delay_hp[wr_ptr];
sum_hp <= sum_hp + sample_in - delay_hp[wr_ptr];
delay_hp[wr_ptr] <= sample_in;
wr_ptr <= wr_ptr + 7'd1;
// Compute average and high-pass
// avg ≈ sum_hp / N_HP (right shift by 7)
hp_out <= sample_in - (sum_hp >>> 7);
// Derivative on high-pass output
dly4 <= dly3; dly3 <= dly2; dly2 <= dly1; dly1 <= hp_out;
deriv <= ( (dly1<<<1) + dly1 - dly3 - (dly4<<<1) ) >>> 3;
// Square (scaled)
square <= deriv * deriv;
// MWI over ~150 ms
mwi_sum <= mwi_sum + square - mwi_fifo[mwi_ptr];
mwi_fifo[mwi_ptr] <= square;
mwi_ptr <= (mwi_ptr==M_MWI-1) ? 0 : (mwi_ptr+1);
mwi_val <= mwi_sum / M_MWI;
// Adaptive threshold and refractory logic
qrs_detect <= 1'b0;
if (refr != 0) begin
refr <= refr - 1'b1;
end else if (mwi_val > thr) begin
qrs_detect <= 1'b1;
refr <= REFRACT[15:0];
// Update threshold towards current energy
thr <= (thr*3 >> 2) + (mwi_val >> 2); // thr = 0.75*thr + 0.25*mwi_val
end else begin
// Slowly decay threshold
thr <= thr - (thr >> 10);
end
end
end
endmodule
// File: rtl/uart_tx.v
module uart_tx #(parameter CLK_HZ=25_000_000, BAUD=1_000_000) (
input wire clk, rst,
input wire [7:0] data,
input wire stb,
output reg busy,
output reg tx
);
localparam DIV = CLK_HZ / BAUD;
reg [$clog2(DIV):0] cnt=0;
reg [3:0] bitn=0;
reg [9:0] sh=10'b1111111111;
always @(posedge clk) begin
if (rst) begin
tx<=1; busy<=0; cnt<=0; bitn<=0; sh<=10'h3FF;
end else begin
if (!busy && stb) begin
// 8N1 frame: start(0), 8 data bits LSB-first, stop(1)
sh <= {1'b1, data, 1'b0};
busy <= 1; cnt<=DIV; bitn<=0;
end else if (busy) begin
if (cnt==0) begin
tx <= sh[0];
sh <= {1'b1, sh[9:1]};
bitn <= bitn + 1;
cnt <= DIV;
if (bitn==4'd9) busy<=0;
end else cnt <= cnt - 1;
end
end
end
endmodule
Constraints (LPF)
Create constr/ulx3s_ecg.lpf. Adjust SITE pins to your ULX3S revision. The example below illustrates a common setup; verify against your board’s official pinout or an existing ULX3S LPF. The clock pin and GPIOs may differ.
# ULX3S ECP5-85F example constraints (adjust to your board revision!)
# IO standard
IOBUF PORT "i2c_scl" IO_TYPE=LVCMOS33 PULLMODE=UP;
IOBUF PORT "i2c_sda" IO_TYPE=LVCMOS33 PULLMODE=UP;
IOBUF PORT "uart_tx" IO_TYPE=LVCMOS33;
# Clock
LOCATE COMP "clk_25m" SITE "G2"; # Example oscillator pin; confirm
IOBUF PORT "clk_25m" IO_TYPE=LVCMOS33;
# Example I2C pins on a generic header; replace SITE values with your chosen header pins
LOCATE COMP "i2c_scl" SITE "L16";
LOCATE COMP "i2c_sda" SITE "K16";
# UART TX to nRF52 RXD
LOCATE COMP "uart_tx" SITE "J17";
If you already have an official ULX3S LPF for your revision, reuse its clock and header pin mappings and only add the three nets above mapped to your chosen connector pins.
nRF52832 firmware (Zephyr, peripheral UART with notification)
We’ll use the Zephyr “peripheral_uart” sample (Nordic UART Service‑like) and ensure UART pins match the DK default (TXD=P0.06, RXD=P0.08). The sample already reads from UART and notifies over BLE. We will only tweak the buffer size and advertisement name.
- prj.conf (override buffer sizes to handle 1 Mbps bursts)
- boards overlay (optional if you keep default pins)
- Minimal patch to main.c to tag notifications as ECG
Create an app tree based on Zephyr’s sample or use a workspace overlay. Example customizations:
/* File: app/prj.conf (based on zephyr/samples/bluetooth/peripheral_uart) */
CONFIG_BT=y
CONFIG_BT_PERIPHERAL=y
CONFIG_BT_DEVICE_NAME="ECG-QRS-ULX3S"
CONFIG_UART_CONSOLE=n
# Nordic UART Service
CONFIG_BT_NUS=y
# UART for streaming
CONFIG_SERIAL=y
CONFIG_UART_0_NRF_FLOW_CONTROL=n
CONFIG_BT_NUS_UART_BUFFER_SIZE=256
CONFIG_MAIN_STACK_SIZE=2048
CONFIG_SYSTEM_WORKQUEUE_STACK_SIZE=2048
CONFIG_BT_MAX_CONN=1
CONFIG_BT_DEVICE_APPEARANCE=0
/* File: app/boards/nrf52dk_nrf52832.overlay (optional; defaults match) */
/ {
chosen {
zephyr,console = &uart0;
};
};
&uart0 {
compatible = "nordic,nrf-uarte";
status = "okay";
current-speed = <1000000>;
// Default nRF52 DK pins: TXD=P0.06, RXD=P0.08
// No HW flow control
hw-flow-control = "disabled";
};
/* File: app/src/main.c (trimmed: forward UART->NUS notifications) */
#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/drivers/uart.h>
#include <zephyr/bluetooth/bluetooth.h>
#include <zephyr/bluetooth/services/nus.h>
#define UART_DEVICE_NODE DT_CHOSEN(zephyr_console)
static const struct device *const uart_dev = DEVICE_DT_GET(UART_DEVICE_NODE);
static void bt_ready(int err)
{
if (err) return;
bt_le_adv_start(BT_LE_ADV_CONN, NULL, 0, NULL, 0);
}
static void uart_cb(const struct device *dev, struct uart_event *evt, void *user_data)
{
static uint8_t rx[256];
static size_t len = 0;
switch (evt->type) {
case UART_RX_RDY:
memcpy(rx + len, evt->data.rx.buf + evt->data.rx.offset, evt->data.rx.len);
len += evt->data.rx.len;
// Simple packet boundary: look for 5-byte records starting with 0xEC
while (len >= 5) {
if (rx[0] == 0xEC) {
// forward as a single notification
bt_nus_send(NULL, rx, 5);
memmove(rx, rx+5, len-5);
len -= 5;
} else {
// shift until sync
memmove(rx, rx+1, len-1);
len -= 1;
}
}
break;
case UART_RX_DISABLED:
uart_rx_enable(dev, rx, sizeof(rx), 10);
break;
default:
break;
}
}
void main(void)
{
int err;
if (!device_is_ready(uart_dev)) { return; }
uart_callback_set(uart_dev, uart_cb, NULL);
static uint8_t rxbuf[256];
uart_rx_enable(uart_dev, rxbuf, sizeof(rxbuf), 10);
bt_enable(bt_ready);
for (;;) { k_sleep(K_SECONDS(1)); }
}
This firmware receives the 5‑byte frames from the FPGA and forwards each frame as a BLE notification. The payload is small enough to fit default MTU; you can later increase MTU/throughput if you batch multiple frames per notification.
Build, Flash, Run Commands
1) FPGA bitstream (yosys + nextpnr‑ecp5 + ecppack + openFPGALoader)
Project layout:
– rtl/ (Verilog files)
– constr/ulx3s_ecg.lpf
– build/ (generated)
Commands:
# 0) Prepare directories
mkdir -p build
# 1) Synthesis (Yosys 0.37)
yosys -V
yosys -p "
read_verilog rtl/top.v rtl/i2c_master.v rtl/ads1115_ctrl.v rtl/qrs_detector.v rtl/uart_tx.v
synth_ecp5 -top top -json build/ecg.json
"
# 2) Place & route (nextpnr-ecp5 0.6) for ECP5-85F -8, CABGA381
nextpnr-ecp5 --json build/ecg.json \
--lpf constr/ulx3s_ecg.lpf \
--textcfg build/ecg.config \
--85k --package CABGA381 --speed 8
# 3) Bitstream (ecppack from Project Trellis v1.3)
ecppack build/ecg.config build/ecg.bit --compress
# 4) Program (openFPGALoader 0.12)
openFPGALoader --version
openFPGALoader -b ulx3s build/ecg.bit
Notes:
– If openFPGALoader can’t identify the board, try -c ft4232 or use fujprog if you have that configured for ULX3S.
– If your LPF clock pin is wrong, nextpnr timing/pack will likely fail or your design won’t run at all—check that first.
2) nRF52832 firmware (Zephyr, NCS 2.5.0)
Assuming NCS environment is initialized:
# 1) Create an app folder that contains prj.conf, overlay, and main.c as shown
west init -m https://github.com/zephyrproject-rtos/zephyr --mr v3.5.0 ecg-zephyr
cd ecg-zephyr
west update
# Copy your app into ecg-zephyr/app (create the folder)
mkdir -p app/src app/boards
# Place prj.conf, boards/nrf52dk_nrf52832.overlay, src/main.c accordingly
# 2) Build for nRF52 DK (PCA10040)
west build -b nrf52dk_nrf52832 app -p always
# 3) Flash (requires nrfjprog)
west flash
If you use a different nRF52832 board, change the board target and (if needed) adjust overlay for pin assignments. Make sure the UART RX pin in your board matches the wire from ULX3S TX.
Step‑by‑Step Validation
Follow these steps in order, so you can isolate faults quickly.
1) Hardware sanity:
– Power the ULX3S from USB.
– Confirm 3.3 V present on your breadboard rails.
– Verify AD8232 powered and electrode leads connected to your body appropriately (LA to left arm, RA to right arm, RL to right leg). Sit still to reduce motion artifacts.
– ADS1115 powered (VDD=3.3 V), SDA/SCL connected, pull‑ups present (check with multimeter, you should see ≈3.3 V idle on SDA/SCL).
– nRF52 DK powered via USB; keep it close to ULX3S.
2) Firmware on nRF52:
– Build and flash the Zephyr app.
– Open nRF Connect for Mobile or a BLE scanner; you should see a device named “ECG‑QRS‑ULX3S” advertising.
– Don’t connect yet; we’ll bring up the FPGA first.
3) FPGA bitstream:
– Build and program the FPGA bitstream using the commands above.
– As soon as the FPGA boots, it will:
– Configure ADS1115 to AIN0 @ 475 SPS, continuous conversion.
– Start reading samples and performing QRS detection.
– Emit 5‑byte UART frames at 475 Hz.
4) BLE connection:
– On your phone (nRF Connect), connect to “ECG‑QRS‑ULX3S”.
– Discover services; you should see the NUS (Nordic UART Service).
– Subscribe to the RX characteristic to receive notifications.
– You should see a steady stream of 5‑byte packets. Each packet is:
– Byte 0: 0xEC
– Byte 1: 0x71
– Bytes 2‑3: signed little‑endian ADS1115 sample (counts)
– Byte 4: qrs_flag (0x00 or 0x01); 1 indicates QRS detected on that sample.
5) PC‑side BLE validation (optional, Linux):
– Use Python and bleak to log notifications and save to CSV for quick plotting.
# File: tools/ble_logger.py
import asyncio, struct, datetime, csv
from bleak import BleakScanner, BleakClient
# NUS RX characteristic UUID (Nordic default)
NUS_RX_UUID = "6e400003-b5a3-f393-e0a9-e50e24dcca9e"
async def main():
print("Scanning for ECG-QRS-ULX3S...")
devices = await BleakScanner.discover(timeout=5.0)
target = None
for d in devices:
if "ECG-QRS-ULX3S" in (d.name or ""):
target = d
break
if not target:
print("Device not found")
return
print("Connecting to", target)
async with BleakClient(target) as client:
print("Connected, subscribing...")
rows = []
def cb(_, data: bytearray):
if len(data) == 5 and data[0]==0xEC and data[1]==0x71:
s = struct.unpack("<h", data[2:4])[0]
q = data[4]
ts = datetime.datetime.now().isoformat()
rows.append((ts, s, q))
if len(rows) % 100 == 0:
print(f"{len(rows)} samples, last s={s}, q={q}")
await client.start_notify(NUS_RX_UUID, cb)
await asyncio.sleep(10.0)
await client.stop_notify(NUS_RX_UUID)
with open("ecg_log.csv","w",newline="") as f:
w=csv.writer(f)
w.writerow(["timestamp","sample_counts","qrs"])
w.writerows(rows)
print("Saved ecg_log.csv")
asyncio.run(main())
- Run:
python3 tools/ble_logger.py
- Inspect the printed QRS markers and ensure periodic detections (~60–90 BPM typical at rest).
6) Signal plausibility:
– Counts to volts: ADS1115 at ±2.048 V full‑scale has 62.5 µV/LSB. Expect ECG excursions of a few mV around the mid‑supply; after HP filter, typical QRS peak amplitude tens to hundreds of LSB depending on electrode quality and gain chain.
– Detection: Each QRS should trigger a single 0x01 on byte 4 within a tight time window; a train of 0x01 events suggests threshold mis‑tuning or noise.
7) I2C health check (optional):
– Place a logic analyzer on SDA/SCL; confirm 400 kHz traffic and regular reads.
Troubleshooting
- No BLE device seen:
- Confirm the nRF52 firmware flashed without errors.
- LED behavior on the DK usually indicates advertisement; use
nrfjprog --recoverif the device seems stuck, then reflash. -
Ensure the app name is set to “ECG‑QRS‑ULX3S” and advertising started.
-
BLE connects but no notifications:
- Verify you subscribed to the correct NUS RX characteristic.
- Ensure UART speed in overlay is 1,000,000 and the ULX3S UART is wired to nRF52 RX (P0.08).
-
Attach a USB‑TTL adapter to ULX3S TX as a sanity check at 1 Mbps to confirm packets are emitted.
-
I2C NACK / zero samples:
- ADS1115 address may differ (0x48 default; 0x49/0x4A/0x4B if ADDR pin strapped). Update ADDR in ads1115_ctrl.v.
- Missing pull‑ups on SDA/SCL: measure idle lines; should be high (3.3 V).
-
Lower I2C speed to 100 kHz by setting I2C_HZ=100_000 if the bus is long/noisy.
-
ECG saturated or flat:
- Check AD8232 powered correctly; ensure electrodes are firmly attached.
- For noisy environments, try setting ADS1115 PGA to ±1.024 V (CFG_HI: PGA=001) and adjust threshold.
-
If using single‑ended AIN0, DC offset is expected; the high‑pass stage removes it downstream.
-
QRS missed or multiple detections:
- Adjust REFRACT to 80–120 samples (≈170–250 ms) to match heart rate.
- Initialize threshold thr to a lower or higher starting value depending on observed mwi_val magnitude.
-
Increase MWI window to 90–110 samples for more smoothing in very noisy signals (at the cost of latency).
-
Timing / toolchain errors:
- Confirm nextpnr device flags:
--85k --package CABGA381 --speed 8. - Verify your LPF clock pin; wrong SITE for clk_25m will break everything.
- If routing fails, try lowering the UART baud to 460800 and re‑synth.
Improvements
- Throughput:
- Batch multiple 5‑byte frames and send larger notifications to reduce BLE overhead; increase ATT MTU in Zephyr.
-
Use a simple CRC in the frame to detect UART errors.
-
Signal Processing:
- Replace derivative and MWI with a designed digital band‑pass (e.g., 5–15 Hz) using fixed‑point biquads.
- Implement a better baseline wander removal (IIR HP @ 0.5 Hz).
-
Export R‑R intervals and heart rate variability metrics over BLE.
-
Multi‑channel:
-
Use ADS1115 AIN1 for a second lead configuration, multiplex channels in the FPGA, and expand the BLE frame.
-
On‑device timestamps:
-
Add a 32‑bit sample counter or timestamp in the frame for lossless reconstruction and latency measurements.
-
Power:
-
Duty‑cycle BLE advertising/notifications or use connection parameters optimized for low power if battery‑powered.
-
Robustness:
- Detect ADS1115 data ready via comparator/drdy pin (if routed) to avoid fixed polling.
- Add watchdogs on the I2C FSM and an error LED.
Final Checklist
- Materials
- ULX3S (ECP5‑85F) powered and enumerating.
- AD8232 and ADS1115 powered at 3.3 V, electrodes attached properly.
- nRF52832 DK flashed with Zephyr peripheral UART, advertising as “ECG‑QRS‑ULX3S”.
-
Common ground among ULX3S, ADS1115, AD8232, and nRF52.
-
Connections
- AD8232 OUT → ADS1115 AIN0; grounds common.
- ADS1115 SDA/SCL → ULX3S I/Os with pull‑ups; VDD=3.3 V.
-
ULX3S UART TX → nRF52 RXD (P0.08).
-
FPGA build
- Yosys 0.37 synthesis success; nextpnr‑ecp5 0.6 route success; ecppack bitstream generated.
-
LPF pins verified to match your chosen header; clk_25m correctly constrained.
-
Runtime behavior
- BLE device visible and connectable.
- Notifications arriving at ≈475 per second, 5 bytes each, valid sync (0xEC,0x71).
-
QRS flags appear at physiologically plausible intervals (600–1000 ms at rest).
-
Validation
- Optional: BLE log to CSV; compute heart rate from QRS markers.
- Optional: I2C waveform checked at 400 kHz on a logic analyzer.
If all items above check out, you’ve implemented a working ECG → I2C → FPGA QRS → UART → BLE stream on the exact device set: Radiona ULX3S (Lattice ECP5‑85F) + AD8232 + ADS1115 + nRF52832.
Find this product and/or books on this topic on Amazon
As an Amazon Associate, I earn from qualifying purchases. If you buy through this link, you help keep this project running.



