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


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:
- Sensor summary
- Motion graph from the IMU
- QR code encoding the latest readings, so a phone camera can read them off the screen
- 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 |

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));
}