Metadata-Version: 2.4
Name: hardlabs-sdk
Version: 0.2.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.
- **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 below). 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 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.
