t4as

System design · nRF9151 + nRF5340 + nRF52840 · 2026

Thingy:91 X ESL Gateway: BLE PAwR Sensors to MQTT over LTE-M

A complete sensor network on Nordic silicon. nRF52840 tags on a PAwR schedule, a Thingy:91 X gateway whose nRF5340 runs the BLE access point and whose nRF9151 runs everything else, and MQTT over LTE-M. I designed the split between the two gateway chips, the framed SPI link between them, and a firmware-update path that reaches a chip with no network of its own.

  • nRF9151
  • nRF5340
  • nRF52840
  • Zephyr
  • nRF Connect SDK
  • PAwR / PAST
  • MCUboot
  • MCUmgr SMP
  • MQTT
  • LTE-M / NB-IoT
chips in the chain, each updatable in the field through MCUboot
3
fixed SPI frame between the gateway chips, CRC-8, 7 frame types
520 B
lines in the six gateway modules I added on the nRF9151
8,300+
fault flags checked by the boot self-test
13
Nordic Thingy:91 X used as the cellular ESL gateway

What it is

Temperature tags that sleep on a Bluetooth LE Periodic Advertising with Responses (PAwR) train, and a gateway that manages them from the cloud. The gateway is a Nordic Thingy:91 X, which has two application processors: an nRF5340 for Bluetooth and an nRF9151 for LTE-M / NB-IoT. The project started as a fork of Nordic's Asset Tracker Template and became a system of three firmware images I maintain:

nRF52840 tag

DS18B20 temperature and battery on an ItsyBitsy nRF52840. ESL client on PAwR, a command shell over NUS, config in NVS, a history buffer, and MCUboot with signed BLE and USB updates. Released as v1.0.0 with seven on-hardware test scripts.

nRF5340: the radio

Runs the ESL access point on NCS 2.8: scanning, bonding, configuring tags, PAwR and PAST, NUS commands carried over PAwR. It handles the Bluetooth mechanics; what to do and when comes from the nRF9151 over SPI.

nRF9151: the brain

Runs LTE, MQTT, scheduling, the tag database, polling, persistence, self-test and every update path. Built on the Asset Tracker Template's SMF/zbus module framework (NCS 3.0).

System: MQTT broker, nRF9151, SPI link, nRF5340, PAwR, tags; with update paths MQTT broker commands in, JSON events out nRF9151 LTE-M, MQTT, scheduler tag DB, settings, selftest SPI master, SMP client ext. flash: update staging nRF5340 ESL access point PAwR, PAST, NUS SPI slave, SMP server shell, dummy backend tag tag tag LTE-M SPI +DRDY PAwR FIELD UPDATES nRF9151: HTTPS → external flash → MCUboot swap nRF5340: HTTPS to nRF9151 staging → MCUmgr SMP over SPI → MCUboot swap tag: MCUmgr SMP over BLE (or USB) → MCUboot swap
Figure 1. The three firmware images and how each one is updated in the field. The nRF5340 has no network connection of its own, so its updates travel through the nRF9151 and across the SPI link.

Where the logic lives

The first version had both chips being clever. The nRF5340 ran auto-onboarding and its own tag database, and the nRF9151 tried to follow along by parsing text. I moved every product decision to the nRF9151: which tags to accept, when to scan and poll, what gets reported and persisted. The nRF5340 keeps only the Bluetooth mechanics. That one choice shaped the rest of the design.

DecisionReason
All scheduling, polling and state on the nRF9151Feature changes touch one firmware image, which is the one with a network connection and HTTPS updates.
nRF5340 always onIt has to keep the PAwR train running, or every tag loses sync. Its BLE controller runs on the network core, so nothing on the application core can delay the train.
Commands stay as the ESL shell text (esl_c …)The same commands I use over USB while developing drive the product. On the nRF5340, SPI frames go into Zephyr's dummy shell backend, so there is one parser and no second protocol to keep in sync.
Events are #-prefixed linesEasy to grep in a log, easy to parse on the nRF9151, and easy to forward to MQTT unchanged when debugging. The event format is specified in a 1,000-line protocol document.
Tag names, poll interval, expected tag count and broker settings persistedA reboot or an update restores the network without anyone reconfiguring it over MQTT.

From a text UART to a framed SPI link

