Driving WS2812B LEDs using I2S on the Nordic nRF52832 BLE SoC

It’s hard not to like a project with blinking LEDs. Red, Green, Blue, Yellow,… and then there are RGB LEDs where you have three lines to control the colour. In 2010 WorldSemi launched the WS2812 – an RGB LED and driver chip integrated into one package, which could be daisy-chained to any number of other similar LEDs, and here’s the best thing – you could control all of them with a single wire. Thus the internet exploded with a colourful overabundance of LED projects.
In this project, we will drive WS2812B (the improved WS2812) using the Nordic nRF52832, and control the output using a smartphone.
But what about the umpteen number of WS2812 libraries and examples that already exist, you ask. Well the difference here is that we will use (or misuse, rather) the I2S (Inter-IC Sound) peripheral of the nRF52832 to do it.
Note: This work is inspired by that of Takafumi Naka (Japanese article). I have only taken the idea; the research and code is all mine.
Driving WS2812Bs
Driving WS2812B LEDs requires only one data line, but it’s quite sensitive to the timing of the signals on that line. Here’s relevant information which I gleaned from the data sheet.

So the LEDs require specific waveforms for ON and OFF. These are grouped together as 8 “bits” per channel, and hence 24 per LED, multiplied by N, the number of LEDs in the daisy chain. To send the next frame to the LEDs, you need to sent the RESET signal, which is just a LOW pulse greater than 50 microseconds.
Now, let’s look at the I2S protocol.
The I2S Protocol
The I2S (Inter-IC Sound) was developed by Philips Semiconductor in 1986, and it’s a means of transimitting digital audio between devices. The Nordic nRF52832 comes built-in with an I2S peripheral, which is what we are trying to leverage. Here’s an I2S signal diagram example from the nRF52832 data sheet.

So, as you can see above, there are four relevant signals – SCK (serial clock), LRCK (left right clock), SDIN (input data) and SDOUT (ouput data). SCK pulses once for each bit of data transferred on SDIN/SDOUT, and LRCK is used to select right/left channels (remember this is a protocol for sound). In addition, there is the MCK (master clock) which is used to generate SCK and LRCK when we operate in Master mode (our case).
So all we have to do is generate the correct WS2812B waveform on SDOUT. We do so by setting up I2S as follows:
- MCK to 3.2 MHz (period 0.3125 us)
- 16 bit stereo
- LRCK = MCK / 32
- SCK = 2 * LRCK * 16
Now, we represent a WS2812B ON/OFF “bit” as 4 actual bits, where each bit is a pulse of SCK. ON is 1110 and OFF is 1000. You can see above that 0.3125 us falls within the 0.4 us +- 0.15 us pulse width dictated by the WS2812B datasheet.
Now let’s see how to hook the hardware up.
Hardware Hookup
We need an nRF52832 board, and in this case we are using our beautiful Bluey nRF52 dev board. We’ll use a 16 LED ring of WS2812Bs, and the other piece of hardware we need is a level shifter on the SDOUT line.

The above is required because the nRF52832 runs at 3.3 V and the WS2812B data line needs atleast 3.5 V to work reliably.
In our case, SDOUT is on P0.27, and although you don’t need to hook these up, you can see SCK on P0.31 and LRCK on P0.30.
Here’s what these signal looks like in the real world, by the way:

