t4as

Embedded & Firmware · 2025

nRF52840 Multi-Sensor Board

A Zephyr test bed for the nRF52840 with an IMU, a temperature/humidity sensor and an OLED. It has a command shell over USB and BLE (NUS), and settings are stored in flash with NVS.

  • nRF52840
  • Zephyr
  • nRF Connect SDK
  • BLE NUS
  • NVS
  • OLED
OLED refresh
~30 FPS
auto-cycling screens
4
shell transports (USB + BLE)
2
nRF52840 Multi-Sensor Board

Animated capture of the multi-screen OLED interface

What it is

A Zephyr application for the nRF52840 that I use as a test bed and as a starting template for sensor devices. It reads a BMI160 IMU and an AHT21 temperature/humidity sensor, shows the data on an SH1106 OLED, and exposes a command shell over both USB and BLE. Configuration is stored in flash so it survives reboots.

Part Purpose
Nordic nRF52840 Application SoC, BLE
Bosch BMI160 Accelerometer + gyroscope
AHT21 Temperature and humidity
SH1106 OLED (I²C) UI
USB-C serial Shell and logs

Threads

Sensor polling and display rendering run in separate Zephyr threads with their own priorities. The display refreshes at ~30 FPS independently of the sensor update rate. After starting both threads, the main thread only sleeps.

The display thread cycles through four screens every 5 seconds:

  1. Sensor summary
  2. Motion graph from the IMU
  3. QR code encoding the latest readings, so a phone camera can read them off the screen
  4. System information

Remote management over Bluetooth LE

The shell also runs over the Nordic UART Service (NUS), so any BLE terminal app on a phone can read status and change settings. The same commands work over USB.

Command Effect
STATUS Uptime, connections and other live status
SENSORS Latest sensor values
CONFIG Current configuration
NAME=<name> Change the advertised name
ADV_INT=<min,max> Advertising interval (ms)
TX_PWR=<dBm> Transmit power
RESET Reboot remotely

Driving the board from a BLE terminal app on a phone

Persistent configuration

Settings are stored in on-chip flash with Zephyr’s NVS (Non-Volatile Storage) subsystem and loaded at boot:

  • Device name
  • BLE advertising intervals and TX power
  • Enable/disable state for each sensor
  • Sensor update rate
  • Deep-sleep configuration

OLED screens

Enclosure

A two-part printed prototype case with a TPU gasket between the halves for some splash resistance. Design details are on the 3D prints & CAD page.

Code

Thread start-up (main_simple.c):

int main(void)
{
    /* ... initialise USB, sensors, Bluetooth ... */

    if (device_is_ready(display)) {
        LOG_INF("Starting display thread...");
        k_thread_create(&display_thread, display_thread_stack,
                        K_THREAD_STACK_SIZEOF(display_thread_stack),
                        display_thread_fn, NULL, NULL, NULL,
                        DISPLAY_THREAD_PRIORITY, 0, K_NO_WAIT);
        k_thread_name_set(&display_thread, "display");
    }

    LOG_INF("Starting sensor thread...");
    k_thread_create(&sensor_thread, sensor_thread_stack,
                    K_THREAD_STACK_SIZEOF(sensor_thread_stack),
                    sensor_thread_fn, NULL, NULL, NULL,
                    SENSOR_THREAD_PRIORITY, 0, K_NO_WAIT);
    k_thread_name_set(&sensor_thread, "sensors");

    /* Main thread only does low-priority housekeeping. */
    while (1) {
        k_sleep(K_SECONDS(30));
    }
    return 0;
}

Display loop with screen rotation (display_simple.c):

while (1) {
    uint32_t now = k_uptime_get_32();

    /* Auto-switch screens every 5 seconds */
    if (now - last_screen_change >= 5000) {
        current_screen = get_next_screen_id(current_screen);
        last_screen_change = now;
    }

    if (now - last_update >= DISPLAY_UPDATE_INTERVAL_MS) {
        uint8_t frame = (uint8_t)(frame_count % 256);

        switch (current_screen) {
        case SCREEN_SENSOR_DATA: draw_sensor_data_screen(&app.display_state, frame); break;
        case SCREEN_MOTION:      draw_motion_screen(&app.display_state, frame); break;
        case SCREEN_QR:          draw_qr_screen(&app.display_state, frame); break;
        case SCREEN_SYSTEM_INFO: draw_system_info_screen(&app.display_state, frame); break;
        }

        cfb_framebuffer_finalize(display);
        app.display_update_count++;
        frame_count++;
        last_update = now;
    }
    k_sleep(K_MSEC(1));
}