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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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}"