Sunday, March 22, 2026

Board-Centric vs MCU-Centric: Zephyr, Mbed, and Arduino Don't Scale the Way You Think

Arduino, Mbed, Zephyr, and IOsonata compared — and why a simple bus abstraction turns new-board work from O(N) into something closer to O(1).

You are spinning a new PCB on the same MCU family. A couple of pins move; one sensor hops from I²C to SPI. The application logic is unchanged. How hard should this be — and why does it sometimes turn into a half-day ordeal?

It depends far more on your firmware architecture than on your IDE or team size. A lot of embedded developers look at Zephyr’s huge board list and assume portability has been solved. It has not. What Zephyr has solved is something different: it has built a large, framework-managed ecosystem around boards, configuration metadata, and generated build logic. That is why it can support a lot of hardware — and also why so many users experience so much friction.

But before going further into Zephyr, it is worth understanding what board-centric actually means, because not all board-centric designs are the same.

Board-Centric Is Not Automatically Bad

Arduino is also board-centric. And Arduino shows that board-centric does not have to mean high-friction.

Arduino’s platform model is simple. Its official platform specification describes each architecture through a handful of files: platform.txt, boards.txt, and programmers.txt. The board itself is described by key-value properties. For the Uno, that is essentially uno.build.mcu=atmega328p, uno.build.f_cpu=16000000L, uno.build.core=arduino, and a variant. Arduino even documents that the minimum files needed to define new hardware can, in some cases, be reduced to just boards.txt.

That is still board-centric — but it is thin, explicit, and easy to reason about. The metadata surface is small. The board selection changes a few build properties, and that is it. There is no separate devicetree binding layer, no generated macro API for device discovery, no west workspace model, and no sysbuild hierarchy sitting on top of the base build.

Arduino is simple board-centric. Zephyr is not.

What Board-Centric Means in Zephyr

Board-centric and MCU-centric are not just two ways to organize files. They are two different ideas about where variability should live.

In a board-centric design, the build knows boards as first-class objects. Board files decide what hardware exists, what drivers are enabled, how flashing works, and how buses are wired into the system model. In an MCU-centric design, the stable unit is the silicon. Once the MCU family is supported, a new board is mostly pin mapping, clocks, and external device hookup. The build does not need to “learn” every board as a new entity. That sounds subtle, but it changes everything when you start scaling across product variants.

Zephyr’s user-facing contract is the board. You build with west build -b <board>. West is Zephyr’s own meta-tool for workspace and command orchestration. Sysbuild is a higher-level build layer that can combine multiple Zephyr and non-Zephyr builds. From the user’s point of view, hardware description, software enablement, repository management, and multi-image orchestration are all tied together.

The board porting guide makes the design explicit. A Zephyr board directory is not just a pin map. The documented structure includes board.yml, board.cmake, CMakeLists.txt, Kconfig.plank, Kconfig.defconfig, board defconfig fragments, DTS files, YAML, and documentation assets. Zephyr also requires board.cmake to configure flash and debug runner integration for west flash and west debug. A board in Zephyr is a build object, not just a hardware description.

Difficulty Scorecard: A “Pin Moved + I²C↔SPI” Change

Experience-based scores for a typical pin-move plus bus-swap on nRF/STM32. Lower = easier. Effort is typical time for an experienced developer.

Framework Artifacts Locality Cognitive Generators Feedback App impact Effort
Arduino (variants) 332134 30–60 min
Mbed OS (targets) 433333 1–2 h
Zephyr (DT/Kconfig) 545543–4 2.5–5 h
MCU-Centric (IOsonata) 111–211–21 15–20 min

Criteria — Artifacts: files/kinds to edit. Locality: one place vs many layers. Cognitive: schemas/bindings/macros to understand. Generators: code/config generation steps. Feedback: do errors point to the edit site? App impact: does a bus swap change driver or application code?

Why DTS and CMake Are the Main Sources of Friction

Zephyr’s documentation states that Devicetree “mainly deals with hardware” while Kconfig deals with software. That sounds clean on paper. In practice it means the developer must understand where hardware description ends, where software selection begins, and how those two layers interact through generated code and build rules. Zephyr’s C/C++ Devicetree API is macro-based, and the docs explicitly warn that Zephyr does not use runtime DT parsing the way Linux does — instead it generates a C header and exposes everything through a macro API at build time.

