Metadata-Version: 2.4
Name: hardlabs-sdk
Version: 0.1.0
Summary: Run your tests against a HardLabs simulated board running your firmware
Author-email: HardLabs <hello@hardlabs.io>
License: Proprietary
Project-URL: Homepage, https://www.hardlabs.io
Project-URL: Documentation, https://www.hardlabs.io/docs
Project-URL: Board parts, https://www.hardlabs.io/docs/parts
Project-URL: Command line, https://www.hardlabs.io/docs/cli
Keywords: firmware,embedded,simulation,hardware-in-the-loop,pytest,pcb
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: Pytest
Classifier: Intended Audience :: Developers
Classifier: License :: Other/Proprietary License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Software Development :: Testing
Classifier: Topic :: Software Development :: Embedded Systems
Requires-Python: >=3.9
Description-Content-Type: text/markdown
Requires-Dist: simple-websocket>=1.0
Provides-Extra: test
Requires-Dist: pytest>=7; extra == "test"
Requires-Dist: pyserial>=3.5; extra == "test"

# HardLabs SDK for Python

Run your tests against a HardLabs 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.

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

## 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.

## 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.
- **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.
- **Ports by hand:**
  `python -m hardlabs_sdk up company_name/board_name --firmware bootloader.bin@0x00400000 --firmware app.bin@0x00410000 --serial rs485`
  boots the board and prints each port's address. Point your own CLI at it.
  Ctrl-C stops it.

### Swapping a bench rig for the simulation in pytest

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:

```python
# 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
```

```bash
pytest tests/pcb --hardlabs-sim company_name/board_name \
    --hardlabs-firmware bootloader.bin@0x00400000 --hardlabs-firmware app.bin@0x00410000
```

## pytest fixtures

Installing the package adds them:

| | |
|---|---|
| `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.

## Driving 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 also answer to its schematic
  reference designator, `U4.extra-load-current` (below).
- **Timeouts:** `ConsoleTimeout` says which text didn't appear and shows what
  the console printed instead. `SimulationStopped` means simulated time
  stopped moving.

## Parts by reference designator

The full guide, with every filter, field and error: https://www.hardlabs.io/docs/parts

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

```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
#   ...
```

- `board.part("U4")` is `board.U4`, in any case. A part the board lacks
  raises with the ones it has.
- Each part has `ref`, `part` (its part number or name), `what`, `where` (a
  bus and address, when it is on one), `status` (how far it is simulated),
  `note`, `controls` and `reading_names`.
- A part with nothing to do or read is still listed: the parts list says
  what the simulation leaves out.

### Finding parts by where they sit

Like a DOM query: filter the parts by bus, address, or what they are wired to.

```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
```

- Each part has `bus`, `address`, `cs` (an SPI part's chip select), `behind`,
  `mcu_pins` (the processor pins it reaches, with their nets) and
  `connected_refs`. `part.connected()` gives the parts themselves, the processor
  first.
- `connected_to` the processor includes the parts on its buses. For any
  other part it means a shared signal net, or the switch a part sits behind.
- Wiring comes from the board's netlist. A board without one knows only
  what its parts list says about each part's bus and address.

## 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()
```

## 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: `hardlabs` → `scope`, 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.
