t4as

Embedded & Firmware · 2026

Pro Micro Maintenance Remote: Signed Updates and a PAwR Sensor Network

A 240×240 display and six buttons on a nice!nano-class nRF52840, turned into the start of a field tool for PAwR sensor networks. It has two independent update paths, watchdog rollback and switched-VCC power gating, and it joins the same ESL network as the production sensors, answering the gateway as reliably as they do.

  • nRF52840
  • nRF5340
  • Zephyr
  • nRF Connect SDK
  • MCUboot
  • MCUmgr
  • PAwR
  • ESL
  • BlueZ
signed BLE update of 260 KB, upload to confirmed
55 s
PAwR status requests answered by our tag (production tag: 17/18)
18/18
independent update paths, both run on the board
2

Result

The end goal is a maintenance remote. Someone who has just installed a sensor network walks the site with a small device that has a screen and buttons, joins the network, and checks every sensor from the network itself: no laptop, no cloud. This page covers the foundation, all of it run on the hardware with logs behind every number:

  • Two ways to update. Normal updates go over BLE on an authenticated, bonded link: the device shows a passkey on its screen, the host types it. If the application is broken, MCUboot’s own USB recovery takes over, and it needs nothing from the app. An image that never confirms itself is reset by the watchdog and rolled back.
  • Power gating without back-powering the panel. The board’s switched VCC rail is cut when the screen idles, and every line into the panel is released first.
  • It is a sensor on the network. The production ESL tag stack now runs inside the app. On the same gateway, with the same procedure, it answers as reliably as the production sensor.

The hardware

Part Detail
Pro Micro nRF52840 clone nice!nano v2 class: Li-Po charger, switched VCC on P0.13
GMT130-V1.0 (ST7789) 240×240 in a 320-row GRAM; SPI mode 3, no CS, no MISO
6 push buttons two softkeys + d-pad, old-phone style
nRF5340 Audio DK PAwR gateway on the bench, second J-Link

One bring-up detail: the panel is mounted upside down, and MADCTL 0xC0 alone put the image one 80-row band off, with noise in the rows that were never written. At that rotation the visible window is GRAM rows 80 to 319, so the write window needs y-offset = <80>.

Cutting power under a live bus

The rail manager refuses to cut if any consumer couldn’t let go (src/power/ext_power.c):

SYS_SLIST_FOR_EACH_CONTAINER(&consumers, c, node) {
	if (c->suspend) {
		int rc = c->suspend();

		if (rc < 0) {
			/* Lines may still be driven: never cut under them. */
			LOG_ERR("%s suspend failed (%d); rail stays on", c->name, rc);
			if (c->resume) {
				(void)c->resume();
			}
			return rc;
		}
	}
}

On the board the screen blanks after 60 s idle and the rail drops 10 s later, and the P0.13 polarity turned out as the nice!nano v2 design says: high means on.

Two ways to update

The update stack is a reusable Zephyr module, taken out of the radar display project: MCUboot with swap and rollback, MCUmgr, and a boot-health gate. A new image is confirmed only by the application, after a health window, and only if the update transport is up. Two things changed on the way to this board.

The watchdog now checks the application, not only the kernel. It used to be fed from the system work queue, so a hung main loop never reset the board. Now it is fed only while the loop keeps checking in.

BLE pairing works on Linux. The radar project stopped at Just Works pairing: BlueZ’s default agent turns it into a numeric-comparison prompt that nobody answers, and the pairing fails with reason 0x0C. This board has a screen, so it can use a stronger method. The device is DisplayOnly and shows a 6-digit passkey; the host types it (Passkey Entry, LE Secure Connections). A small D-Bus agent types a fixed passkey on the bench. The result is an encrypted, bonded link at security level 4, and the bond survives every update and reboot.

Timeline of one BLE update of 260 KB, to scale 0102030405055 s 0–22.7 s: upload over the bonded BLE link, 11.2 KiB/s upload 22.7 s hash checked, marked for test, reset MCUboot swaps the images and the new app boots; no radio until it advertises again swap + boot: 31 s from reset to reconnect 55 s: new image active and confirmed by its own health gate Measured by the host tool (ble_update.py) on 2026-09-30; the reset point is placed from the two measured durations.
Figure 1. Update 2.0.1 → 2.0.2 over BLE. Most of the time is MCUboot moving 260 KB between the slots, with the radio silent. The image counts as done only when the device reports it confirmed, which its own health gate does 5 s after boot.

One tool on the way reported “Upgrade complete.” and changed nothing. smpmgr upgrade (0.19) marks an image for test only if you pass a non-default slot or --confirm. By default it uploads and resets, and the device keeps running the old image; with --confirm it skips the rollback safety altogether. Reading back the image state caught it. Our host tool checks the uploaded hash, marks the image for test, and then waits for the device’s own verdict (tools/ble_update.py):