That design is not just a syntax choice. It is why so much bring-up work turns into “find the right node, binding, alias, chosen entry, Kconfig symbol, overlay, generated header output, and CMake path.” The build owns too much of the topology.

This is not just outsider criticism. The friction shows up inside Zephyr’s own ecosystem. A Zephyr collaborator wrote in a 2024 discussion that “there is unnecessary complexity put to the user” in the dt_spec usage model. A long-running CMake issue complains that Zephyr wraps standard CMake commands in its own API, forcing users to learn Zephyr-specific interfaces on top of normal CMake.

Independent engineers report the same experience. Memfault’s Practical Zephyr series exists largely because the author found the learning curve overwhelming and had to chase link after link to understand west, Kconfig, and devicetree well enough to build simple applications. The series notes explicitly that devicetree syntax and usage are more intricate than Kconfig and require their own deep dive.

The pattern on Reddit is even more direct. Users describe .dts files as a “maze of dependencies,” Zephyr as “80% configuration and 20% coding,” and its abstraction layers as “extreme” compared with doing the same job through simpler headers, linker files, and conventional build tools.

And at Open Source Summit Europe 2025, a Zephyr-related talk on devicetree complexity asked the audience whether Zephyr’s devicetree was too complicated — roughly half the room raised their hand. That is a strong signal that the problem is widely recognized even inside the community.

The Missing Piece: Bus-Agnostic Drivers via DeviceIntrf

The reason MCU-centric scales is not just “board.h vs DTS.” It is that drivers do not know or care about the bus — or the MCU.

IOsonata’s core is a C vtable (DevIntrf_t) with a C++ wrapper class DeviceIntrf. Driver code uses Read/Write only. The bus implementation (I²C/SPI/UART) is injected at initialization — the driver never sees it directly.

Core abstraction

class DeviceIntrf {
public:
    // High-level helpers that drivers use:
    virtual int Read(uint32_t DevSel,
                     const uint8_t* pAdCmd, int AdCmdLen,
                     uint8_t* pBuff, int BuffLen) = 0;

    virtual int Write(uint32_t DevSel,
                      const uint8_t* pAdCmd, int AdCmdLen,
                      const uint8_t* pData, int DataLen) = 0;

    virtual int Rx(uint32_t DevSel, uint8_t* pBuff, int BuffLen) = 0;
    virtual int Tx(uint32_t DevSel, const uint8_t* pData, int DataLen) = 0;

    virtual ~DeviceIntrf() = default;
};

BME280 driver — written once, bus-agnostic

class TphBme280 {
    DeviceIntrf* intrf = nullptr;
    uint32_t     devSel = 0;  // I2C 7-bit address or SPI CS index

public:
    bool Init(DeviceIntrf& bus, uint32_t addr) {
        intrf  = &bus;
        devSel = addr;

        uint8_t whoamiReg = 0xD0;
        uint8_t id        = 0;

        if (intrf->Read(devSel, &whoamiReg, 1, &id, 1) != 1)
            return false;

        return id == 0x60;  // BME280 chip ID
    }
};

Application chooses the bus — driver unchanged

I2C  g_I2c;   // implements DeviceIntrf
SPI  g_Spi;   // implements DeviceIntrf
TphBme280 sensor;

void BringUp(bool useI2C) {
    DeviceIntrf& bus = useI2C
        ? static_cast<DeviceIntrf&>(g_I2c)
        : static_cast<DeviceIntrf&>(g_Spi);

    sensor.Init(bus, 0x76);  // same call, same driver, any bus
}

The same driver works with I²C or SPI on any MCU that has a backend (nRF52, STM32, ESP32, RISC-V…). The bus complexity lives in two backends, not duplicated across every driver.

Side-by-Side: What You Actually Touch

Change Arduino Mbed Zephyr MCU-Centric
Pins moved (same MCU) Edit pins_arduino.h (+ boards.txt if new board) Edit PinNames.h + PeripheralPins.* (+ sometimes custom_targets.json) Edit .overlay (+ board/Kconfig/bindings as needed), regenerate Edit numbers in board.h
I²C ↔ SPI swap App swaps library (WireSPI) App swaps class (I2CSPI) + update pin maps Update DT nodes/bindings; app impact varies by binding model Swap injected backend at composition root; driver unchanged
Build “knows” board?
Generation step? ✔ (mbed_config.h) ✔ (DT/Kconfig → headers)

