hardlabs-sdk 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hardlabs_sdk-0.1.0/PKG-INFO +221 -0
- hardlabs_sdk-0.1.0/README.md +195 -0
- hardlabs_sdk-0.1.0/pyproject.toml +42 -0
- hardlabs_sdk-0.1.0/setup.cfg +4 -0
- hardlabs_sdk-0.1.0/src/hardlabs_sdk/__init__.py +22 -0
- hardlabs_sdk-0.1.0/src/hardlabs_sdk/__main__.py +60 -0
- hardlabs_sdk-0.1.0/src/hardlabs_sdk/_http.py +87 -0
- hardlabs_sdk-0.1.0/src/hardlabs_sdk/board.py +454 -0
- hardlabs_sdk-0.1.0/src/hardlabs_sdk/client.py +82 -0
- hardlabs_sdk-0.1.0/src/hardlabs_sdk/config.py +46 -0
- hardlabs_sdk-0.1.0/src/hardlabs_sdk/images.py +65 -0
- hardlabs_sdk-0.1.0/src/hardlabs_sdk/pytest_plugin.py +128 -0
- hardlabs_sdk-0.1.0/src/hardlabs_sdk/serial.py +219 -0
- hardlabs_sdk-0.1.0/src/hardlabs_sdk/unit_tests.py +131 -0
- hardlabs_sdk-0.1.0/src/hardlabs_sdk.egg-info/PKG-INFO +221 -0
- hardlabs_sdk-0.1.0/src/hardlabs_sdk.egg-info/SOURCES.txt +19 -0
- hardlabs_sdk-0.1.0/src/hardlabs_sdk.egg-info/dependency_links.txt +1 -0
- hardlabs_sdk-0.1.0/src/hardlabs_sdk.egg-info/entry_points.txt +2 -0
- hardlabs_sdk-0.1.0/src/hardlabs_sdk.egg-info/requires.txt +5 -0
- hardlabs_sdk-0.1.0/src/hardlabs_sdk.egg-info/top_level.txt +1 -0
- hardlabs_sdk-0.1.0/tests/test_offline.py +131 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hardlabs-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Run your tests against a HardLabs simulated board running your firmware
|
|
5
|
+
Author-email: HardLabs <hello@hardlabs.io>
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Project-URL: Homepage, https://www.hardlabs.io
|
|
8
|
+
Project-URL: Documentation, https://www.hardlabs.io/docs
|
|
9
|
+
Project-URL: Board parts, https://www.hardlabs.io/docs/parts
|
|
10
|
+
Project-URL: Command line, https://www.hardlabs.io/docs/cli
|
|
11
|
+
Keywords: firmware,embedded,simulation,hardware-in-the-loop,pytest,pcb
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Framework :: Pytest
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: Other/Proprietary License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Topic :: Software Development :: Testing
|
|
19
|
+
Classifier: Topic :: Software Development :: Embedded Systems
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Requires-Dist: simple-websocket>=1.0
|
|
23
|
+
Provides-Extra: test
|
|
24
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
25
|
+
Requires-Dist: pyserial>=3.5; extra == "test"
|
|
26
|
+
|
|
27
|
+
# HardLabs SDK for Python
|
|
28
|
+
|
|
29
|
+
Run your tests against a HardLabs simulation of your board, running the
|
|
30
|
+
firmware you'd flash. Use it two ways:
|
|
31
|
+
|
|
32
|
+
- **Keep your test framework.** If your tests talk to the device over a serial
|
|
33
|
+
line (RS-485, a debug UART), the SDK opens the simulated board's port on
|
|
34
|
+
your computer. pyserial opens it with `serial_for_url(...)`, so your
|
|
35
|
+
framework runs unchanged.
|
|
36
|
+
- **Drive the board from your tests.** Work the board's controls (supply
|
|
37
|
+
voltage, a sensor's value, a button), read what its panels show, type into
|
|
38
|
+
the firmware's console and wait for output. Waits count simulated time.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install hardlabs-sdk
|
|
42
|
+
npx hardlabs login # once per computer; in CI, set HARDLABS_TOKEN instead
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Firmware: one ELF, or what your flash holds
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from hardlabs_sdk import Client
|
|
49
|
+
|
|
50
|
+
board = Client().start("company_name/board_name", "build/app.elf")
|
|
51
|
+
|
|
52
|
+
# A bootloader and its application, each at the address you flash it to:
|
|
53
|
+
board = Client().start("company_name/board_name", ["bootloader.bin@0x00400000", "app.bin@0x00410000"])
|
|
54
|
+
print(board.firmware["images"]) # where each image went, with its sha256
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Raw `.bin` images need their address. Intel HEX files and ELF files carry
|
|
58
|
+
their own addresses. The CPU boots from the image at the lowest address, as it
|
|
59
|
+
would from flash.
|
|
60
|
+
|
|
61
|
+
## Your framework, on the simulated board
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
with Client().start("company_name/board_name", ["bootloader.bin@0x00400000", "app.bin@0x00410000"]) as board:
|
|
65
|
+
print(board.serial_ports()) # ['console', 'rs485', 'rs485-b']
|
|
66
|
+
bus = board.serial("rs485")
|
|
67
|
+
print(bus.url) # socket://127.0.0.1:53817
|
|
68
|
+
|
|
69
|
+
import serial
|
|
70
|
+
port = serial.serial_for_url(bus.url, baudrate=115200, timeout=5)
|
|
71
|
+
port.write(frame) # bytes go to the firmware's UART as they are
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
- **Bytes:** nothing is added or stripped. A binary protocol (SLIP frames,
|
|
75
|
+
CRCs) arrives as sent.
|
|
76
|
+
- **Restarts:** the local port stays the same while the board restarts. The
|
|
77
|
+
bridge reconnects to the new session by itself.
|
|
78
|
+
- **Device files:** on macOS and Linux, `board.serial("rs485", pty=True)` gives
|
|
79
|
+
a pseudo-terminal at `bus.path` (`/dev/ttys012`) for tools that want one.
|
|
80
|
+
- **Ports by hand:**
|
|
81
|
+
`python -m hardlabs_sdk up company_name/board_name --firmware bootloader.bin@0x00400000 --firmware app.bin@0x00410000 --serial rs485`
|
|
82
|
+
boots the board and prints each port's address. Point your own CLI at it.
|
|
83
|
+
Ctrl-C stops it.
|
|
84
|
+
|
|
85
|
+
### Swapping a bench rig for the simulation in pytest
|
|
86
|
+
|
|
87
|
+
If your conftest finds the device's serial port in a session fixture, replace
|
|
88
|
+
that one fixture. The same pattern runs a suite against a local simulator:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
# conftest.py next to the tests you want to run on the simulated board
|
|
92
|
+
import pytest
|
|
93
|
+
|
|
94
|
+
@pytest.fixture(name="setup", scope="session", autouse=True)
|
|
95
|
+
def setup_fixture(pytestconfig, hardlabs_serial):
|
|
96
|
+
port = hardlabs_serial("rs485") # the board, booted once for the session
|
|
97
|
+
set_context(port=port.url, ...) # your framework's own setup, minus USB discovery
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
pytest tests/pcb --hardlabs-sim company_name/board_name \
|
|
102
|
+
--hardlabs-firmware bootloader.bin@0x00400000 --hardlabs-firmware app.bin@0x00410000
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## pytest fixtures
|
|
106
|
+
|
|
107
|
+
Installing the package adds them:
|
|
108
|
+
|
|
109
|
+
| | |
|
|
110
|
+
|---|---|
|
|
111
|
+
| `hardlabs_board` | the board, booted once per session with `--hardlabs-firmware` (or `HARDLABS_FIRMWARE`) |
|
|
112
|
+
| `hardlabs_serial(port, pty=False)` | a local serial port for the session, the same across restarts |
|
|
113
|
+
| `@pytest.mark.hardlabs_fresh` | boot the board afresh before this test |
|
|
114
|
+
|
|
115
|
+
A failing test's report includes the console's last lines. It also lists what
|
|
116
|
+
went wrong in the run: the firmware's crashes, asserts and restarts, and
|
|
117
|
+
anything that may be a limit of the simulation.
|
|
118
|
+
|
|
119
|
+
## Driving the board
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
board.wait_for("app ready", within=5) # a console line, within 5 s of simulated time
|
|
123
|
+
board.send("status") # one line into the firmware's shell
|
|
124
|
+
board.expect_console("mode: SAFE", "faults: 0")
|
|
125
|
+
|
|
126
|
+
board.do("bench-supply.volts", 11.5) # a control, by the name the website and CLI use
|
|
127
|
+
board.do("usb.plugged-in", True)
|
|
128
|
+
board.do("reset.press")
|
|
129
|
+
for c in board.controls():
|
|
130
|
+
print(c.name, c.usage)
|
|
131
|
+
|
|
132
|
+
assert board.read("power-monitor.bus-current") < 0.2
|
|
133
|
+
print(board.readings()) # every value the panels show, with units
|
|
134
|
+
|
|
135
|
+
board.wait(2.0) # let 2 s of simulated time pass
|
|
136
|
+
board.clock() # simulated seconds since boot
|
|
137
|
+
board.restart() # boot again with the same firmware
|
|
138
|
+
board.problems() # crashes, asserts, restarts; the simulation's limits
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
- **Names:** controls and readings use the names the website, the CLI and
|
|
142
|
+
scenario files use: `<panel>.<control>`. The part after the dot is enough
|
|
143
|
+
when only one panel has it. A part's also answer to its schematic
|
|
144
|
+
reference designator, `U4.extra-load-current` (below).
|
|
145
|
+
- **Timeouts:** `ConsoleTimeout` says which text didn't appear and shows what
|
|
146
|
+
the console printed instead. `SimulationStopped` means simulated time
|
|
147
|
+
stopped moving.
|
|
148
|
+
|
|
149
|
+
## Parts by reference designator
|
|
150
|
+
|
|
151
|
+
The full guide, with every filter, field and error: https://www.hardlabs.io/docs/parts
|
|
152
|
+
|
|
153
|
+
Name a part the way the schematic does. Several identical parts (five load
|
|
154
|
+
switches, two haptic drivers) are told apart by their refdes:
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
board.U4.do("extra-load-current", 0.5) # or board.do("U4.extra-load-current", 0.5)
|
|
158
|
+
assert not board.U4.read("fault")
|
|
159
|
+
print(board.U6.readings()) # {'U6.load-current': Reading(...), 'U6.fault': ...}
|
|
160
|
+
|
|
161
|
+
for part in board.parts(): # every part on the board, passives left out
|
|
162
|
+
print(part)
|
|
163
|
+
# U4 MAX890LESA+ — Load switch, simulated
|
|
164
|
+
# do U4.extra-load-current <value 0..2 A>
|
|
165
|
+
# read U4.load-current, U4.fault
|
|
166
|
+
# U9 BME280 — Environment sensor, not simulated
|
|
167
|
+
# ...
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
- `board.part("U4")` is `board.U4`, in any case. A part the board lacks
|
|
171
|
+
raises with the ones it has.
|
|
172
|
+
- Each part has `ref`, `part` (its part number or name), `what`, `where` (a
|
|
173
|
+
bus and address, when it is on one), `status` (how far it is simulated),
|
|
174
|
+
`note`, `controls` and `reading_names`.
|
|
175
|
+
- A part with nothing to do or read is still listed: the parts list says
|
|
176
|
+
what the simulation leaves out.
|
|
177
|
+
|
|
178
|
+
### Finding parts by where they sit
|
|
179
|
+
|
|
180
|
+
Like a DOM query: filter the parts by bus, address, or what they are wired to.
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
board.parts(bus="i2c0") # every part on that bus, as the board names it
|
|
184
|
+
board.find(bus="i2c0", address=0x6B) # exactly one part, or LookupError
|
|
185
|
+
board.parts(connected_to="U1", status="not_simulated")
|
|
186
|
+
|
|
187
|
+
for part in board.mcu.connected(): # every part the processor reaches
|
|
188
|
+
print(part.ref, part.bus, part.address, part.mcu_pins)
|
|
189
|
+
board.U43.behind # "U42": the I2C switch it sits behind
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
- Each part has `bus`, `address`, `cs` (an SPI part's chip select), `behind`,
|
|
193
|
+
`mcu_pins` (the processor pins it reaches, with their nets) and
|
|
194
|
+
`connected_refs`. `part.connected()` gives the parts themselves, the processor
|
|
195
|
+
first.
|
|
196
|
+
- `connected_to` the processor includes the parts on its buses. For any
|
|
197
|
+
other part it means a shared signal net, or the switch a part sits behind.
|
|
198
|
+
- Wiring comes from the board's netlist. A board without one knows only
|
|
199
|
+
what its parts list says about each part's bus and address.
|
|
200
|
+
|
|
201
|
+
## On-target unit tests
|
|
202
|
+
|
|
203
|
+
A test binary built for the board's processor runs on the simulated one. The
|
|
204
|
+
SDK reads its Unity or GoogleTest results from the console:
|
|
205
|
+
|
|
206
|
+
```python
|
|
207
|
+
board = Client().start("company_name/board_name", "build/tests.elf")
|
|
208
|
+
report = board.run_unit_tests(within=120)
|
|
209
|
+
open("results.xml", "w").write(report.junit())
|
|
210
|
+
assert report.ok, report.summary()
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## What it is not
|
|
214
|
+
|
|
215
|
+
- **Not your physical board.** Every value comes from a model of a part.
|
|
216
|
+
Before you blame the firmware for a disagreement with the board, check what
|
|
217
|
+
the model covers: `hardlabs` → `scope`, or a panel's **Model** chip on the
|
|
218
|
+
website.
|
|
219
|
+
- **One session per board per account.** Two CI jobs that use the same token
|
|
220
|
+
on the same board share one simulation. Give parallel jobs their own boards,
|
|
221
|
+
or run them one after the other.
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# HardLabs SDK for Python
|
|
2
|
+
|
|
3
|
+
Run your tests against a HardLabs simulation of your board, running the
|
|
4
|
+
firmware you'd flash. Use it two ways:
|
|
5
|
+
|
|
6
|
+
- **Keep your test framework.** If your tests talk to the device over a serial
|
|
7
|
+
line (RS-485, a debug UART), the SDK opens the simulated board's port on
|
|
8
|
+
your computer. pyserial opens it with `serial_for_url(...)`, so your
|
|
9
|
+
framework runs unchanged.
|
|
10
|
+
- **Drive the board from your tests.** Work the board's controls (supply
|
|
11
|
+
voltage, a sensor's value, a button), read what its panels show, type into
|
|
12
|
+
the firmware's console and wait for output. Waits count simulated time.
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install hardlabs-sdk
|
|
16
|
+
npx hardlabs login # once per computer; in CI, set HARDLABS_TOKEN instead
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Firmware: one ELF, or what your flash holds
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from hardlabs_sdk import Client
|
|
23
|
+
|
|
24
|
+
board = Client().start("company_name/board_name", "build/app.elf")
|
|
25
|
+
|
|
26
|
+
# A bootloader and its application, each at the address you flash it to:
|
|
27
|
+
board = Client().start("company_name/board_name", ["bootloader.bin@0x00400000", "app.bin@0x00410000"])
|
|
28
|
+
print(board.firmware["images"]) # where each image went, with its sha256
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Raw `.bin` images need their address. Intel HEX files and ELF files carry
|
|
32
|
+
their own addresses. The CPU boots from the image at the lowest address, as it
|
|
33
|
+
would from flash.
|
|
34
|
+
|
|
35
|
+
## Your framework, on the simulated board
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
with Client().start("company_name/board_name", ["bootloader.bin@0x00400000", "app.bin@0x00410000"]) as board:
|
|
39
|
+
print(board.serial_ports()) # ['console', 'rs485', 'rs485-b']
|
|
40
|
+
bus = board.serial("rs485")
|
|
41
|
+
print(bus.url) # socket://127.0.0.1:53817
|
|
42
|
+
|
|
43
|
+
import serial
|
|
44
|
+
port = serial.serial_for_url(bus.url, baudrate=115200, timeout=5)
|
|
45
|
+
port.write(frame) # bytes go to the firmware's UART as they are
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- **Bytes:** nothing is added or stripped. A binary protocol (SLIP frames,
|
|
49
|
+
CRCs) arrives as sent.
|
|
50
|
+
- **Restarts:** the local port stays the same while the board restarts. The
|
|
51
|
+
bridge reconnects to the new session by itself.
|
|
52
|
+
- **Device files:** on macOS and Linux, `board.serial("rs485", pty=True)` gives
|
|
53
|
+
a pseudo-terminal at `bus.path` (`/dev/ttys012`) for tools that want one.
|
|
54
|
+
- **Ports by hand:**
|
|
55
|
+
`python -m hardlabs_sdk up company_name/board_name --firmware bootloader.bin@0x00400000 --firmware app.bin@0x00410000 --serial rs485`
|
|
56
|
+
boots the board and prints each port's address. Point your own CLI at it.
|
|
57
|
+
Ctrl-C stops it.
|
|
58
|
+
|
|
59
|
+
### Swapping a bench rig for the simulation in pytest
|
|
60
|
+
|
|
61
|
+
If your conftest finds the device's serial port in a session fixture, replace
|
|
62
|
+
that one fixture. The same pattern runs a suite against a local simulator:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
# conftest.py next to the tests you want to run on the simulated board
|
|
66
|
+
import pytest
|
|
67
|
+
|
|
68
|
+
@pytest.fixture(name="setup", scope="session", autouse=True)
|
|
69
|
+
def setup_fixture(pytestconfig, hardlabs_serial):
|
|
70
|
+
port = hardlabs_serial("rs485") # the board, booted once for the session
|
|
71
|
+
set_context(port=port.url, ...) # your framework's own setup, minus USB discovery
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
pytest tests/pcb --hardlabs-sim company_name/board_name \
|
|
76
|
+
--hardlabs-firmware bootloader.bin@0x00400000 --hardlabs-firmware app.bin@0x00410000
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## pytest fixtures
|
|
80
|
+
|
|
81
|
+
Installing the package adds them:
|
|
82
|
+
|
|
83
|
+
| | |
|
|
84
|
+
|---|---|
|
|
85
|
+
| `hardlabs_board` | the board, booted once per session with `--hardlabs-firmware` (or `HARDLABS_FIRMWARE`) |
|
|
86
|
+
| `hardlabs_serial(port, pty=False)` | a local serial port for the session, the same across restarts |
|
|
87
|
+
| `@pytest.mark.hardlabs_fresh` | boot the board afresh before this test |
|
|
88
|
+
|
|
89
|
+
A failing test's report includes the console's last lines. It also lists what
|
|
90
|
+
went wrong in the run: the firmware's crashes, asserts and restarts, and
|
|
91
|
+
anything that may be a limit of the simulation.
|
|
92
|
+
|
|
93
|
+
## Driving the board
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
board.wait_for("app ready", within=5) # a console line, within 5 s of simulated time
|
|
97
|
+
board.send("status") # one line into the firmware's shell
|
|
98
|
+
board.expect_console("mode: SAFE", "faults: 0")
|
|
99
|
+
|
|
100
|
+
board.do("bench-supply.volts", 11.5) # a control, by the name the website and CLI use
|
|
101
|
+
board.do("usb.plugged-in", True)
|
|
102
|
+
board.do("reset.press")
|
|
103
|
+
for c in board.controls():
|
|
104
|
+
print(c.name, c.usage)
|
|
105
|
+
|
|
106
|
+
assert board.read("power-monitor.bus-current") < 0.2
|
|
107
|
+
print(board.readings()) # every value the panels show, with units
|
|
108
|
+
|
|
109
|
+
board.wait(2.0) # let 2 s of simulated time pass
|
|
110
|
+
board.clock() # simulated seconds since boot
|
|
111
|
+
board.restart() # boot again with the same firmware
|
|
112
|
+
board.problems() # crashes, asserts, restarts; the simulation's limits
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
- **Names:** controls and readings use the names the website, the CLI and
|
|
116
|
+
scenario files use: `<panel>.<control>`. The part after the dot is enough
|
|
117
|
+
when only one panel has it. A part's also answer to its schematic
|
|
118
|
+
reference designator, `U4.extra-load-current` (below).
|
|
119
|
+
- **Timeouts:** `ConsoleTimeout` says which text didn't appear and shows what
|
|
120
|
+
the console printed instead. `SimulationStopped` means simulated time
|
|
121
|
+
stopped moving.
|
|
122
|
+
|
|
123
|
+
## Parts by reference designator
|
|
124
|
+
|
|
125
|
+
The full guide, with every filter, field and error: https://www.hardlabs.io/docs/parts
|
|
126
|
+
|
|
127
|
+
Name a part the way the schematic does. Several identical parts (five load
|
|
128
|
+
switches, two haptic drivers) are told apart by their refdes:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
board.U4.do("extra-load-current", 0.5) # or board.do("U4.extra-load-current", 0.5)
|
|
132
|
+
assert not board.U4.read("fault")
|
|
133
|
+
print(board.U6.readings()) # {'U6.load-current': Reading(...), 'U6.fault': ...}
|
|
134
|
+
|
|
135
|
+
for part in board.parts(): # every part on the board, passives left out
|
|
136
|
+
print(part)
|
|
137
|
+
# U4 MAX890LESA+ — Load switch, simulated
|
|
138
|
+
# do U4.extra-load-current <value 0..2 A>
|
|
139
|
+
# read U4.load-current, U4.fault
|
|
140
|
+
# U9 BME280 — Environment sensor, not simulated
|
|
141
|
+
# ...
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
- `board.part("U4")` is `board.U4`, in any case. A part the board lacks
|
|
145
|
+
raises with the ones it has.
|
|
146
|
+
- Each part has `ref`, `part` (its part number or name), `what`, `where` (a
|
|
147
|
+
bus and address, when it is on one), `status` (how far it is simulated),
|
|
148
|
+
`note`, `controls` and `reading_names`.
|
|
149
|
+
- A part with nothing to do or read is still listed: the parts list says
|
|
150
|
+
what the simulation leaves out.
|
|
151
|
+
|
|
152
|
+
### Finding parts by where they sit
|
|
153
|
+
|
|
154
|
+
Like a DOM query: filter the parts by bus, address, or what they are wired to.
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
board.parts(bus="i2c0") # every part on that bus, as the board names it
|
|
158
|
+
board.find(bus="i2c0", address=0x6B) # exactly one part, or LookupError
|
|
159
|
+
board.parts(connected_to="U1", status="not_simulated")
|
|
160
|
+
|
|
161
|
+
for part in board.mcu.connected(): # every part the processor reaches
|
|
162
|
+
print(part.ref, part.bus, part.address, part.mcu_pins)
|
|
163
|
+
board.U43.behind # "U42": the I2C switch it sits behind
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
- Each part has `bus`, `address`, `cs` (an SPI part's chip select), `behind`,
|
|
167
|
+
`mcu_pins` (the processor pins it reaches, with their nets) and
|
|
168
|
+
`connected_refs`. `part.connected()` gives the parts themselves, the processor
|
|
169
|
+
first.
|
|
170
|
+
- `connected_to` the processor includes the parts on its buses. For any
|
|
171
|
+
other part it means a shared signal net, or the switch a part sits behind.
|
|
172
|
+
- Wiring comes from the board's netlist. A board without one knows only
|
|
173
|
+
what its parts list says about each part's bus and address.
|
|
174
|
+
|
|
175
|
+
## On-target unit tests
|
|
176
|
+
|
|
177
|
+
A test binary built for the board's processor runs on the simulated one. The
|
|
178
|
+
SDK reads its Unity or GoogleTest results from the console:
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
board = Client().start("company_name/board_name", "build/tests.elf")
|
|
182
|
+
report = board.run_unit_tests(within=120)
|
|
183
|
+
open("results.xml", "w").write(report.junit())
|
|
184
|
+
assert report.ok, report.summary()
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## What it is not
|
|
188
|
+
|
|
189
|
+
- **Not your physical board.** Every value comes from a model of a part.
|
|
190
|
+
Before you blame the firmware for a disagreement with the board, check what
|
|
191
|
+
the model covers: `hardlabs` → `scope`, or a panel's **Model** chip on the
|
|
192
|
+
website.
|
|
193
|
+
- **One session per board per account.** Two CI jobs that use the same token
|
|
194
|
+
on the same board share one simulation. Give parallel jobs their own boards,
|
|
195
|
+
or run them one after the other.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "hardlabs-sdk"
|
|
7
|
+
dynamic = ["version"] # hardlabs_sdk.__version__: one place to bump (PUBLISHING.md)
|
|
8
|
+
description = "Run your tests against a HardLabs simulated board running your firmware"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "Proprietary" }
|
|
12
|
+
authors = [{ name = "HardLabs", email = "hello@hardlabs.io" }]
|
|
13
|
+
keywords = ["firmware", "embedded", "simulation", "hardware-in-the-loop", "pytest", "pcb"]
|
|
14
|
+
dependencies = ["simple-websocket>=1.0"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Framework :: Pytest",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: Other/Proprietary License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
22
|
+
"Topic :: Software Development :: Testing",
|
|
23
|
+
"Topic :: Software Development :: Embedded Systems",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
test = ["pytest>=7", "pyserial>=3.5"]
|
|
28
|
+
|
|
29
|
+
[project.entry-points.pytest11]
|
|
30
|
+
hardlabs = "hardlabs_sdk.pytest_plugin"
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://www.hardlabs.io"
|
|
34
|
+
Documentation = "https://www.hardlabs.io/docs"
|
|
35
|
+
"Board parts" = "https://www.hardlabs.io/docs/parts"
|
|
36
|
+
"Command line" = "https://www.hardlabs.io/docs/cli"
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.packages.find]
|
|
39
|
+
where = ["src"]
|
|
40
|
+
|
|
41
|
+
[tool.setuptools.dynamic]
|
|
42
|
+
version = { attr = "hardlabs_sdk.__version__" }
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""HardLabs SDK: run your tests against a simulated board running your firmware.
|
|
2
|
+
|
|
3
|
+
from hardlabs_sdk import Client
|
|
4
|
+
|
|
5
|
+
with Client().start("company_name/board_name", ["bootloader.bin@0x00400000", "app.bin@0x00410000"]) as board:
|
|
6
|
+
board.wait_for("app ready", within=5)
|
|
7
|
+
port = board.serial("rs485") # serial_for_url(port.url) in your own framework
|
|
8
|
+
board.do("bench-supply.volts", 11.5)
|
|
9
|
+
assert board.read("power-monitor.bus-current") < 0.2
|
|
10
|
+
board.U4.do("extra-load-current", 0.5) # a part by its schematic reference designator
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
__version__ = "0.1.0"
|
|
14
|
+
|
|
15
|
+
from ._http import HardLabsError # noqa: E402
|
|
16
|
+
from .board import Board, ConsoleTimeout, Control, Line, Part, Reading, SimulationStopped # noqa: E402
|
|
17
|
+
from .client import Client # noqa: E402
|
|
18
|
+
from .images import Image # noqa: E402
|
|
19
|
+
from .unit_tests import TestCase, UnitTestReport # noqa: E402
|
|
20
|
+
|
|
21
|
+
__all__ = ["Board", "Client", "ConsoleTimeout", "Control", "HardLabsError", "Image", "Line", "Part", "Reading",
|
|
22
|
+
"SimulationStopped", "TestCase", "UnitTestReport", "__version__"]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""``python -m hardlabs_sdk up``: boot a board and keep its serial ports open
|
|
2
|
+
on this computer, for tools you run by hand (your own CLI, a terminal).
|
|
3
|
+
|
|
4
|
+
python -m hardlabs_sdk up company_name/board_name \\
|
|
5
|
+
--firmware bootloader.bin@0x00400000 --firmware app.bin@0x00410000 --serial rs485
|
|
6
|
+
|
|
7
|
+
prints each port's address (``socket://127.0.0.1:N``) and runs until Ctrl-C,
|
|
8
|
+
then stops the board.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
from ._http import HardLabsError
|
|
18
|
+
from .client import Client
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main(argv: list[str] | None = None) -> int:
|
|
22
|
+
ap = argparse.ArgumentParser(prog="python -m hardlabs_sdk")
|
|
23
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
24
|
+
up = sub.add_parser("up", help="boot a board and bridge its serial ports here")
|
|
25
|
+
up.add_argument("sim", help="owner/project[@version]")
|
|
26
|
+
up.add_argument("--firmware", action="append", help="an ELF, or file@address per image (repeat)")
|
|
27
|
+
up.add_argument("--serial", action="append", default=[], help="a serial port to bridge (repeat; default: console)")
|
|
28
|
+
up.add_argument("--pty", action="store_true", help="pseudo-terminals instead of socket:// ports")
|
|
29
|
+
up.add_argument("--api", help="API address")
|
|
30
|
+
sub.add_parser("sims", help="the simulations you can open")
|
|
31
|
+
args = ap.parse_args(argv)
|
|
32
|
+
try:
|
|
33
|
+
client = Client(api=getattr(args, "api", None))
|
|
34
|
+
if args.cmd == "sims":
|
|
35
|
+
for s in client.sims():
|
|
36
|
+
print(s.get("address") or s["id"])
|
|
37
|
+
return 0
|
|
38
|
+
board = client.board(args.sim).start(args.firmware)
|
|
39
|
+
fw = board.firmware or {}
|
|
40
|
+
print(f"{board.name} running {fw.get('name', 'its own firmware')}")
|
|
41
|
+
for img in fw.get("images") or []:
|
|
42
|
+
print(f" {img['name']} at {img['address']}")
|
|
43
|
+
bridges = [board.serial(p, pty=args.pty) for p in (args.serial or ["console"])]
|
|
44
|
+
for b in bridges:
|
|
45
|
+
print(f" {b.port}: {b.url}")
|
|
46
|
+
print("Ctrl-C stops the board.", flush=True)
|
|
47
|
+
try:
|
|
48
|
+
while True:
|
|
49
|
+
time.sleep(1)
|
|
50
|
+
except KeyboardInterrupt:
|
|
51
|
+
pass
|
|
52
|
+
board.stop()
|
|
53
|
+
return 0
|
|
54
|
+
except (HardLabsError, LookupError, FileNotFoundError, ValueError) as e:
|
|
55
|
+
print(f"hardlabs: {e}", file=sys.stderr)
|
|
56
|
+
return 1
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
if __name__ == "__main__":
|
|
60
|
+
sys.exit(main())
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""The HardLabs API over HTTP: a token on every request, JSON in and out,
|
|
2
|
+
errors as HardLabsError with the server's message. Standard library only."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import secrets
|
|
9
|
+
import socket
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.request
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from . import __version__
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HardLabsError(Exception):
|
|
18
|
+
"""A request the API refused or could not answer."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, message: str, status: int = 0, code: str | None = None, body: dict | None = None) -> None:
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.status = status
|
|
23
|
+
self.code = code
|
|
24
|
+
self.body = body or {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def friendly(msg: str) -> str:
|
|
28
|
+
"""Server messages as a customer reads them: the engine underneath is not their concern."""
|
|
29
|
+
return re.sub(r"\bRenode\b", "the simulator", msg.replace("Renode Monitor", "simulator"))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Http:
|
|
33
|
+
def __init__(self, base: str, token: str, *, timeout: float = 60.0, client: str = f"sdk-python/{__version__}") -> None:
|
|
34
|
+
self.base = base.rstrip("/")
|
|
35
|
+
self.token = token
|
|
36
|
+
self.timeout = timeout
|
|
37
|
+
self.client = client
|
|
38
|
+
|
|
39
|
+
def headers(self) -> dict[str, str]:
|
|
40
|
+
out = {"X-HardLabs-Client": self.client}
|
|
41
|
+
if self.token:
|
|
42
|
+
out["Authorization"] = f"Bearer {self.token}"
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
def call(self, method: str, path: str, body: Any = None, *, files: list[tuple[str, str, bytes]] | None = None,
|
|
46
|
+
form: list[tuple[str, str]] | None = None, timeout: float | None = None) -> dict:
|
|
47
|
+
headers = self.headers()
|
|
48
|
+
data: bytes | None = None
|
|
49
|
+
if files is not None or form is not None:
|
|
50
|
+
data, ctype = multipart(form or [], files or [])
|
|
51
|
+
headers["Content-Type"] = ctype
|
|
52
|
+
elif body is not None:
|
|
53
|
+
data = json.dumps(body).encode()
|
|
54
|
+
headers["Content-Type"] = "application/json"
|
|
55
|
+
req = urllib.request.Request(f"{self.base}{path}", data=data, method=method, headers=headers)
|
|
56
|
+
try:
|
|
57
|
+
with urllib.request.urlopen(req, timeout=timeout or self.timeout) as res:
|
|
58
|
+
text = res.read()
|
|
59
|
+
except urllib.error.HTTPError as e:
|
|
60
|
+
raw = e.read()
|
|
61
|
+
try:
|
|
62
|
+
payload = json.loads(raw) if raw else {}
|
|
63
|
+
except ValueError:
|
|
64
|
+
payload = {}
|
|
65
|
+
msg = str(payload.get("error") or payload.get("detail") or f"HTTP {e.code}")
|
|
66
|
+
if payload.get("detail") and payload.get("error"):
|
|
67
|
+
msg += f" — {payload['detail']}"
|
|
68
|
+
raise HardLabsError(friendly(msg), e.code, payload.get("error") if isinstance(payload.get("error"), str) else None,
|
|
69
|
+
payload) from None
|
|
70
|
+
except (urllib.error.URLError, socket.timeout, ConnectionError) as e:
|
|
71
|
+
raise HardLabsError(f"cannot reach {self.base} ({getattr(e, 'reason', e)})") from None
|
|
72
|
+
return json.loads(text) if text else {}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def multipart(fields: list[tuple[str, str]], files: list[tuple[str, str, bytes]]) -> tuple[bytes, str]:
|
|
76
|
+
"""``(body, content type)`` for form *fields* and *files* as (field, filename, data); repeated names kept in order."""
|
|
77
|
+
boundary = f"hardlabs-{secrets.token_hex(12)}"
|
|
78
|
+
out = bytearray()
|
|
79
|
+
for name, value in fields:
|
|
80
|
+
out += f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'.encode()
|
|
81
|
+
for name, filename, data in files:
|
|
82
|
+
safe = filename.replace('"', "_").replace("\r", "_").replace("\n", "_")
|
|
83
|
+
out += (f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"; filename="{safe}"\r\n'
|
|
84
|
+
"Content-Type: application/octet-stream\r\n\r\n").encode()
|
|
85
|
+
out += data + b"\r\n"
|
|
86
|
+
out += f"--{boundary}--\r\n".encode()
|
|
87
|
+
return bytes(out), f"multipart/form-data; boundary={boundary}"
|