Docs / Python SDK

Your tests, against a simulated board

The HardLabs SDK for Python runs your tests against a simulation of your board, running the firmware you'd flash. Use it two ways:

  • Keep your test framework. If your tests talk to the device over a serial line (RS-485, a debug UART), the SDK opens the simulated board's port on your computer. pyserial opens it with serial_for_url(...), so your framework runs unchanged.
  • Drive the board from your tests. Work the board's controls (supply voltage, a sensor's value, a button), read what its panels show, type into the firmware's console and wait for output. Waits count simulated time.

1. Install and sign in

Terminal
pip install hardlabs-sdk
npx hardlabs login        # once per computer; in CI, set HARDLABS_TOKEN instead

The package is hardlabs-sdk on PyPI. It signs in with the token the hardlabs command line saves, so one login covers both. The examples use company_name/board_namefor your board's simulation; use the name the website shows for yours.

2. Firmware: one ELF, or what your flash holds

Python
from hardlabs_sdk import Client

board = Client().start("company_name/board_name", "build/app.elf")

# A bootloader and its application, each at the address you flash it to:
board = Client().start("company_name/board_name", ["bootloader.bin@0x00400000", "app.bin@0x00410000"])
print(board.firmware["images"])   # where each image went, with its sha256

Raw .bin images need their address. Intel HEX files and ELF files carry their own addresses. The CPU boots from the image at the lowest address, as it would from flash.

3. Your framework, on the simulated board

Python
with Client().start("company_name/board_name", ["bootloader.bin@0x00400000", "app.bin@0x00410000"]) as board:
    print(board.serial_ports())          # ['console', 'rs485', 'rs485-b']
    bus = board.serial("rs485")
    print(bus.url)                       # socket://127.0.0.1:53817

    import serial
    port = serial.serial_for_url(bus.url, baudrate=115200, timeout=5)
    port.write(frame)                    # bytes go to the firmware's UART as they are
  • Bytes: nothing is added or stripped. A binary protocol (SLIP frames, CRCs) arrives as sent.
  • Restarts: the local port stays the same while the board restarts. The bridge reconnects to the new session by itself.
  • Opening the port per command: pyserial's socket://close waits 0.3 s before returning. For the bridge's own ports it doesn't, so a framework that opens and closes the port around every command runs at the board's pace. This holds where the bridge lives in the same process as your framework (the pytest fixtures). A tool in another process, next to python -m hardlabs_sdk up, pays the 0.3 s each time it closes the port.
  • Device files: on macOS and Linux, board.serial("rs485", pty=True) gives a pseudo-terminal at bus.path (/dev/ttys012) for tools that want one. Don't use it with a framework that calls reset_output_buffer()(or tcflush) before closing: on macOS that discards bytes the bridge hasn't read yet, so a command sent without waiting for a reply can be lost.

Ports for another program

To point a tool that isn't written in Python at the board, boot it from the terminal. It prints each port's address and keeps them open until Ctrl-C:

Terminal
python -m hardlabs_sdk up company_name/board_name \
    --firmware bootloader.bin@0x00400000 --firmware app.bin@0x00410000 \
    --serial rs485

Repeat --serial for more ports (the console when you give none), and add --pty for device files instead of socket:// addresses.

4. pytest

Swap a bench rig for the simulation

If your conftest finds the device's serial port in a session fixture, replace that one fixture. The same pattern runs a suite against a local simulator:

conftest.py, next to the tests you want to run on the simulated board
import pytest

@pytest.fixture(name="setup", scope="session", autouse=True)
def setup_fixture(pytestconfig, hardlabs_serial):
    port = hardlabs_serial("rs485")              # the board, booted once for the session
    set_context(port=port.url, ...)              # your framework's own setup, minus USB discovery
Terminal
pytest tests/pcb --hardlabs-sim company_name/board_name \
    --hardlabs-firmware bootloader.bin@0x00400000 --hardlabs-firmware app.bin@0x00410000

In CI, HARDLABS_SIM and HARDLABS_FIRMWARE (images separated by spaces) do the same as the two options.

Fixtures

Installing the package adds them:

FixtureWhat it gives you
hardlabs_boardthe board, booted once per session with --hardlabs-firmware (or HARDLABS_FIRMWARE)
hardlabs_serial(port, pty=False)a local serial port for the session, the same across restarts
@pytest.mark.hardlabs_freshboot the board afresh before this test

A failing test's report includes the console's last lines. It also lists what went wrong in the run: the firmware's crashes, asserts and restarts, and anything that may be a limit of the simulation.

5. Drive the board

Python
board.wait_for("app ready", within=5)          # a console line, within 5 s of simulated time
board.send("status")                           # one line into the firmware's shell
board.expect_console("mode: SAFE", "faults: 0")

board.do("bench-supply.volts", 11.5)           # a control, by the name the website and CLI use
board.do("usb.plugged-in", True)
board.do("reset.press")
for c in board.controls():
    print(c.name, c.usage)

assert board.read("power-monitor.bus-current") < 0.2
print(board.readings())                        # every value the panels show, with units

board.wait(2.0)                                # let 2 s of simulated time pass
board.clock()                                  # simulated seconds since boot
board.restart()                                # boot again with the same firmware
board.problems()                               # crashes, asserts, restarts; the simulation's limits
  • Names: controls and readings use the names the website, the CLI and scenario files use: <panel>.<control>. The part after the dot is enough when only one panel has it. A part's controls and readings also answer to its schematic reference designator: U4.extra-load-current.
  • Timeouts: ConsoleTimeoutsays which text didn't appear and shows what the console printed instead. SimulationStopped means simulated time stopped moving.

6. Parts by reference designator

Name a part the way the schematic does. Several identical parts (five load switches, two haptic drivers) are told apart by their reference designators:

Python
board.U4.do("extra-load-current", 0.5)         # or board.do("U4.extra-load-current", 0.5)
assert not board.U4.read("fault")
print(board.U6.readings())                     # {'U6.load-current': Reading(...), 'U6.fault': ...}

for part in board.parts():                     # every part on the board, passives left out
    print(part)
# U4 MAX890LESA+ — Load switch, simulated
#   do   U4.extra-load-current <value 0..2 A>
#   read U4.load-current, U4.fault
# U9 BME280 — Environment sensor, not simulated
#   ...

Filter them by where they sit, like a query on a web page's elements:

Python
board.parts(bus="i2c0")                     # every part on that bus, as the board names it
board.find(bus="i2c0", address=0x6B)        # exactly one part, or LookupError
board.parts(connected_to="U1", status="not_simulated")

for part in board.mcu.connected():          # every part the processor reaches
    print(part.ref, part.bus, part.address, part.mcu_pins)
board.U43.behind                            # "U42": the I2C switch it sits behind

A part with nothing to do or read is still listed, so the parts list says what the simulation leaves out. Every filter, field and error is in your board's parts.

7. On-target unit tests

A test binary built for the board's processor runs on the simulated one. The SDK reads its Unity or GoogleTest results from the console:

Python
board = Client().start("company_name/board_name", "build/tests.elf")
report = board.run_unit_tests(within=120)
open("results.xml", "w").write(report.junit())
assert report.ok, report.summary()

8. What it is not

  • Not your physical board. Every value comes from a model of a part. Before you blame the firmware for a disagreement with the board, check what the model covers: scopein the hardlabs shell, or a panel's Model chip on the website.
  • One session per board per account. Two CI jobs that use the same token on the same board share one simulation. Give parallel jobs their own boards, or run them one after the other.

Next: your board's parts · the hardlabs command line · all docs