Code Examples: How Board-Centric Frameworks Wire Drivers

Zephyr — compile-time bus wiring via Devicetree

I²C variant (bme280_i2c.overlay):

&i2c1 {
    status = "okay";
    bme280@76 {
        compatible = "bosch,bme280";
        reg = <0x76>;
        status = "okay";
    };
};

SPI variant (bme280_spi.overlay):

&spi3 {
    status = "okay";
    bme280@0 {
        compatible = "bosch,bme280";
        reg = <0>;
        spi-max-frequency = <8000000>;
        cs-gpios = <&gpio0 15 GPIO_ACTIVE_LOW>;
        status = "okay";
    };
};

Build by swapping the overlay:

# I2C build
west build -b your_board app -- -DDTC_OVERLAY_FILE=bme280_i2c.overlay

# SPI build
west build -b your_board app -- -DDTC_OVERLAY_FILE=bme280_spi.overlay

The driver/device code must handle both paths via #if DT_ANY_INST_ON_BUS_STATUS_OKAY(i2c) and #elif DT_ANY_INST_ON_BUS_STATUS_OKAY(spi) blocks, each with different i2c_dt_spec / spi_dt_spec structs and different transfer functions. The bus is chosen at build time. Switching I²C↔SPI means updating DT and rebuilding.

Mbed — bus-specific classes or compile-time selection

I2C i2c(PB_9, PB_8);
SPI spi(PA_7, PA_6, PA_5);
DigitalOut cs(D10, 1);

int main() {
#if defined(USE_I2C)
    BME280_I2C sensor(i2c, 0x76 << 1);
    printf("T=%.2f\n", sensor.temperature());
#else
    BME280_SPI sensor(spi, cs);
    printf("T=%.2f\n", sensor.temperature());
#endif
}

With Mbed you typically instantiate a different class or code path for I²C vs SPI, and you keep pin/peripheral maps in PinNames.h / PeripheralPins.c (and often custom_targets.json) for new boards.

The Real Design Problem

The usual defense of board-centric complexity is that it buys portability. But portability to what?

If portability means “the framework can model many boards,” then yes, Zephyr is portable. If portability means “my application and drivers survive board changes with minimal rewiring,” that is a different test — and under that test, the architecture works against you. The more the build system owns board identity, bus topology, and hardware relationships, the more a board change becomes a build and configuration exercise instead of a firmware composition exercise.

That is why Devicetree, in this context, is a bad design choice for many MCU firmware projects. Not because hardware description is useless. Not because Linux concepts are always wrong. But because Zephyr’s DTS is tied into a larger configuration machine — bindings, Kconfig, overlays, generated macros, CMake, west, modules, and sometimes sysbuild. Ordinary firmware changes get routed through metadata plumbing instead of staying in code.

What MCU-Centric Changes

An MCU-centric architecture reverses that burden.

The MCU is the stable base. The board becomes a thin adaptation layer. Drivers talk to abstract device interfaces instead of being tightly coupled to bus-specific board metadata. Bus choice becomes a composition decision, not a build-system identity.

In practice, adding a new board in IOsonata does not require a framework object with its own DTS, Kconfig fragments, runner logic, YAML metadata, and CMake wiring. It requires a pin mapping header and initialization choices. Swapping I²C for SPI is a one-line change at initialization, not a devicetree overlay exercise. The application and driver code do not change at all, because they were written to abstract interfaces from the start.

The scaling math makes this concrete. With 100 device drivers and I²C + SPI support across 5 MCU families:

  • Board-centric: up to 100 devices × 2 buses × 5 families = many integration paths. Each driver is bus-aware.
  • MCU-centric: 100 devices × 1 driver. Bus complexity lives in 10 backends (I²C and SPI per family). Drivers stay unchanged.

Because drivers call Read/Write on an abstract interface, one firmware image can even probe at runtime and select the backend without rebuilding:

void InitSensorRuntime() {
    uint8_t whoamiReg = 0xD0, id = 0;

    if (g_I2c.Read(0x76, &whoamiReg, 1, &id, 1) == 1 && id == 0x60)
        sensor.Init(static_cast<DeviceIntrf&>(g_I2c), 0x76);
    else
        sensor.Init(static_cast<DeviceIntrf&>(g_Spi), 0);
}