The chips were first connected over a 115200-baud UART carrying lines of text. It had no framing, no way to match a response to its command, a 128-byte line buffer that long ESL output overflowed, and no way to tell whether the other chip was alive. Firmware updates over that link were not realistic. I replaced it with SPI and a fixed frame.

520-byte SPI frame layout 0xA5MAGIC TYPE1 B LEN2 B, LE PAYLOAD512 B, zero-padded: shell text or raw SMP CRC80x07 rsv3 B 0124516517 types: CMD 0x01 · CMD_RESP 0x02 · NOTIFY 0x03 · READ_REQ 0x04 · NOOP 0x05 · SMP 0x06 · SMP_FWD 0x07
Figure 2. Every transaction is exactly 520 bytes in both directions, so the slave can arm its DMA before it knows what is coming. The CRC covers bytes 0–515.

The nRF9151 is the only master. The nRF5340 cannot start a transfer, so it raises a DATA_READY line when it has something to say. Before every transaction the slave pre-loads its transmit buffer, in priority order: firmware-update responses first, then queued events, then an idle frame. Each exchange carries data both ways, so a command and a queued event can cross in the same transfer.

The rewiring cost nothing. CS and DATA_READY use the two pins the UART's TX and RX lines used before, and the protocol document gives the pin map for both chips. The slave loop is a single high-priority thread blocked in spi_transceive(), so the nRF5340 sleeps until the master clocks a frame.

One detail in the code on the right: the nRF9151 triggers on the rising edge of DATA_READY. If the slave loaded a new frame while the line was already high, the master would never see a new edge. So the line is always pulled low first, and it goes high only after the DMA buffer is armed.

/* central_esl/src/spi1_manager.c (trimmed) */
/* Priority: SMP response > NOTIFY > NOOP.
 * Assert DATA_READY AFTER loading so nRF9151
 * never sees DATA_READY before EasyDMA is armed. */
if (smp_len > 0) {
    frame_build(tx_buf, FRAME_SMP, smp_tmp, smp_len);
    have_data = true;
}
...
if (!have_data) {
    have_data = load_tx_notify();
}

/* Always deassert first so nRF9151 gets a clean
 * rising edge on the *next* assert. */
gpio_pin_set_dt(&data_ready_gpio, 0);
if (have_data) {
    k_sleep(K_USEC(10));  /* min pulse width */
    gpio_pin_set_dt(&data_ready_gpio, 1);
}
/* block until the master clocks 520 bytes */
spi_transceive(...);

Updating a chip that has no network

A gateway in the field has to update both of its chips without a cable. The nRF9151 can download over HTTPS, but the nRF5340 has no network connection of its own. So I made the nRF9151 an MCUmgr client and carried standard SMP packets inside the SPI frames.

  1. Stage. An MQTT command starts an HTTPS download of the signed nRF5340 image into a dedicated partition on the nRF9151's external flash. A second staging slot is reserved for tag images.
  2. Upload. spi_dfu.c reads it back in 256-byte chunks, CBOR-encodes SMP image-upload requests and sends them as FRAME_SMP. Each chunk waits for DATA_READY and uses the offset the nRF5340 reports back as the next offset. It refuses to go backwards, with a 30 s timeout and 3 retries per chunk.
  3. Share the bus. Normal command and event traffic keeps running between chunks. The SPI bus sits behind a mutex that the update client holds for one chunk at a time.
  4. Swap. Image list → slot-1 hash → mark for test → reset. MCUboot on the nRF5340 swaps images and can revert if the new image doesn't confirm itself.

The nRF9151 updates itself over HTTPS with Nordic's fota_download, and progress is reported over MQTT. For bench work there is a second frame type, SMP_FWD, that runs the other way: the nRF5340 exposes a second USB CDC port and relays standard mcumgr traffic from a PC to the nRF9151 over SPI. The nRF5340 relay is done, and the nRF9151 end is the next piece.


The tag: keeping a bit-banged sensor off the radio's timing

The hardest problem on the tag had nothing to do with Bluetooth code. Tags would drop PAwR sync after a random number of good events (14, 42, 212, 213, 385 in the logs), always while the gateway was still transmitting. The cause was the DS18B20 temperature read.

