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
pip install hardlabs-sdk
npx hardlabs login # once per computer; in CI, set HARDLABS_TOKEN insteadThe 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
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 sha256Raw .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
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:
python -m hardlabs_sdk up company_name/board_name \
--firmware bootloader.bin@0x00400000 --firmware app.bin@0x00410000 \
--serial rs485Repeat --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:
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 discoverypytest tests/pcb --hardlabs-sim company_name/board_name \
--hardlabs-firmware bootloader.bin@0x00400000 --hardlabs-firmware app.bin@0x00410000In CI, HARDLABS_SIM and HARDLABS_FIRMWARE (images separated by spaces) do the same as the two options.
Fixtures
Installing the package adds them:
| Fixture | What it gives you |
|---|---|
| hardlabs_board | the 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_fresh | boot 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
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:
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:
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 behindA 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:
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