This enables board revision detection, population options, field-swappable sensors, and one binary across multiple hardware variants — without rebuilding. In board-centric systems, this is not the default pattern; it typically requires building and maintaining board/DT metadata for each variant separately.

Where Board-Centric Works — and Where It Breaks

The Arduino example shows that board-centric can work when the metadata surface stays small. A handful of key-value properties, one file, no generation step — that is a model a developer can hold in their head. The moment you add Devicetree, Kconfig, west, and sysbuild on top, the model stops being board-centric and becomes an ecosystem dependency. Every new board inherits the full weight of that stack, whether the board needs it or not. That is not a configuration problem or a documentation problem. It is a design problem, and it does not get better as the product line grows.


Zephyr’s friction is not accidental. It is a predictable result of a board-centric architecture that keeps adding layers in the name of generality.

Arduino proves board-centric can be simple.

Zephyr proves board-centric can become an ecosystem tax.

MCU-centric design exists for one reason: to keep the build from owning your product.

Your build system knows your board. Does it know it better than you do — and are you paying for that every time you add a variant?

Saturday, February 14, 2026

After 12 Years of Writing Eclipse Setup Guides, I Finally Automated It

Tags: Embedded Systems, Eclipse CDT, ARM Cortex-M, RISC-V, nRF52, OpenOCD, Debugging


If you’ve been reading this blog for a while, you know I’ve spent a good part of the last 15 years writing tutorials on how to set up Eclipse CDT for embedded development.

OpenOCD configuration.
GCC toolchain paths.
Makefiles that almost work.
J-Link vs ST-Link quirks.
Getting the debugger to halt at main() instead of running off into the void.

The irony is that for many embedded projects, the hardest part isn’t the firmware.

It’s the toolchain.

You want to bring up a new nRF52 or STM32 board.
Instead, you spend hours (or days) installing toolchains, fixing environment variables, aligning SDK versions, and debugging your debugger.

I’ve repeated the same setup process enough times that I finally decided to automate it properly.

That’s how IOcomposer started.


The Goal

From a clean OS install to hitting a breakpoint in under 15 minutes.

Not an Arduino-style abstraction layer.
Not a cloud IDE.
A full professional embedded workflow:

  • GCC toolchains (ARM & RISC-V)

  • OpenOCD integration

  • Real JTAG debugging

  • Eclipse CDT

  • Bare-metal or lightweight framework development

But without the manual wrangling.


What IOcomposer Actually Is

IOcomposer is a standalone IDE built on Eclipse Embedded CDT, pre-integrated with:

  • xPack GCC toolchains (ARM & RISC-V)

  • OpenOCD & QEMU

  • Nordic nRF5 SDK (ready to build)

  • The IOsonata framework (multi-architecture firmware layer)

You install it.
You open it.
You build.
You debug.

No chasing paths. No manual plugin setup.

If you’ve followed my previous Eclipse tutorials, think of this as those guides — baked into the tool.


Why Add an AI Assistant?

This is where I hesitated before writing this post.

There are already generic AI coding tools. Most of them are good at C++ syntax. Most of them are not good at embedded systems.

The assistant in IOcomposer is different in one important way:

It has access to the full project structure — including macro definitions, board configuration, and linker setup.

That allows it to reason about embedded configuration problems, not just generate code.


A simple example:

Add SPI and Wrong pin order detected
Add SPI and wrong pin order detected


If you misconfigure SPI pins on an nRF52832, or define a macro in board.h that conflicts with the SDK, it can flag the inconsistency and suggest the missing definitions.

If a build fails, it analyzes the console output and suggests the specific include, flag, or configuration change that caused the failure.

It doesn’t replace debugging.
But it can shorten the “why is this failing?” phase.


Debugging Still Matters

The “Debug” button launches a fully configured OpenOCD session with:

  • Breakpoints

  • Live variable watch

  • Peripheral register view

  • Memory monitor

  • Disassembly view

This isn’t meant to abstract away embedded development.

It’s meant to remove the setup friction so you can focus on the firmware.


Who This Is (and Isn’t) For

If you’re happy with Arduino and don’t need JTAG debugging, this is probably unnecessary.

If your company standardizes on Zephyr and that workflow works for you, stick with it.

