Automatically Detecting Boards for Hardware-in-the-Loop (HIL) Testing
Golioth is an IoT company that supports as much custom hardware as possible: a multitude of microcontrollers and many different connection types. This presents a challenge when testing on real hardware.
We developed tooling that tests the Golioth Firmware SDK on actual boards. Known as Hardware-in-the-Loop (HIL) testing, it’s an important part of our CI strategy. Our latest development is automatically detecting boards connected to our CI machines; these can recognize what type of hardware is attached to each self-hosted test runner. In a nutshell, you plug in the board to USB and run a workflow to figure out what you just plugged it.
Let’s take a look at how we did that, and why it’s necessary.
Goals and Challenges of Hardware Testing
So the fundamental goals here are:
- Run tests on actual hardware
- Make the number of tests scalable
- Make the number of hardware types scalable
If we were running all of our firmware tests manually, we’d be fine on goal #1. But when scaling and repeatability comes into play, we start to look at automation. In particular, we look at making it easy to create more of the self-hosted runners (the computers running the automated test programs).
Adding a new board to one of those runners is no small task. If you build a self-hosted runner today, then next week add two new hardware variations that need testing, how will the runner know how to interface with those boards?
Frequent readers of the Golioth blog will remember that we already set up self-hosted runners, so go back and look the first post and second post in this series. Those articles detail how we are using GitHub’s self-hosted runner infrastructure to use workflows to perform Hardware-in-the-Loop tests. I also did a Zephyr tech talk on self-hosted runners.
To make things more scalable, we developed a workflow that queries all of the hardware connected to a runner. It runs some tests to determine the type of hardware, the port, and the programmer serial number (or other unique id) of that hardware. This information is written to a yaml file for that runner and made available to all other workflows that need to run on real hardware.
Recognizing Microcontroller Boards Connected Via USB
Self-hosted runners are nothing more than a single-board computer running Linux and connected to the internet (and in our case connected to GitHub). These runners are in the home offices of my colleagues around the world, but it doesn’t matter where they are. Our goal is that we don’t need to touch the runners after initial set up. If we add new hardware, just plug in a USB cable and the hardware part of the setup is done.
The first step in recognizing what new hardware has been plugged in is listing all the serial devices. Linux provides a neat little way of doing this by the device ID:
golioth@orangepi3-lts:~$ ls /dev/serial/by-id -1a . .. usb-SEGGER_J-Link_000729255509-if00 usb-SEGGER_J-Link_001050266122-if00 usb-SEGGER_J-Link_001050266122-if02 usb-Silicon_Labs_CP2102N_USB_to_UART_Bridge_Controller_4ea3e6704b5fec1187ca2c5f25bfaa52-if00-port0
This represents three devices: Nordic, NXP, and Espressif. For now we assume the SiLabs USB-to-UART is an Espressif device because we don’t have any other devices that use that chip. However, there are multiple SEGGER entries, so we need to sort those out. We also need to know which type of ESP32 board is connected.
We use regular expressions to pull out the unique serial numbers from each of these and perform the board identification in the next step.
Using J-Link to Identify Board Type
Boards that use J-Link include a serial number in their by-id
listing that can be used to gather more information. This works great for Nordic development boards. It works less great for NXP boards.
from pynrfjprog import LowLevel from board_classes import Board chip2board = { # Identifying characteristic from chip: Board name used by Golioth LowLevel.DeviceName.NRF9160: "nrf9160dk", LowLevel.DeviceName.NRF52840: "nrf52840dk", LowLevel.DeviceName.NRF5340: "nrf7002dk" } def get_nrf_device_info(snr): with LowLevel.API() as api: api.connect_to_emu_with_snr(snr) api.connect_to_device() api.halt() return api.read_device_info() def nrf_get_device_name(board: Board): with LowLevel.API() as api: snr_list = api.enum_emu_snr() if board.snr not in snr_list: return None try: device_info = get_nrf_device_info(board.snr) if not device_info: autorecognized = False else: for i in device_info: if i in chip2board: board.name = chip2board[i] autorecognized = True except Exception as e: print("Exception in find_nrf_device():", e) autorecognized = False return autorecognized
Nordic provides a Python package for their nrfjprog
tool. Its low-level API can read the target device type from the J-Link programmer. Since we already have the serial number included in the port listing, we can use the script above to determine which type of chip is on each board. For now, we are only HIL testing three Nordic boards and they all have different processors which makes it easy to automatically recognize these boards.
An NXP mimxrt1024_evk board is included in our USB output from above. This will be recognized by the script but the low-level API will not be able to determine what target chip is connected to the debugger. For now, we take the port and serial number information and manually add the board name to this device. We will continue to refine this method to increase automation.
Using esptool.py to Guess Board Type
Espressif boards are slightly trickier to quantify. We can easily assume which devices are ESP32 based on the by-id path. And esptool.py
– Espressif’s software tool for working with these chips – can be used in a roundabout way to guess which board is connected.
import esptool from board_classes import Board chip2board = { # Identifying characteristic from chip: Board name used by Golioth 'ESP32': 'esp32_devkitc_wroom', 'ESP32-C3': 'esp32c3_devkitm', 'ESP32-D0WD-V3': 'esp32_devkitc_wrover', 'ESP32-S2': 'esp32s2_saola', 'ESP32-S3': 'esp32s3_devkitc' } ## caputure output: https://stackoverflow.com/a/16571630/922013 from io import StringIO import sys class Capturing(list): def __enter__(self): self._stdout = sys.stdout sys.stdout = self._stringio = StringIO() return self def __exit__(self, *args): self.extend(self._stringio.getvalue().splitlines()) del self._stringio # free up some memory sys.stdout = self._stdout ## end snippet def detect_esp(port): try: with Capturing() as output: esptool.main(["--port", port, "read_mac"]) for line in output: if line.startswith('Chip is'): chip = line.split('Chip is ')[1].split(' ')[0] if chip in chip2board: return chip2board[chip] else: return chip except Exception as e: print("Error", e) return None def esp_get_device_name(board: Board): board.name = detect_esp(board.port)
One of the challenges here is that esptool.py
doesn’t include an API to simply return the chip designation. However, part of the standard output for any command includes that information. So we listen to the output when running the read_mac
command and use simple string operations to get the chip designation.
golioth@orangepi3-lts:~$ esptool.py read_mac esptool.py v4.6.2 Found 4 serial ports Serial port /dev/ttyUSB0 Connecting.... Detecting chip type... Unsupported detection protocol, switching and trying again... Connecting..... Detecting chip type... ESP32 Chip is ESP32-D0WD-V3 (revision v3.0) Features: WiFi, BT, Dual Core, 240MHz, VRef calibration in efuse, Coding Scheme None Crystal is 40MHz MAC: 90:38:0c:eb:0a:28 Uploading stub... Running stub... Stub running... MAC: 90:38:0c:eb:0a:28 Hard resetting via RTS pin...
While this doesn’t tell us what board is connected, a lookup table of the ESP32 dev boards Golioth commonly tests makes it easy to identify the board. I feel like there should be a way to simply use the mac address to determine the chip type (after all, esptool.py is doing this somehow) but I haven’t yet found documentation about that.
Using Board Configuration
Now we know the board name, serial port, and serial number for each connected board. What should be done with that information? This is the topic of a future post. Generally speaking, we write it to a yaml file in the repository that is running the recognition workflow.
In a future workflow, that information is written back to the self-hosted runner where it can be used by other workflows. And as part of that process, we assign labels such as has_nrf52840dk
so GitHub knows which runners to use for any given test.
Take Golioth for a Test Drive
We do all this testing so that you know the Golioth platform is a rock solid way to manage your IoT fleet. Don’t reinvent the wheel, give Golioth’s free Dev Tier a try and get your fleet up and running fast.
Start the discussion at forum.golioth.io