Channel 1, 2 and 3 above show SDOUT, LRCK and SCK respectively. Now, onwards to the code.
The Code
The code is setup to do the following. We use the Nordic UART service to control start/stop of I2C as well as the colour of the LEDs. The Nordic SDK has a driver for the I2S peripheral, and we will make use of it.
First, here are the global I2S buffers and related variables:
#define NLEDS 16
#define RESET_BITS 6
#define I2S_BUFFER_SIZE 3*NLEDS + RESET_BITS
static uint32_t m_buffer_tx[I2S_BUFFER_SIZE];
static volatile int nled = 1;
Here’s the I2S initialisation:
nrf_drv_i2s_config_t config = NRF_DRV_I2S_DEFAULT_CONFIG;
config.sdin_pin = I2S_SDIN_PIN;
config.sdout_pin = I2S_SDOUT_PIN;
config.mck_setup = NRF_I2S_MCK_32MDIV10; ///< 32 MHz / 10 = 3.2 MHz.
config.ratio = NRF_I2S_RATIO_32X; ///< LRCK = MCK / 32.
config.channels = NRF_I2S_CHANNELS_STEREO;
err_code = nrf_drv_i2s_init(&config, data_handler);
APP_ERROR_CHECK(err_code);
We don’t need to setup an Rx buffer in the above call. The data_handler is defined as follows:
// This is the I2S data handler - all data exchange related to the I2S transfers
// is done here.
static void data_handler(uint32_t const * p_data_received,
uint32_t * p_data_to_send,
uint16_t number_of_words)
{
// Non-NULL value in 'p_data_to_send' indicates that the driver needs
// a new portion of data to send.
if (p_data_to_send != NULL)
{
// do nothing - buffer is updated elsewhere
}
}
Why aren’t we doing anything in the handler above? Well, sometimes Nordic’s APIs are a bit confusing. In this case they’ve implemented a scheme where only half the Tx buffer is transmitted in one shot and they keep flipping the pointers. This is not what the undelying I2S hardware does and seems unnecessary. (The hardware does implement double buffering which to my understanding is something entirely different.) So to simplify the code, I just modify the Tx buffer as I need in the main loop by calling a set_led_data function.
void set_led_data()
{
for(int i = 0; i < 3*NLEDS; i += 3) {
if (i == 3*nled) {
switch(g_demo_mode)
{
case 0:
{
m_buffer_tx[i] = 0x88888888;
m_buffer_tx[i+1] = caclChannelValue(128);
m_buffer_tx[i+2] = 0x88888888;
}
break;
case 1:
{
m_buffer_tx[i] = caclChannelValue(128);;
m_buffer_tx[i+1] = 0x88888888;
m_buffer_tx[i+2] = 0x88888888;
}
break;
case 2:
{
m_buffer_tx[i] = 0x88888888;
m_buffer_tx[i+1] = 0x88888888;
m_buffer_tx[i+2] = caclChannelValue(128);
}
break;
default:
break;
}
}
else {
m_buffer_tx[i] = 0x88888888;
m_buffer_tx[i+1] = 0x88888888;
m_buffer_tx[i+2] = 0x88888888;
}
}
// reset
for(int i = 3*NLEDS; i < I2S_BUFFER_SIZE; i++) {
m_buffer_tx[i] = 0;
}
}
In the above code, the Tx buffer is being set according to the pattern we want the LEDs to flash in. Here’s the implementation of caclChannelValue:
uint32_t caclChannelValue(uint8_t level)
{
uint32_t val = 0;
// 0
if(level == 0) {
val = 0x88888888;
}
// 255
else if (level == 255) {
val = 0xeeeeeeee;
}
else {
// apply 4-bit 0xe HIGH pattern wherever level bits are 1.
val = 0x88888888;
for (uint8_t i = 0; i < 8; i++) {
if((1 << i) & level) {
uint32_t mask = ~(0x0f << 4*i);
uint32_t patt = (0x0e << 4*i);
val = (val & mask) | patt;
}
}
// swap 16 bits
val = (val >> 16) | (val << 16);
}
return val;
}
The above function sets up a 32 bit value for a channel (R/G/B). A channel has 8 x 4-bit codes. Code 0xe is HIGH and 0x8 is LOW. So a level of 128 would be represented as 0xe8888888. At the end, the 16 bit values need to be swapped because of the way I2S sends data – right/left channels. So for the above example, final value sent would be 0x8888e888.
And here’s the main loop:
for (;;)
{
// start I2S
if(g_i2s_start && !g_i2s_running) {
err_code = nrf_drv_i2s_start(0, m_buffer_tx, I2S_BUFFER_SIZE, 0);
APP_ERROR_CHECK(err_code);
g_i2s_running = true;
}
// stop I2S
if(!g_i2s_start && g_i2s_running) {
nrf_drv_i2s_stop();
g_i2s_running = false;
}
nrf_delay_ms(250);
// update
if (g_i2s_running) {
nled = (nled + 1) % NLEDS;
set_led_data();
}
}
In the above code, I2S is started and stopped, and the colour set based on a flags which are set in the
NUS (Nordic UART Serice) handler below.
volatile uint8_t g_demo_mode = 0;
volatile bool g_i2s_start = true;
volatile bool g_i2s_running = false;
static void nus_data_handler(ble_nus_t * p_nus, uint8_t * p_data, uint16_t length)
{
switch(p_data[0]) {
case '1':
{
g_demo_mode = 0;
}
break;
case '2':
{
g_demo_mode = 1;
}
break;
case '3':
{
g_demo_mode = 2;
}
break;
case 'S':
{
g_i2s_start = false;
}
break;
case 'P':
{
g_i2s_start = true;
}
break;
}
// send to UART
for (uint32_t i = 0; i < length; i++)
{
while (app_uart_put(p_data[i]) != NRF_SUCCESS);
}
while (app_uart_put('\r') != NRF_SUCCESS);
while (app_uart_put('\n') != NRF_SUCCESS);
}
Now let’s see how this all works.
In Action
To test our code, we use the Nordic nRFToolbox app. Set up the UART keypad as follows:
Key | Value (string) |
---|---|
1 | 1 |
2 | 2 |
3 | 3 |
[ ] | S |
> | P |
You can see it in action here:
Conclusion
There you have it – a fun misuse of the I2S peripheral to drive some LEDs!
Downloads
You can download the code for this project from the git repo below:
https://github.com/electronut/ElectronutLabs-bluey/tree/master/code/bluey-WS2812-I2S