But if you prefer:

  • Bare-metal or lightweight firmware

  • Full debugger control

  • A professional desktop IDE

  • Without spending a weekend configuring it

Then IOcomposer might be worth trying.


Public Preview

This is an early public preview.

It runs on Windows, Linux, and macOS.

The core IDE and debugging features are free.
Paid tiers are only for higher AI usage.

You can download it here:

👉 https://iocomposer.io

If you try it, I’d genuinely appreciate feedback:

  • Does the install work on your distro?

  • Does debugging connect reliably to your hardware?

  • Where does it break?

  • What feels unnecessary?

After writing setup guides for 12 years, I’m curious whether this actually solves the problem — or if I’ve just automated my own habits.

Let me know in the comments.


Friday, February 23, 2018

Bluetooth LE with Nordic nRF51 & nRF52 series
The easy way!
Part 3


Custom service
(UART over BLE)



We have looked at how to advertise in previous post.  Now, lets see how simple it is to create a BLE custom service.  For this purpose, we'll create an UART to BLE firmware that sends whatever it received on UART RX to BLE and whatever that is received from BLE to UART TX.

First thing first, lets start by initializing the UART interface.  As mentioned before the concept of EHAL is very simple.  Everything is initialized with a Config data first then just use.

The UART interface configuration starts by defining I/O pins to be used for the UART.

/// UART pins definitions
static IOPINCFG s_UartPins[] = {
    {UART_RX_PORT, UART_RX_PIN, UART_RX_PINOP, IOPINDIR_INPUT, IOPINRES_NONE, IOPINTYPE_NORMAL}, // RX
    {UART_TX_PORT, UART_TX_PIN, UART_TX_PINOP, IOPINDIR_OUTPUT, IOPINRES_NONE, IOPINTYPE_NORMAL}, // TX
    {UART_CTS_PORT, UART_CTS_PIN, UART_CTS_PINOP, IOPINDIR_INPUT, IOPINRES_NONE, IOPINTYPE_NORMAL}, // CTS
    {UART_RTS_PORT, UART_RTS_PIN, UART_RTS_PINOP, IOPINDIR_OUTPUT, IOPINRES_NONE, IOPINTYPE_NORMAL}, // RTS
};


Then the configuration data and the UART interface instance.

/// UART configuration
const UARTCFG g_UartCfg = {
    0, // Device number zero based
    s_UartPins, // UART assigned pins
    sizeof(s_UartPins) / sizeof(IOPINCFG),// Total number of UART pins used
    1000000, // Baudrate
    8, // Data bits
    UART_PARITY_NONE, // Parity
    1, // Stop bit
    UART_FLWCTRL_HW, // Flow control
    true, // Interrupt mode
    APP_IRQ_PRIORITY_LOW, // Interrupt priority
    nRFUartEvthandler, // UART event handler
    true, // Blocking FIFO
};

/// UART object instance
UART g_Uart;

Initialize the interface :

    g_Uart.Init(g_UartCfg);

Using UART :

    g_Uart.printf("Hello\r\n");


Now the BLE configuration for custom service.

/// Characteristic definitions
BLESRVC_CHAR g_UartChars[] = {
    {
  // Read characteristic
BLUEIO_UUID_UART_RX_CHAR,
20,
BLESVC_CHAR_PROP_READ | BLESVC_CHAR_PROP_NOTIFY | BLESVC_CHAR_PROP_VARLEN,
s_RxCharDescString, // char UTF-8 description string
NULL, // Callback for write char, set to NULL for read char
NULL, // Callback on set notification
NULL, // Tx completed callback
NULL, // pointer to char default values
0, // Default value length in bytes
    },
    {
// Write characteristic
BLUEIO_UUID_UART_TX_CHAR,// char UUID
20, // char max data length
BLESVC_CHAR_PROP_WRITEWORESP,// char properties define by BLUEIOSVC_CHAR_PROP_...
s_TxCharDescString, // char UTF-8 description string
UartTxSrvcCallback, // Callback for write char, set to NULL for read char
NULL, // Callback on set notification
NULL, // Tx completed callback
NULL, // pointer to char default values
0 // Default value length in bytes
    },
};

