Modern C++ Firmware: Proven Strategies for Tiny, Critical Systems (Part 8/10)

Python and ASCII Protocols for Hardware in the Loop

Part 7 pushed as much validation as possible onto the host: fast tests, deterministic scenarios, sanitizers, and coverage. That is where most bugs should die.

Part 8 covers the bugs that do not.

Some failures only show up on real hardware:

  • ISR timing interactions and DMA corner cases
  • pin mux mistakes and board wiring realities
  • analog input behavior, noise, and thresholds
  • brownout, clock tolerance, and peripheral quirks

You still want these tests to be automated, repeatable, and CI-friendly. The best pattern I know is a dedicated hardware test firmware plus a Python pytest harness that drives it over a simple protocol.

The boundary: production firmware vs test firmware

A common mistake is trying to turn production firmware into a lab tool. That usually bloats the binary and complicates safety arguments.

Instead, build a separate test image:

  • Production firmware: minimal, deterministic, mission behavior only.
  • IoTest firmware: exposes explicit test commands, instrumented for bench work, not for production.

The test image should be easy to flash, easy to identify, and safe by default.

Unsolicited advice: make “test firmware” a first-class build target. If it is a hacky branch, it will rot.

Why ASCII is often the right protocol for HIL

Binary protocols are efficient, but HIL testing cares more about debuggability than bandwidth.

ASCII command protocols have advantages:

  • You can type commands manually during bring-up.
  • Logs are readable without tooling.
  • Failures are easier to triage remotely.
  • The protocol is stable and easy to extend.

If you do need a binary format later, add it as a separate variant. Do not over-rotate on “efficient” before you have “reliable.”

The HIL architecture

The model is simple:

  • A firmware image that implements a command parser and exposes controlled operations.
  • A Python client that sends commandsAS and reads responses.
  • A pytest suite that expresses hardware checks as real tests.

The firmware does not need to be smart. The firmware needs to be deterministic, safe, and observable.

IoTest

IoTest is a name I made up for a specific type of firmware build. IoTest is a dedicated “hardware exercise” firmware image whose only job is to make the board testable. Instead of bloating production firmware with bench-only commands and debug behavior, you build a separate variant that exposes a small, explicit command interface (often ASCII over UART) for setting outputs, reading inputs, checking health, and resetting the device to a known safe state. The benefit is repeatable hardware validation that can be scripted (for example with Python and pytest) while keeping the real flight or production binary minimal, deterministic, and easier to certify.

What the IoTest firmware should expose

Keep the command set small and explicit. Typical categories:

  • Identity and health
    • version, uptime, build id
    • self-test status, fault counters
  • Digital outputs
    • set/clear output channels, read back state
  • PWM outputs
    • set duty cycle, read back configured duty
  • Digital inputs
    • read current state, edge counters if useful
  • Analog inputs
    • read raw ADC counts, optionally scaled values
  • Bulk queries
    • read all inputs or outputs in one response
  • Error handling
    • invalid command behavior, range checking, timeouts

The key is that the firmware is an interface to hardware capabilities, not a second copy of production logic.

One tight example: the pytest client and a test

This is the minimum you need: a thin client that sends a command and returns a response, plus a test that asserts behavior.

import pytest
import serial

class IoTestClient:
    def __init__(self, port: str, baud: int = 115200, timeout: float = 1.0):
        self._ser = serial.Serial(port, baudrate=baud, timeout=timeout)

    def cmd(self, s: str) -> str:
        self._ser.write((s.strip() + "\n").encode("ascii"))
        line = self._ser.readline().decode("ascii", errors="replace").strip()
        return line

@pytest.mark.hil
def test_set_discrete_output(serial_port):
    dut = IoTestClient(serial_port)
    resp = dut.cmd("SET DO1 ON")
    assert resp.startswith("OK"), resp
    resp = dut.cmd("GET DO1")
    assert resp == "DO1=ON"

A few practical points:

  • Keep responses single-line and explicit.
  • Have firmware return OK or ERR with a short reason.
  • Make commands idempotent when possible.
  • Default outputs to safe states on boot and expose a reset command.

Unsolicited advice: do not build a “smart” parser. Build a boring parser with strict validation and predictable errors.

Making HIL tests stable and repeatable

HIL tests fail for reasons unrelated to firmware quality: flaky cables, boards in weird states, ports changing, timing issues in the host.

Stability practices:

  • Provide a reset command that sets the device to a known safe state.
  • Use pytest fixtures that reset before and after each test.
  • Group tests with markers (hil, inputs, outputs, pwm, analog) so you can run subsets.
  • Keep timing generous. HIL tests are not microbenchmarks.
  • Prefer read-back assertions (set something, then query state) rather than relying on host timing.

Avoid tests that depend on human wiring unless you can detect wiring state via inputs.

CI integration: don’t pretend every runner has hardware

HIL testing in CI is possible, but it is not the same as host tests.

You have two realistic options:

  • Dedicated HIL runner: a GitLab runner physically connected to the board(s).
  • Manual or scheduled HIL: a pipeline job that runs only on demand or nightly, with a clear “requires hardware” label.

What you should not do:

  • Block every merge on HIL if hardware availability is limited. That encourages bypass behavior.
  • Never run HIL at all. That guarantees board-specific regressions.

A pragmatic approach:

  • Host tests and target builds block merges.
  • HIL runs on a schedule, and also on release candidates and hardware-related changes.

Where HIL fits in the quality pyramid

The pyramid should look like this:

  • Most checks: host unit tests, sanitizers, coverage (fast, cheap, deterministic)
  • Fewer checks: target builds, size gates, static analysis (still cheap)
  • Fewest checks: HIL tests (slow, limited hardware, highest value per test)

HIL is for validating hardware truths and integration, not for catching basic logic bugs.

Minimal checklist

  • Separate IoTest firmware image, not a debug mode bolted into production firmware.
  • Simple ASCII protocol with strict validation and explicit responses.
  • Python pytest harness with fixtures to reset device state.
  • Tests categorized by markers so they can be run selectively.
  • Dedicated hardware runner or scheduled pipeline integration.
  • Use HIL for what only hardware can tell you.

Part 9 will cover observability without firmware bloat: how to keep target telemetry minimal and push heavy tracing, visualization, and analysis onto the host where it belongs.

The Complete “Modern C++ Firmware” Series:


Need professional firmware development help? Engage with Polyrhythm


Discover more from John Farrier

Subscribe to get the latest posts sent to your email.

Leave a Reply

Discover more from John Farrier

Subscribe now to keep reading and get access to the full archive.

Continue reading