
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
OKorERRwith 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:
- Modern C++ Firmware: Proven Strategies for Tiny, Critical Systems (Part 1/10)
- The Case for Modern C++ on Tiny, Safety Critical Targets
- Modern C++ Firmware: Proven Strategies for Tiny, Critical Systems (Part 2/10)
- Choosing C++20 Today, C++23 on a Short Leash
- Modern C++ Firmware: Proven Strategies for Tiny, Critical Systems (Part 3/10)
- Deterministic By Construction: The Rules You Do Not Cross
- Modern C++ Firmware: Proven Strategies for Tiny, Critical Systems (Part 4/10)
- Time and Scheduling Without Footguns
- Modern C++ Firmware: Proven Strategies for Tiny, Critical Systems (Part 5/10)
- Concepts for Hardware Platforms, Not Vtables
- Modern C++ Firmware: Proven Strategies for Tiny, Critical Systems (Part 6/10)
- No Allocation in the Loop: Memory Rules That Survive CI
- Modern C++ Firmware: Proven Strategies for Tiny, Critical Systems (Part 7/10)
- Test the Firmware Without the Board: Host First Strategy
- Modern C++ Firmware: Proven Strategies for Tiny, Critical Systems (Part 8/10)
- Python and ASCII Protocols for Hardware in the Loop
- Modern C++ Firmware: Proven Strategies for Tiny, Critical Systems (Part 9/10)
- Observability Belongs on the PC, Not in the Production Binary
- Modern C++ Firmware: Proven Strategies for Tiny, Critical Systems (Part 10/10)
- GitLab Pipeline Blueprint and a Migration Checklist
Need professional firmware development help? Engage with Polyrhythm
Discover more from John Farrier
Subscribe to get the latest posts sent to your email.