/// Service definition
const BLESRVC_CFG s_UartSrvcCfg = {
    BLESRVC_SECTYPE_NONE, // Secure or Open service/char
    BLUEIO_UUID_BASE, // Base UUID
    BLUEIO_UUID_UART_SERVICE, // Service UUID
    2, // Total number of characteristics for the service
    g_UartChars, // Pointer a an array of characteristic
    g_LWrBuffer, // pointer to user long write buffer
    sizeof(g_LWrBuffer) // long write buffer size
};

BLESRVC g_UartBleSrvc;

const BLEAPP_DEVDESC s_UartBleDevDesc {
    MODEL_NAME,           // Model name
    MANUFACTURER_NAME, // Manufacturer name
    "", // Serial number string
    "0.0", // Firmware version string
    "0.0", // Hardware version string
};

const BLEAPP_CFG s_BleAppCfg = {
    { // Clock config nrf_clock_lf_cfg_t
#ifdef IMM_NRF51822
  NRF_CLOCK_LF_SRC_RC, // Source RC
1, 1, 0
#else
NRF_CLOCK_LF_SRC_XTAL, // Source 32KHz XTAL
0, 0, NRF_CLOCK_LF_ACCURACY_20_PPM
#endif
    },
    0, // Number of central link
    1, // Number of peripheral link
    BLEAPP_MODE_APPSCHED, // Use scheduler
    DEVICE_NAME, // Device name
    ISYST_BLUETOOTH_ID, // PnP Bluetooth/USB vendor id
    1, // PnP Product ID
    0, // Pnp prod version
    true, // Enable device information service (DIS)
    &s_UartBleDevDesc,
    g_ManData, // Manufacture specific data to advertise
    sizeof(g_ManData), // Length of manufacture specific data
    BLEAPP_SECTYPE_STATICKEY_MITM,//BLEAPP_SECTYPE_NONE,    // Secure connection type
    BLEAPP_SECEXCHG_NONE, // Security key exchange
    NULL,      // Service uuids to advertise
    0, // Total number of uuids
    APP_ADV_INTERVAL, // Advertising interval in msec
    APP_ADV_TIMEOUT_IN_SECONDS, // Advertising timeout in sec
    0, // Slow advertising interval, if > 0, fallback to
// slow interval on adv timeout and advertise until connected
    MIN_CONN_INTERVAL,
    MAX_CONN_INTERVAL,
    BLUEIO_CONNECT_LED_PORT, // Led port nuber
    BLUEIO_CONNECT_LED_PIN, // Led pin number
    0, // Tx power
    NULL // RTOS Softdevice handler

};


In order to handle the UART receive and send it to BLE, we need to hookup the UART event handler.

void UartRxChedHandler(void * p_event_data, uint16_t event_size)
{
    uint8_t buff[128];

    int l = g_Uart.Rx(buff, 128);
    if (l > 0)
    {
BleSrvcCharNotify(&g_UartBleSrvc, 0, buff, l);
    }
}

int nRFUartEvthandler(UARTDEV *pDev, UART_EVT EvtId, uint8_t *pBuffer, int BufferLen)
{
    int cnt = 0;
    uint8_t buff[20];

    switch (EvtId)
    {
case UART_EVT_RXTIMEOUT:
case UART_EVT_RXDATA:
           app_sched_event_put(NULL, 0, UartRxChedHandler);
           break;
case UART_EVT_TXREADY:
           break;
case UART_EVT_LINESTATE:
           break;
    }

    return cnt;



}

Handling received date from BLE and transmit to UART Tx.

void UartTxSrvcCallback(BLESRVC *pBlueIOSvc, uint8_t *pData, int Offset, int Len)
{
    g_Uart.Tx(pData, Len);
}


Those the main parts of this example.  Full source code is located here as part of the IOsonata library.

Part 1 Part 2

Tuesday, January 2, 2018

Bluetooth LE with Nordic nRF51 & nRF52 series
The easy way!
Part 2



Advertising environmental sensor data
(Temperature, Humidity, Pressure)
with Bosch BME280 & BME680 


In previous post, we advertise over Bluetooth only an incremental counter.  Lets do something more interesting this time by advertising environmental sensor data, temperature, pressure and humidity.  Most digital sensors can be communicated using either I2C or SPI interface.  EHAL provides a simple way to us those interface.  Similar to the BLE interface, the I2C & SPI also make use to configuration data structure and an Init function.



The hardware 


