GitHub link for this project:
https://github.com/jackparsons93/OpenBCI_Projects/blob/main/main.c
Building a Zephyr BLE ADS1299 Streamer for EEG Bench Testing
Lately I’ve been spending a lot of time trying to get clean EEG data off an ADS1299 board using a Nordic nRF52 running Zephyr. I wanted something practical: initialize the chip correctly, configure a simple input mode for bench testing, read samples over SPI, and then stream them over Bluetooth so I could inspect them on another device.
That is what this program does. It is a C firmware project built on Zephyr that talks to the ADS1299 over SPI, waits for DRDY, reads raw sample frames, packs the data into a binary payload, and sends it out over BLE using Nordic UART Service.
What I like about this program is that it is focused and practical. It is not trying to do everything at once. It is aimed at one very specific milestone: getting a reliable external-channel bench test working and streaming the data wirelessly.
What the firmware is doing overall
At a high level, this firmware combines a few important pieces:
- ADS1299 command and register definitions
- SPI communication helpers
- GPIO control for reset, start, and DRDY
- BLE advertising and Nordic UART Service
- ADS1299 configuration for an external CH1 test mode
- sample-frame acquisition and binary packet streaming
That is basically the full data path from analog front end to wireless packet output.
Defining the ADS1299 command set
The program starts by defining the core ADS1299 commands and register addresses. I like doing this explicitly because it makes the rest of the code much easier to read. Instead of magic hex values scattered everywhere, the firmware uses named commands like CMD_RESET, CMD_START, and CMD_RDATA.
CMD_WAKEUP 0x02 CMD_STANDBY 0x04 CMD_RESET 0x06 CMD_START 0x08 CMD_STOP 0x0A CMD_RDATAC 0x10 CMD_SDATAC 0x11 CMD_RDATA 0x12
That kind of setup pays off later because the firmware logic reads almost like a checklist of ADS1299 operations instead of a pile of raw bytes. fileciteturn8file0
Binding Zephyr peripherals to the hardware
One of the first things the program does is bind the SPI bus and the GPIO lines through Zephyr’s device tree helpers. That gives the firmware access to the ADS1299 SPI device and the reset, start, and DRDY pins.
static const struct spi_dt_spec ads_spi = SPI_DT_SPEC_GET(DT_NODELABEL(ads1299), SPI_WORD_SET(8) | SPI_TRANSFER_MSB | SPI_MODE_CPHA, 0);static const struct gpio_dt_spec reset_pin = GPIO_DT_SPEC_GET(ZEPHYR_USER_NODE, ads_reset_gpios);static const struct gpio_dt_spec start_pin = GPIO_DT_SPEC_GET(ZEPHYR_USER_NODE, ads_start_gpios);static const struct gpio_dt_spec drdy_pin = GPIO_DT_SPEC_GET(ZEPHYR_USER_NODE, ads_drdy_gpios);
I like this because it keeps the firmware tied cleanly to the board description. Instead of hard-coding pins everywhere, the code pulls them from the device tree. fileciteturn8file0
SPI helpers for talking to the ADS1299
To keep the rest of the program clean, the firmware wraps the low-level SPI transfers into small helper functions. There is one for pure writes and another for transceive operations.
static int ads_spi_write_bytes(const uint8_t *data, size_t len){ ... return spi_write_dt(&ads_spi, &txs);}static int ads_spi_transceive_bytes(const uint8_t *tx_data, uint8_t *rx_data, size_t len){ ... return spi_transceive_dt(&ads_spi, &txs, &rxs);}
That makes the register and command helpers simpler, because they can focus on ADS1299 protocol details rather than rebuilding SPI buffer structures every time. fileciteturn8file0
Sending commands and reading registers
Once the SPI helpers exist, the code builds small ADS1299-specific operations on top of them. These functions send a single command byte, read a register, write a register, or write a whole register block.
static int ads_send_cmd(uint8_t cmd){ int ret = ads_spi_write_bytes(&cmd, 1); k_busy_wait(10); return ret;}
static int ads_read_reg(uint8_t reg, uint8_t *value){ uint8_t tx[3] = { (uint8_t)(CMD_RREG | reg), 0x00, 0x00 }; ...}
I especially like the small delay after sending a command. It shows that this code is written with the actual device timing in mind, not just as abstract C code. fileciteturn8file0
Waiting for DRDY and decoding 24-bit samples
The ADS1299 data path depends on the DRDY signal. This firmware polls DRDY with a timeout so it knows when a new conversion is ready to read.
static int ads_wait_drdy_low_timeout_ms(int timeout_ms){ int loops = timeout_ms * 10; for (int i = 0; i < loops; i++) { int val = gpio_pin_get_dt(&drdy_pin); ... if (val == 0) { return 0; } k_busy_wait(100); } return -ETIMEDOUT;}
Then the firmware has to turn the ADS1299’s 24-bit channel values into proper signed 32-bit integers.
static int32_t ads_decode24(const uint8_t *p){ int32_t v = ((int32_t)p[0] << 16) | ((int32_t)p[1] << 8) | ((int32_t)p[2]); if (v & 0x800000) { v |= (int32_t)0xFF000000; } return v;}
This is one of those small but important details. If the sign extension is wrong, all of the streamed EEG values become misleading. fileciteturn8file0
Reading a full ADS1299 frame
When the firmware wants a sample frame, it issues CMD_RDATA and clocks out the full payload: three status bytes plus eight channels of 24-bit data.
ADS_NUM_CHANNELS 8 ADS_FRAME_BYTES (3 + ADS_NUM_CHANNELS * 3)
static int ads_read_frame_rdata(uint8_t frame[ADS_FRAME_BYTES]){ uint8_t tx[1 + ADS_FRAME_BYTES]; uint8_t rx[1 + ADS_FRAME_BYTES]; tx[0] = CMD_RDATA; ...}
I like that this firmware stays in explicit command-driven mode for reading, because it makes the bench-test behavior easier to reason about while debugging. fileciteturn8file0
Printing the chip ID and key registers
Before streaming anything useful, the program verifies that the ADS1299 is alive and configured as expected. There is a helper that reads the chip ID register and another that dumps a list of important configuration registers.
static int ads_print_id(void){ ... ret = ads_read_reg(REG_ID, &id); ... printk("ADS1299 ID: 0x%02X\n", id); return 0;}
I really like having this in firmware. When I’m bringing up hardware, register dumps are one of the fastest ways to catch mistakes early. fileciteturn8file0
Configuring a simple external CH1 test mode
The most interesting part of the whole program is the channel configuration function. It sets up the ADS1299 specifically for a simple external bench test where channel 1 is active as a normal electrode input and channels 2 through 8 are powered down.
uint8_t chset_all[8] = { 0x60, /* CH1 active, normal input */ 0xE1, /* CH2 off */ 0xE1, /* CH3 off */ 0xE1, /* CH4 off */ 0xE1, /* CH5 off */ 0xE1, /* CH6 off */ 0xE1, /* CH7 off */ 0xE1 /* CH8 off */};
The comments in the code make the intent very clear. This is not trying to do a full EEG headset configuration yet. It is a focused external signal test on one active channel. fileciteturn8file0
The firmware also configures the main ADS1299 control registers for that mode.
ret = ads_write_reg(REG_CONFIG1, 0x96);ret = ads_write_reg(REG_CONFIG2, 0xC0);ret = ads_write_reg(REG_CONFIG3, 0x60);
Then it disables lead-off detection and keeps the bias setup simple for this first external test.
ret = ads_write_reg(REG_LOFF, 0x00);ret = ads_write_reg(REG_LOFF_SENSP, 0x00);ret = ads_write_reg(REG_LOFF_SENSN, 0x00);ret = ads_write_reg(REG_BIAS_SENSP, 0x00);ret = ads_write_reg(REG_BIAS_SENSN, 0x00);
I think this is a smart approach. When bringing up a mixed-signal system, it is usually better to keep the first test as simple as possible instead of enabling every feature at once. fileciteturn8file0
Packing data into a BLE-friendly binary packet
Once a frame has been read, the firmware converts it into a 40-byte binary packet. The packet contains the sample index, the ADS1299 status word, and the eight decoded channel values.
PACKET_SIZE 40
static void build_binary_packet(uint8_t pkt[PACKET_SIZE], uint32_t sample_idx, const uint8_t frame[ADS_FRAME_BYTES]){ uint32_t status = ((uint32_t)frame[0] << 16) | ((uint32_t)frame[1] << 8) | ((uint32_t)frame[2]); sys_put_le32(sample_idx, &pkt[0]); sys_put_le32(status, &pkt[4]); for (int ch = 0; ch < ADS_NUM_CHANNELS; ch++) { int32_t sample = ads_decode24(&frame[3 + ch * 3]); sys_put_le32((uint32_t)sample, &pkt[8 + ch * 4]); }}
This is a nice format because it is compact, fixed-size, and easy for a receiver script to parse consistently. fileciteturn8file0
Using Nordic UART Service over BLE
On the Bluetooth side, the firmware advertises itself, registers a Nordic UART Service callback, and sends binary packets whenever a connection is active.
static void nus_send_binary(const uint8_t *data, uint16_t len){ if (!ble_ready || current_conn == NULL) { return; } err = bt_nus_send(current_conn, data, len); if (err) { printk("bt_nus_send err=%d\n", err); }}
I like NUS for early development because it gives a straightforward BLE transport without making me build a custom profile first. fileciteturn8file0
The firmware also reacts to simple text commands coming in over BLE. If it receives start, streaming is requested. If it receives stop, streaming is disabled.
if (strstr(msg, "start") != NULL) { stream_requested = true; stream_enabled = false; stream_start_at_ms = k_uptime_get() + 200; printk("Streaming requested\n");} else if (strstr(msg, "stop") != NULL) { stream_requested = false; stream_enabled = false; printk("Streaming disabled\n");}
That gives me a simple remote control path for starting the data flow only when the receiver is ready. fileciteturn8file0
Bringing everything together in main()
The main() function ties everything together in a clean bring-up sequence. It waits briefly at boot, checks that SPI is ready, configures the GPIOs, pulses reset, resets the ADS1299, prints the ID, configures the chip, dumps the registers, sends START, and then initializes BLE.
ret = ads_send_cmd(CMD_RESET);...ret = ads_print_id();...ret = ads_configure_external_ch1_mode();...ret = ads_dump_key_regs();...ret = ads_send_cmd(CMD_START);...ret = ble_init();
I really like this part because it reads like a practical hardware bring-up recipe. It is easy to follow and easy to troubleshoot. fileciteturn8file0
The streaming loop
Once setup is complete, the firmware sits in a loop waiting for a BLE start request. When streaming is enabled, it waits for DRDY, reads a frame, packs it, and sends it out over BLE.
ret = ads_wait_drdy_low_timeout_ms(1000);if (ret) { printk("DRDY timeout/error: %d\n", ret); k_sleep(K_MSEC(10)); continue;}ret = ads_read_frame_rdata(frame);if (ret) { printk("RDATA read error: %d\n", ret); k_sleep(K_MSEC(10)); continue;}build_binary_packet(pkt, sample_idx, frame);nus_send_binary(pkt, sizeof(pkt));
It also prints channel 1 periodically for debugging, which is very useful during bench testing.
if ((sample_idx % 250U) == 0U) { int32_t ch1 = ads_decode24(&frame[3]); printk("sample=%lu status=%02X%02X%02X ch1=%ld\n", (unsigned long)sample_idx, frame[0], frame[1], frame[2], (long)ch1);}
That is exactly the kind of small debug print I like in embedded work. It gives me a heartbeat that tells me the system is alive and the front end is actually producing numbers. fileciteturn8file0
Why I like this program
What I like most about this firmware is that it is practical. It is not trying to solve all EEG problems at once. It focuses on a single-channel external bench test, clean SPI communication, explicit chip setup, simple BLE control, and a consistent packet format.
That is exactly how I like to approach hardware and embedded systems: simplify the setup, verify each stage, get one clean data path working, and only then add more complexity.
Final thoughts
For me, this firmware is a solid bridge between the analog front end and the software tools I use later. It resets and configures the ADS1299, reads real conversion frames, turns them into decoded channel values, and streams them wirelessly over BLE in a format that is easy to consume.
That makes it more than just a low-level test file. It is the kind of code that moves a neurotech project from “the chip is on the board” to “I can actually get data out and work with it.”
And honestly, that is one of the most satisfying points in any embedded EEG project.
Leave a comment