Guides

Building a pyserial Plug for Serial Devices

Talk to a DUT or instrument over a UART from an OpenHTF plug with pyserial — port discovery, command/response with timeouts, line framing, reading a serial number and firmware version into measurements, and capturing a boot log as an attachment.

Last updated · Verified with OpenHTF 1.6.1

The most common DUT interface on a production line is a UART: a debug console, a bootloader, an AT-command modem, a proprietary ASCII protocol. pyserial opens the port; an OpenHTF plug owns it for the duration of the test.

Terminal
pip install pyserial

Find the port

list_ports.py
from serial.tools import list_ports

for p in list_ports.comports():
    print(p.device, p.vid, p.pid, p.serial_number, p.description)
Terminal
/dev/ttyUSB0 4292 60000 A50285BI USB Serial
COM3 1027 24577 FT5ZQ7X8 USB Serial Port (COM3)

Prefer selecting by USB serial_number (the adapter's, not the DUT's): /dev/ttyUSB0 and COM3 change when cables move.

The plug

dut_uart.py
import re
import time

import serial
from serial.tools import list_ports
from openhtf.core import base_plugs


class DutUart(base_plugs.BasePlug):
    """ASCII command/response console on the DUT's debug UART."""

    def __init__(self, port: str, baudrate: int = 115200, adapter_serial: str | None = None):
        if adapter_serial:
            port = self._port_for_adapter(adapter_serial)
        self._ser = serial.Serial(port, baudrate, timeout=1)   # read timeout, seconds
        self._ser.reset_input_buffer()
        self.logger.info("Opened %s @ %d", port, baudrate)

    @staticmethod
    def _port_for_adapter(adapter_serial: str) -> str:
        for p in list_ports.comports():
            if p.serial_number == adapter_serial:
                return p.device
        raise RuntimeError(f"No serial adapter with serial {adapter_serial!r}")

    def command(self, cmd: str, timeout_s: float = 2.0) -> str:
        """Send `cmd`, return everything up to the next prompt."""
        self._ser.reset_input_buffer()
        self._ser.write((cmd + "\r\n").encode())
        deadline = time.monotonic() + timeout_s
        lines = []
        while time.monotonic() < deadline:
            line = self._ser.readline().decode(errors="replace").rstrip("\r\n")
            if line == ">":                 # DUT prompt: response complete
                break
            if line and line != cmd:        # drop echo
                lines.append(line)
        else:
            raise TimeoutError(f"No prompt after {cmd!r}")
        response = "\n".join(lines)
        self.logger.debug("%s -> %s", cmd, response)
        return response

    def read_until_pattern(self, pattern: str, timeout_s: float = 30.0) -> str:
        """Collect output until a regex matches — e.g. a boot banner."""
        regex = re.compile(pattern)
        deadline = time.monotonic() + timeout_s
        buf = ""
        while time.monotonic() < deadline:
            chunk = self._ser.read(self._ser.in_waiting or 1).decode(errors="replace")
            buf += chunk
            if regex.search(buf):
                return buf
        raise TimeoutError(f"Pattern {pattern!r} not seen within {timeout_s}s")

    def tearDown(self) -> None:
        self._ser.close()

Use it in phases

main.py
import openhtf as htf
from openhtf.util import configuration

from dut_uart import DutUart

CONF = configuration.CONF
UART_PORT = CONF.declare("uart_port", default_value="/dev/ttyUSB0")
UART_BAUD = CONF.declare("uart_baud", default_value=115200)
Uart = configuration.bind_init_args(DutUart, UART_PORT, UART_BAUD)


@htf.plug(uart=Uart)
def wait_for_boot(test, uart):
    banner = uart.read_until_pattern(r"login:|>\s*$", timeout_s=20)
    test.attach("boot_log", banner.encode(), mimetype="text/plain")


@htf.plug(uart=Uart)
@htf.measures(
    htf.Measurement("fw_version").matches_regex(r"^2\.1\.\d+$"),
    htf.Measurement("mcu_serial").matches_regex(r"^[0-9A-F]{16}$"),
    htf.Measurement("vbat_mv").in_range(3600, 4200),
)
def read_identity(test, uart):
    test.measurements.fw_version = uart.command("version")
    test.measurements.mcu_serial = uart.command("serial")
    test.measurements.vbat_mv = int(uart.command("adc vbat"))


@htf.plug(uart=Uart)
def set_dut_id_from_device(test, uart):
    # Use the MCU's own serial as the DUT ID when there is no barcode.
    test.test_record.dut_id = uart.command("serial")


def main():
    test = htf.Test(wait_for_boot, set_dut_id_from_device, read_identity)
    test.execute()      # DUT ID set by set_dut_id_from_device

if __name__ == "__main__":
    main()

Practical notes

  • Timeouts everywhere. serial.Serial(timeout=1) bounds each read; the plug adds a per-command deadline on top. A hung DUT then fails the phase in seconds, not forever.
  • Framing. Decide what ends a response — a prompt, a newline, a fixed length, a checksum — and implement exactly that. readline() alone is only right for single-line replies.
  • Echo. Many consoles echo the command; strip it.
  • Binary protocols. Use bytes, struct.pack/unpack, and ser.read(n); skip the decode.
  • Flow control and DTR. Some boards reset when DTR toggles on open. serial.Serial(port=None) then ser.dtr = False; ser.port = port; ser.open() avoids the pulse.
  • Boot logs as attachments. Capture the whole boot with read_until_pattern and attach it; when a unit fails later, the log is already in the record. For continuous capture in the background, the bundled SerialCollectionPlug does it in a thread.
  • Windows ports are COM3; above COM9 use \\\\.\\COM10.

On this page

First-pass yield
0%4.1
Track with TofuPilot