slot1 = [i for i in await states(c) if i.slot == 1]
if not slot1 or bytes(slot1[0].hash) != want:
    print("ERROR: slot 1 hash does not match the image", file=sys.stderr)
    return 1

r = await c.request(ImageStatesWrite(hash=want, confirm=False))
if error(r):
    print(f"ERROR: mark for test refused: {r}", file=sys.stderr)
    return 1
print("marked for test:\n" + fmt(await states(c)))
await c.request(ResetWrite())

The rest, measured on the board:

Path Result
Rollback drill An image that never confirms: watchdog reset, MCUboot restores the previous one, reset cause WATCHDOG on the device’s STATUS screen
MCUboot USB recovery Reset pin, or a 1200-baud open of the app’s port (retained boot mode). 260 KB in 221 s: slow, but it needs no app
USB update inside the app mcumgr over the app’s CDC port, same test swap and health gate

The safety nets also caught two real bugs in the first hours. A USB driver thread overflowed its 512-byte stack on the first real enumeration; hardware stack protection turned what would have been a silent hang into a logged, reproducible boot loop. Later, a first ESL image crashed during start-up, never confirmed itself, and MCUboot put the previous firmware back without help.

Joining the PAwR sensor network

The sensors in the field speak ESL (the Bluetooth Electronic Shelf Label profile) over Periodic Advertising with Responses, with a Thingy:91 X as the gateway. I ran the gateway firmware on an nRF5340 Audio DK for the bench. The Thingy:91 X configuration is applied first, then a six-line delta that swaps the nRF9151 SPI link for a serial console. Diffing the final configuration of both builds confirmed the delta. It also caught a first build that had silently dropped the whole radio-core image, because the Audio DK board adds none by default.

PAwR has broken on unrelated-looking changes before (see the PAwR debugging case study), so each step had a baseline and one change:

  1. A production sensor joins the bench gateway, measured.
  2. The production tag stack runs inside our app: a byte-for-byte copy of the ESL service, plus three documented shims for the newer Zephyr, all measured with the same procedure.

One shim is worth showing. Zephyr 4.2 changed the state-machine callback type, and the old ESL code still compiles into undefined behaviour with only a warning. Rather than silence the warning, I checked that ESL never calls those handlers and stored NULL (compat/smf_compat.h):

/*
 * esl-tag port (Zephyr 4.2 / NCS 3.2.4): state_execution now returns
 * enum smf_state_result, while the ESL service run handlers return void.
 * Calling a void function through a value-returning pointer is undefined
 * behaviour (the caller reads a garbage result). ESL never calls
 * smf_run_state() - only smf_set_initial()/smf_set_state() (checked
 * 2026-09-30) - so the run handlers are dead code: store NULL instead.
 */
#define SMF_CREATE_STATE(_entry, _run, _exit) \
	{ \
		.entry = _entry, \
		.run = NULL, /* esl-tag: see note above */ \
		.exit = _exit, \

Same gateway, same procedure (18 status requests over PAwR, one every 10 s):

Production sensor Our app with the tag role
Answered 17/18 18/18
Round trip, min / avg / max 2.0 / 2.8 / 3.6 s 2.0 / 2.7 / 3.6 s

Two results changed how I measure.

The logger dropped lines, not the radio. A five-minute debug capture showed the tag missing 82 PAwR events in a row, while the gateway got every answer in the same minutes. The log was dropping lines under load. Loss is now counted in RAM on the device by a second sync listener, next to the ESL stack’s own, and shown on the remote’s LINK screen:

uint16_t ev = info->periodic_event_counter;

if (link.have_ev) {
	uint16_t step = (uint16_t)(ev - link.last_ev);

	if (step > 1U && step < 0x8000U) {
		link.missed += step - 1U;
	}
}
link.last_ev = ev;
link.have_ev = true;
if (buf == NULL) {
	link.rx_fail++;
} else {
	link.rx++;
	link.rssi = info->rssi;
}

Updating a deployed sensor. The tag updated over USB, kept its bond and its network address, and waited to be picked up. The gateway declared it lost after two missed poll cycles and rescanned, but with the low-power scan profile it uses in the field, and it found nothing. A normal enrolment scan found the tag in 0.2 s, and it was back on the network without reconfiguration. That is a gateway change for the list, found on the bench instead of on site.

Where it goes next

  • Both update paths and the network role in one image. The update path pairs with a passkey, the network pairs with Just Works, and both keep their bonds in the same store. The ESL stack treats any stored bond as its gateway, so this needs a small design of its own.
  • The remote’s screens. HOME answers “is the network OK?”, a sensor list answers “which one?”, and a sensor view answers “why?”, with identify and read-now actions. The gateway will share its sensor registry with the remote over the same PAwR network.
  • Current measurements in each power state, with a PPK2.