1-Wire is bit-banged with microsecond timing, so the driver has to block interrupts. Zephyr's upstream driver uses irq_lock() for whole operations. That holds for about 960 µs on a bus reset, far above the ~40 µs the nRF52840's radio scheduling layer (MPSL) tolerates for its low-priority interrupt. With the BLE stack's zero-latency interrupts enabled, irq_lock() is a BASEPRI mask. The radio's priority-0 interrupt could therefore still fire inside the timed windows and stretch them. The DS18B20's sample point moved, and reads came back as all zeros (ROM family 0x00 instead of 0x28).

I rewrote the driver so that only the GPIO edges run with interrupts masked (PRIMASK, under 1 µs each). The long low and recovery periods run unlocked, and the radio can pre-empt them without breaking the protocol. The one part that can't be split is a read bit, which takes up to 18 µs. So the second half of the fix is scheduling: a full read (~770 ms) only starts in a safe window after a PAwR anchor, and finishes well before the next one.

/* nrf-esl-bluetooth/service/esl.c (trimmed) */
#define PAWR_POST_ANCHOR_GUARD_MS 30  /* recv_cb + rsp slot */
#define PAWR_PRE_ANCHOR_GUARD_MS 900  /* read ~770 ms + slack */

uint32_t bt_esl_pawr_align_delay_ms(void)
{
    if (esl_obj_l->esl_state != SM_SYNCHRONIZED)
        return 0;
    int64_t elapsed = (k_uptime_get() - pawr_anchor_time_ms)
                      % (int64_t)pawr_interval_ms;

    if (elapsed >= PAWR_POST_ANCHOR_GUARD_MS &&
        elapsed <= pawr_interval_ms - PAWR_PRE_ANCHOR_GUARD_MS)
        return 0;                 /* inside the safe window */

    return pawr_interval_ms - elapsed
           + PAWR_POST_ANCHOR_GUARD_MS;  /* just after next anchor */
}
One 1760 ms PAwR interval with guard bands and the sensor read placed after the anchor anchor next anchor safe start window pre-anchor guard 900 ms: no read may start DS18B20 scratchpad read, ~770 ms 1-Wire read ~770 ms 030 ms8601760 ms
Figure 3. One PAwR interval at the production 1.76 s profile, to scale. A read that starts anywhere in the green window ends before the next anchor.

The tag also has two watchdogs for states that the BLE stack alone does not recover from. If no periodic packet arrives for 45 s, the tag drops sync and advertises again, instead of waiting out the controller's 150 s supervision timeout. If PAST completes but the sync callback never arrives, a 30 s timer takes the tag back to advertising. Without that timer, the gateway believes the tag is synchronised while the tag waits indefinitely.


Running it from the cloud

  • MQTT command set. Scanning, listing, polling and naming tags, sensor configuration, broker settings (host, port, credentials; persisted, applied with mqtt_restart), location, time sync, both firmware updates, and a raw passthrough to the nRF5340 shell for debugging.
  • Tag lifecycle as events. esl_tag_found, …_configured, …_disconnected and esl_sensor_data go out as JSON at QoS 1. A discovery loop rescans until the expected number of tags is connected.
  • Time. When LTE provides network time, the nRF9151 pushes the epoch to the nRF5340, which broadcasts it to synced tags, so their history records carry real timestamps.
  • Boot self-test. Read-only checks of SIM, modem firmware, registration, signal (RSRP/RSRQ), PSM/eDRX grants, IP and DNS, with 13 fault flags. The result goes out as an MQTT report once connected, and as an RGB-LED blink code for the person holding the board.
  • Staying reachable. MQTT reconnects with exponential backoff up to 5 minutes. If nothing has been published for an hour, the gateway records the reason in flash, cold-reboots, and reports reboot_reason after it reconnects.
  • Power modes. PSM and eDRX can be switched at runtime. They trade battery life against how quickly a command reaches the device, since a modem in PSM does not receive MQTT until it wakes.

Next: moving the access point to nRF54L15

The next step is to move the access point from the nRF5340 to a single-chip nRF54L15. That port turned into a debugging project of its own, with a seven-radio sniffer and a 20-build reproducer. It is written up in One Packet, Then Silence.

Early version

The first version of the gateway, before PAwR, read BLE sensors over plain connections and forwarded them over UART:

Play: Early version: Thingy:91 X collecting data from external BLE sensors
Early version: Thingy:91 X collecting data from external BLE sensors