Back to resources
HIL
Testing
Firmware

Catching Boot Regressions: UART Assertions in CI

The boot log is the firmware's first chance to tell you it's healthy. With an event-based UART session the BenchPod listens before the board powers on, asserts on the banner, then drives the console live.

Edward Viaene · June 15, 2026 · 3 min read

The serial console is where firmware says whether it came up clean — the boot banner, the self-test result, the "sensor not found" that wasn't there last release. On the bench you read it with your eyes. In CI you need to assert on it, and that turns out to have a subtle timing problem: the most interesting output happens in the first few milliseconds after power-on, before anything is listening.

The BenchPod solves it by listening first and powering on second.

The timing trap

If you power the target on and then open a UART capture, you've already missed the banner. The naive fix — open the capture fast and hope — is exactly the kind of race that makes a HIL test flaky.

The pod schedules the power-on pod-side and returns immediately, so your test can open the UART session before the board boots. The banner lands in a buffer that's already listening:

The pod exposes 12 generic LA channels (pins.pin_1pins.pin_12) with no fixed roles, so map your bench's wiring once and reference it everywhere:

from types import SimpleNamespace

@pytest.fixture
def wiring(pins):  # this bench's DUT-signal -> LA-channel map; edit for your board
    return SimpleNamespace(swclk=pins.pin_11, swdio=pins.pin_12,
                           uart_rx=pins.pin_5, uart_tx=pins.pin_4, efuse=pins.efuse)


def test_boots_clean(benchpod, wiring, firmware):
    benchpod.flash(file=firmware, target="target/stm32f4x.cfg",
                   swclk=wiring.swclk, swdio=wiring.swdio, target_power=wiring.efuse)

    # Schedule power-on 1.5s out (returns now), then start listening before it fires.
    benchpod.power_off(wiring.efuse)
    benchpod.power_on(wiring.efuse, delay=1.5)
    with benchpod.open_uart(rx=wiring.uart_rx, tx=wiring.uart_tx) as uart:
        assert uart.read_until(r"APP_OK", timeout=12), \
            f"firmware did not report APP_OK; captured:\n{uart.text}"

read_until blocks until the regex matches or the timeout expires, and uart.text gives you everything captured so far — so a failure message shows you the actual boot log, not just "timed out."

Drive the console, not just read it

A boot banner proves the firmware started. A lot of regressions hide one layer deeper — in the command interface. Because the UART session is a live, bidirectional link, the same test can talk to the firmware and check its replies:

    uart.drain()                  # discard anything buffered so far
    uart.write("ping\r\n")
    assert uart.expect("pong", timeout=4), \
        f"console did not answer ping; captured:\n{uart.text}"

drain clears the buffer so you're asserting on the response to your command, not leftover boot text. This is something a one-shot capture can't do — you're holding the link open and interacting on it.

When a one-shot capture is enough

Not every test needs an interactive session. For the common "power-cycle and check the banner" case there's a single call that schedules the power-on and captures in one shot:

cap = benchpod.power_cycle_and_capture(rx=wiring.uart_rx, tx=wiring.uart_tx,
                                       delay=1.5, duration=6.0, until=r"APP_OK")
assert cap.match("APP_OK")

Either way, the assertion runs the same on a pod on your desk or one in GitHub Actions — so a boot regression fails the build on the commit that caused it, instead of surfacing on someone's bench a week later. See the pytest framework docs for the fixtures and how to map your wiring.