The environmental sensors used in this example are the BME280 and BME680 from Bosch.

Bluetooth 5 Sensor Board
avail at https://www.crowdsupply.com/i-syst/blyst-nano


The BME680 breakout board available on Tindie
this board support both I2C & SPI interface.


Configuring I2C for the BME680 breakout board


// Configure I2C interface
static const I2CCFG s_I2cCfg = {
  0, // I2C device number
{
        // I2C pins in which the device is connected to.
{I2C0_SDA_PORT, I2C0_SDA_PIN, I2C0_SDA_PINOP, IOPINDIR_BI, IOPINRES_NONE, IOPINTYPE_NORMAL},
{I2C0_SCL_PORT, I2C0_SCL_PIN, I2C0_SCL_PINOP, IOPINDIR_OUTPUT, IOPINRES_NONE, IOPINTYPE_NORMAL},
},
100000, // Rate in Hz
I2CMODE_MASTER,
0, // Slave address
5, // Retry
7, // Interrupt prio
NULL // Event callback
};

// I2C interface instance

I2C g_I2c;

Initialize the I2C interface.

    g_I2c.Init(s_I2cCfg);

The I2C is now ready to use.   Now lets try to use it to read the Bosch BME680 device ID directly.

    uint8_t regaddr = 0xD0; // BME680 device ID register address
    uint8_t d;

    g_I2c.Read(0x76, // BME680 I2C Device address
               &regaddr, 1, // register address to read
              &d, 1 // buffer to return data
              );

    printf("BME680 device id = 0x%02x\r\n", d);

If the device is connected correctly, the variable d should contains the value 0x61 (97 decimal).  

Configuring SPI

// Motsai Neblina V2 module uses SPI interface

static const IOPINCFG gsSpiBoschPin[] = {
    {SPI_SCK_PORT, SPI_SCK_PIN, SPI_SCK_PINOP,
     IOPINDIR_OUTPUT, IOPINRES_NONE, IOPINTYPE_NORMAL},
    {SPI_MISO_PORT, SPI_MISO_PIN, SPI_MISO_PINOP,
     IOPINDIR_INPUT, IOPINRES_NONE, IOPINTYPE_NORMAL},
    {SPI_MOSI_PORT, SPI_MOSI_PIN, SPI_MOSI_PINOP,
     IOPINDIR_OUTPUT, IOPINRES_NONE, IOPINTYPE_NORMAL},
    {SPI_BME280_CS_PORT, SPI_BME280_CS_PIN, SPI_BME280_CS_PINOP,
     IOPINDIR_OUTPUT, IOPINRES_PULLUP, IOPINTYPE_NORMAL},
};

static const SPICFG s_SpiCfg = {
    SPI_DEVNO,
    SPIMODE_MASTER,
    gsSpiBoschPin,
    sizeof( gsSpiBoschPin ) / sizeof( IOPINCFG ),
    8000000,   // Speed in Hz
    8,      // Data Size
    5,      // Max retries
    SPIDATABIT_MSB,
    SPIDATAPHASE_SECOND_CLK, // Data phase
    SPICLKPOL_LOW,         // clock polarity
    SPICSEL_AUTO,
    6, //APP_IRQ_PRIORITY_LOW,      // Interrupt priority
    nullptr
};


SPI g_Spi;

Initialize the SPI interface

    // Initialize I2C
    g_Spi.Init(s_SpiCfg);

The SPI is now ready to use.   Now lets try to use it to read the Bosch BME280 device ID directly.

    uint8_t regaddr = 0xD0; // BME280 device ID register address
    uint8_t d;

    g_Spi.Read(0x76, // BME280 I2C Device address
               &regaddr, 1, // register address to read
              &d, 1 // buffer to return data
               );



    printf("BME280 device id = 0x%02x\r\n", d);

If the device is connected correctly, the variable d should contains the value 0x60 (96 decimal).

Luckily we don't need to manually reading data from the sensor that way.  The EHAL already have driver written for both the BME280 & the BM680.  Here is how to initialize the driver and read sensor data.


Configuring sensor drivers 


// Configure environmental sensor

// Configure BME680 for I2C
static TPHSENSOR_CFG s_Bme680SensorCfg = {
  BME680_I2C_DEV_ADDR0,   // I2C device address
SENSOR_OPMODE_SINGLE,
100, // Sampling frequency in Hz
1,
1,
1,
1
};

