Skip to main content

Klab Pytest Toolkit - Embedded

PyPI Python License: MIT

Reusable embedded HIL testing components for pytest. The goal is to allow testers to easily test embedded devices with reusable components for programming, resetting, communication, and test-bench control.

At the moment the package provides the following components:

  • Board: Main orchestration class for managing board operations, including programming, resetting, and communication.
  • Debug Probes:
    • EspTool: Debug probe implementation for ESP32 devices using esptool.
    • OpenOcdProbe: Generic debug probe implementation using the openocd CLI.
    • ProbeRsProbe: Generic debug probe implementation using the probe-rs CLI.
  • Communicators:
    • SerialCommunicator: Serial port communication interface for UART/USB connections.
  • Logic Analyzers:
    • LogicAnalyzer: Abstract interface for reusable logic analyzer fixtures.
    • SaleaeLogicAnalyzer: Saleae Automation API based implementation with named digital channels.
  • GPIO Controllers:
    • GpioController: Abstract interface for reusable GPIO fixtures.
    • FtdiGpioController: FTDI/pyftdi based GPIO controller with named pins.
  • SPI Controllers:
    • SpiController: Abstract interface for reusable SPI fixtures.
    • FtdiSpiController: FTDI/pyftdi based SPI controller.
    • SpidevSpiController: Linux spidev based SPI controller for Raspberry Pi and other Linux benches.
  • I2C Controllers:
    • I2cController: Abstract interface for reusable I2C fixtures.
    • FtdiI2cController: FTDI/pyftdi based I2C controller.
    • SmbusI2cController: Linux SMBus/I2C controller for Raspberry Pi and other Linux benches.

Installation

pip install klab-pytest-toolkit-embedded

Install Saleae support with the optional extra (pulls in logic2-automation):

pip install 'klab-pytest-toolkit-embedded[saleae]'

Install FTDI bench support with the optional extra:

pip install 'klab-pytest-toolkit-embedded[ftdi]'

Install Linux bench bus support with the optional extra:

pip install 'klab-pytest-toolkit-embedded[linux]'

At the moment, the linux extra is used for Linux SPI and I2C backends (spidev and smbus2). A Linux GPIO backend is planned separately.

Usage

Board Class

The Board class orchestrates board operations by combining optional capabilities such as a debug probe (for programming and resetting) and a communicator (for sending and receiving data).

Create a fixture

import pytest
from pathlib import Path
from typing import Generator
from klab_pytest_toolkit_embedded.board import Board
from klab_pytest_toolkit_embedded.debug_probes import EspTool
from klab_pytest_toolkit_embedded.communicators import SerialCommunicator

@pytest.fixture
def dut() -> Generator[Board]:
    """Fixture to provide a Board instance for Device Under Test (DUT)."""
    PORT = "/dev/ttyUSB0"
    
    communicator = SerialCommunicator(port=PORT, baudrate=115200)
    debug_probe = EspTool(port=PORT, baudrate=1500000, address="0x0")
    
    with Board(debug_probe=debug_probe, communicator=communicator) as board:
        yield board

You can also create boards with only the dependencies they need:

board_with_uart_only = Board(communicator=SerialCommunicator(port="/dev/ttyUSB0"))
board_with_probe_only = Board(debug_probe=EspTool(port="/dev/ttyUSB0"))

Programming and Reset

def test_program_firmware(dut: Board):
    """Test programming firmware to the board."""
    firmware_file = "path/to/firmware.bin"
    dut.program(firmware_file)
    # Firmware is now flashed to the device

def test_reset_board(dut: Board):
    """Test resetting the board."""
    dut.reset()
    # Board has been reset

Communication

The Board class provides methods for sending and receiving data:

def test_send_data(dut: Board):
    """Test sending data to the board."""
    dut.send(b"Hello Device!\n")

def test_receive_data(dut: Board):
    """Test receiving data from the board."""
    data = dut.receive_some(num_bytes=1024)
    print(data.decode('utf-8', errors='ignore'))

def test_wait_for_boot_message(dut: Board):
    """Test waiting for a specific message during boot."""
    firmware_file = "path/to/firmware.bin"
    dut.program(firmware_file)
    
    # Wait for boot message with regex
    boot_message = b"Firmware Ready!"
    assert dut.wait_for_regex_in_line(boot_message, timeout_s=10, log=True)

wait_for_regex_in_line() accepts str, bytes, and compiled regex patterns.

Serial Communicator

The SerialCommunicator provides serial communication functionality with configurable parameters:

from klab_pytest_toolkit_embedded.communicators import SerialCommunicator

# Create a serial communicator
communicator = SerialCommunicator(
    port="/dev/ttyUSB0",
    baudrate=115200,
    timeout=1.0
)

# Send data
communicator.send(b"AT\r\n")

# Receive data
data = communicator.receive(num_bytes=100)

# Check available bytes
available = communicator.bytes_available()

# Flush buffers
communicator.flush_input()
communicator.flush_output()