// BME680 Environmental sensor instance
TphgBme680 g_Bme680Sensor;



// Configure BME280 for SPI
static TPHSENSOR_CFG s_Bme280SensorCfg = {
  0,   // SPI chip select index
SENSOR_OPMODE_SINGLE,
100, // Sampling frequency in Hz
1,
1,
1,
1
};

// BME280 Environmental sensor instance
TphBme280 g_Bme280Sensor;

Initialization and reading sensor data

    // Intitialize BM680 using I2C interface
    g_Bme680Sensor.Init(s_Bme680SensorCfg, &g_I2c, NULL);
    
    // or
    // intitialize BM680 using SPI interface
    g_Bme680Sensor.Init(s_Bme680SensorCfg, &g_Spi, NULL);

    // Intitialize BM280 using SPI interface
    g_Bme280Sensor.Init(s_Bme280SensorCfg, &g_Spi, NULL);

    // Update sensor data
    TPHSENSOR_DATA tphdata;

    g_Bme680Sensor.StartSampling();
    g_Bme680Sensor.Read(tphdata);

    g_Bme280Sensor.StartSampling();
    g_Bme280Sensor.Read(tphdata);


Integrating into BLE advertising data

In the previous article, we advertising only a 32bits count.  Let replace the configuration now to advertise the sensor data instead

BLEADV_MANDATA g_AdvData

const BLEAPP_CFG s_BleAppCfg = {
    { // Clock config nrf_clock_lf_cfg_t
#ifdef IMM_NRF51822
        NRF_CLOCK_LF_SRC_RC, // Source RC
        1, 1, 0
#else
        0, 0, NRF_CLOCK_LF_ACCURACY_20_PPM
#endif
    },
    0, // Number of central link
    0, // Number of peripheral link
    BLEAPP_MODE_NOCONNECT// Connectionless beacon type
    DEVICE_NAME,            // Device name
    ISYST_BLUETOOTH_ID,     // PnP Bluetooth/USB vendor id
    1,                      // PnP Product ID
    0,       // Pnp prod version
    false,    // Enable device information service (DIS)
    NULL,     // Pointer device info descriptor
    (uint8_t*)& g_AdvData,   // Manufacture specific data to advertise
    sizeof(g_AdvData),      // Length of manufacture specific data
    BLEAPP_SECTYPE_NONE,    // Secure connection type
    BLEAPP_SECEXCHG_NONE,   // Security key exchange
    NULL,          // Service uuids to advertise
    0,     // Total number of uuids
    APP_ADV_INTERVAL,       // Advertising interval in msec
    APP_ADV_TIMEOUT_IN_SECONDS, // Advertising timeout in sec
    100,    // Slow advertising interval, if > 0, fallback to
    // slow interval on adv timeout and advertise until connected
    0,     // Min. connection interval
    0,     // Max. connection interval
    -1,     // Led port nuber
    -1,     // Led pin number
    0,     // Tx power
    NULL    // RTOS Softdevice handler

};

void ReadPTHData()
{
    TPHSENSOR_DATA data;

    g_TphSensor.Read(data);
    g_TphSensor.StartSampling();

    g_AdvData.Type = BLEADV_MANDATA_TYPE_TPH;

    // NOTE : M0 does not access unaligned data
    // use local 4 bytes align stack variable then mem copy
    // skip timestamp as advertising pack is limited in size
    memcpy(g_AdvData.Data, ((uint8_t*)&data) + 4, sizeof(BLEADV_MANDATA_TPHSENSOR));

    // Update advertisement data
    BleAppAdvManDataSet(g_AdvDataBuff, sizeof(g_AdvDataBuff));
}

void BlePeriphEvtUserHandler(ble_evt_t * p_ble_evt)
{
    if (p_ble_evt->header.evt_id == BLE_GAP_EVT_TIMEOUT)
    {
    // Update environmental sensor data every time advertisement timeout
    // for re-advertisement
      ReadPTHData();
    }
}

Source code at :https://github.com/IOsonata/IOsonata/blob/master/ARM/Nordic/exemples/TPHSensorTag.cpp
Complete Eclipse based project is on github:  nRF52 ProjectnRF51 Project. Same code works for both nRF51 & nRF52 series including nRF52840