# Close when done
communicator.close()

OpenOCD Debug Probe

The OpenOcdProbe class provides generic programming and reset functionality for boards supported by OpenOCD, including ST-Link based setups. Pass one or more OpenOCD config files, for example separate interface/target configs or a single board config:

from klab_pytest_toolkit_embedded.debug_probes import OpenOcdProbe

probe = OpenOcdProbe(
    config_files=("interface/stlink.cfg", "target/stm32f4x.cfg"),
    search_dirs=("/usr/share/openocd/scripts",),
)

probe.program("build/firmware.elf")
probe.reset()

board_probe = OpenOcdProbe(
    config_files=("board/st_nucleo_f4.cfg",),
)

You can combine it with a serial communicator in a board fixture:

@pytest.fixture
def dut() -> Generator[Board]:
    with Board(
        debug_probe=OpenOcdProbe(
            config_files=("interface/stlink.cfg", "target/stm32f4x.cfg"),
            search_dirs=("/usr/share/openocd/scripts",),
        ),
        communicator=SerialCommunicator(port="/dev/ttyACM0", baudrate=115200),
    ) as board:
        yield board

probe-rs Debug Probe

The ProbeRsProbe class provides programming and reset functionality using the probe-rs CLI:

from klab_pytest_toolkit_embedded.debug_probes import ProbeRsProbe

probe = ProbeRsProbe(
    chip="STM32F411CEUx",
    protocol="swd",
    speed_khz=4000,
)

probe.program("build/firmware.elf")
probe.reset()

You can also select a specific probe:

probe = ProbeRsProbe(
    chip="STM32F411CEUx",
    probe="0483:374B:066EFF515153878367144143",
)

ESP Debug Probe

The EspTool class provides programming and reset functionality for ESP32 devices:

from klab_pytest_toolkit_embedded.debug_probes import EspTool

# Create ESP debug probe
esp_probe = EspTool(
    port="/dev/ttyUSB0",
    baudrate=1500000,
    address="0x0"
)

# Program firmware
esp_probe.program("path/to/firmware.bin")

# Reset the device
esp_probe.reset()

# Close (no persistent connection for esptool)
esp_probe.close()

GPIO Controller

Use a separate fixture for bench GPIO control:

from klab_pytest_toolkit_embedded.gpio_controllers import FtdiGpioController

@pytest.fixture
def gpio() -> FtdiGpioController:
    return FtdiGpioController(
        url="ftdi://ftdi:232h:1/1",
        pins={
            "reset_n": 0,
            "boot0": 1,
        },
    )


def test_boot_mode(dut: Board, gpio: FtdiGpioController):
    gpio.set_high("boot0")
    gpio.pulse("reset_n", 0.05)

SPI Controller

Use a separate fixture for bench SPI access:

from klab_pytest_toolkit_embedded.spi_controllers import FtdiSpiController, SpidevSpiController

@pytest.fixture
def spi() -> FtdiSpiController:
    return FtdiSpiController(
        url="ftdi://ftdi:232h:1/1",
        chip_select=0,
        frequency_hz=1_000_000,
        mode=0,
    )


def test_spi_flash_id(spi: FtdiSpiController):
    response = spi.transfer(b"\x9F\x00\x00\x00")
    assert len(response) == 4


@pytest.fixture
def linux_spi() -> SpidevSpiController:
    return SpidevSpiController(bus=0, device=0, max_speed_hz=1_000_000, mode=0)

I2C Controller

Use a separate fixture for bench I2C access:

from klab_pytest_toolkit_embedded.i2c_controllers import FtdiI2cController, SmbusI2cController

@pytest.fixture
def i2c() -> FtdiI2cController:
    return FtdiI2cController(
        url="ftdi://ftdi:232h:1/1",
        frequency_hz=400_000,
    )


def test_i2c_sensor_read(i2c: FtdiI2cController):
    i2c.write(0x48, b"\x00")
    data = i2c.read(0x48, 2)
    assert len(data) == 2


@pytest.fixture
def linux_i2c() -> SmbusI2cController:
    return SmbusI2cController(bus=1)

Logic Analyzer

Use a separate fixture for bench equipment such as a logic analyzer:

from klab_pytest_toolkit_embedded.logic_analyzers import SaleaeLogicAnalyzer

@pytest.fixture
def logic() -> SaleaeLogicAnalyzer:
    return SaleaeLogicAnalyzer(
        digital_channels={
            "spi_mosi": 0,
            "spi_miso": 1,
            "spi_clk": 2,
            "reset_n": 3,
        },
        sample_rate_hz=25_000_000,
        capture_seconds=2.0,
    )


def test_spi_boot_sequence(dut: Board, logic: SaleaeLogicAnalyzer):
    logic.start_capture()
    dut.reset()
    logic.stop_capture()

    capture = logic.get_capture()
    capture.export("artifacts/spi_boot.sal")
    capture.assert_has_channels("spi_clk", "reset_n")

    assert capture.channel("spi_clk") == 2

So yes: with SaleaeLogicAnalyzer you can pass channel names directly in the constructor instead of raw channel numbers in test code, and access the finished session through logic.get_capture().

Current base LogicCapture helpers:

  • has_channel(name)
  • assert_has_channel(name)
  • assert_has_channels(*names)
  • assert_any_activity(name)
  • assert_no_activity(name)
  • assert_toggles_at_least(name, count)

Note: SaleaeLogicCapture currently supports channel-aware capture/export, but transition analysis is not implemented yet.

To use SaleaeLogicAnalyzer, install the optional Saleae dependency:

pip install 'klab-pytest-toolkit-embedded[saleae]'

Examples

See the test files for comprehensive examples.

Best Practices

Keep DUT and Bench Fixtures Separate

Use Board for the device under test, and keep bench tools such as GPIO, SPI, I2C, and logic analyzers as separate fixtures:

@pytest.fixture
def dut() -> Board:
    return Board(
        debug_probe=OpenOcdProbe(config_files=("board/st_nucleo_f4.cfg",)),
        communicator=SerialCommunicator(port="/dev/ttyACM0", baudrate=115200),
    )


@pytest.fixture
def gpio() -> FtdiGpioController:
    return FtdiGpioController(
        url="ftdi://ftdi:232h:1/1",
        pins={"reset_n": 0, "boot0": 1},
    )

This keeps test dependencies explicit and avoids turning Board into a large test-bench container.

Prefer Named Pins and Channels

Use descriptive names for pins, buses, and logic-analyzer channels instead of raw indices inside tests:

pins={"reset_n": 0, "boot0": 1}
digital_channels={"spi_clk": 0, "spi_mosi": 1, "reset_n": 2}

This makes tests easier to read and maintain.

Keep Hardware Configuration Centralized

Keep serial ports, OpenOCD configs, bus numbers, chip-select values, and pin mappings in one place such as conftest.py or a dedicated configuration module.

Choose the Right Backend

  • Use Linux-native backends such as SpidevSpiController and SmbusI2cController when pytest runs directly on a Linux or Raspberry Pi bench.
  • Use FTDI backends when the host does not expose native buses, or when you need a USB-connected bridge.
  • Use OpenOcdProbe when you want broad probe/target flexibility.
  • Use ProbeRsProbe when you want a modern ARM-focused flashing workflow.

Use Context Managers

The Board class supports context managers to ensure proper cleanup of resources:

with Board(debug_probe=debug_probe, communicator=communicator) as board:
    board.program(firmware_file)
    board.wait_for_regex_in_line(b"Ready", timeout_s=10)
    # Resources are automatically closed when exiting the context

Timeout Configuration

When waiting for messages from the device, always specify appropriate timeouts to prevent tests from hanging:

# Wait with custom timeout
dut.wait_for_regex_in_line(
    regex=b"Boot complete",
    timeout_s=30,
    log=True  # Enable logging to see device output
)

Capture Artifacts for Debugging

For flaky or timing-sensitive tests, save useful artifacts such as UART logs and logic-analyzer captures so failures can be inspected later.

Hardware Availability

For tests that require physical hardware, use pytest.mark.skipif to conditionally skip tests when hardware is not available:

@pytest.mark.skipif(
    not hardware_available(),
    reason="This test requires a physical ESP32 device connected."
)
def test_with_hardware(dut: Board):
    # Test code here
    pass

Links

License

MIT

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

klab_pytest_toolkit_embedded-1.2.0.tar.gz (15.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

klab_pytest_toolkit_embedded-1.2.0-py3-none-any.whl (25.7 kB view details)

Uploaded Python 3

File details

Details for the file klab_pytest_toolkit_embedded-1.2.0.tar.gz.

File metadata

  • Download URL: klab_pytest_toolkit_embedded-1.2.0.tar.gz
  • Upload date:
  • Size: 15.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for klab_pytest_toolkit_embedded-1.2.0.tar.gz
Algorithm Hash digest
SHA256 229c73f17929c4678ced4f0a15e33bd4f1daf5a8b0d3fbe6639ccab0c8e906dc
MD5 b708018c55c65da56db6351e39650da1
BLAKE2b-256 818bcb2625ff5acb3437adcacc7495069e9a04b22f9c839aee6b27c351a916a1

See more details on using hashes here.

File details

Details for the file klab_pytest_toolkit_embedded-1.2.0-py3-none-any.whl.

File metadata

  • Download URL: klab_pytest_toolkit_embedded-1.2.0-py3-none-any.whl
  • Upload date:
  • Size: 25.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for klab_pytest_toolkit_embedded-1.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 ea9442f904a8466608e9e4475b53403c397d353218d57badb08bd85156f38ab1
MD5 c1b86deb274b63e0a6a7737fecc317f8
BLAKE2b-256 287bd9a308e4a34a524055e4c08c2e83ae5cc8e38acc309fbf8cfbebb27eb164

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page