hardlabs-sdk 0.1.0__py3-none-any.whl
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/__init__.py +22 -0
- hardlabs_sdk/__main__.py +60 -0
- hardlabs_sdk/_http.py +87 -0
- hardlabs_sdk/board.py +454 -0
- hardlabs_sdk/client.py +82 -0
- hardlabs_sdk/config.py +46 -0
- hardlabs_sdk/images.py +65 -0
- hardlabs_sdk/pytest_plugin.py +128 -0
- hardlabs_sdk/serial.py +219 -0
- hardlabs_sdk/unit_tests.py +131 -0
- hardlabs_sdk-0.1.0.dist-info/METADATA +221 -0
- hardlabs_sdk-0.1.0.dist-info/RECORD +15 -0
- hardlabs_sdk-0.1.0.dist-info/WHEEL +5 -0
- hardlabs_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- hardlabs_sdk-0.1.0.dist-info/top_level.txt +1 -0
hardlabs_sdk/__init__.py
ADDED
|
@@ -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__"]
|
hardlabs_sdk/__main__.py
ADDED
|
@@ -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())
|
hardlabs_sdk/_http.py
ADDED
|
@@ -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}"
|
hardlabs_sdk/board.py
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"""A simulated board running your firmware: its console, its controls and
|
|
2
|
+
readings, simulated time, and its serial ports for your own test framework.
|
|
3
|
+
|
|
4
|
+
Waits and timeouts count simulated seconds, so a busy server or a slow
|
|
5
|
+
network doesn't change a result. Console matching follows the scenario
|
|
6
|
+
runner (``hardlabs run``): a match uses up its line and the lines before it,
|
|
7
|
+
and output printed before a ``send`` is not the reply to it.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING, Any, Iterable, Union
|
|
16
|
+
|
|
17
|
+
from ._http import HardLabsError
|
|
18
|
+
from .images import Image, as_images
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from .client import Client
|
|
22
|
+
from .serial import SerialBridge
|
|
23
|
+
from .unit_tests import UnitTestReport
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ConsoleTimeout(AssertionError):
|
|
27
|
+
"""Expected console output didn't appear in time (simulated seconds)."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, text: str, within: float, recent: list[str]) -> None:
|
|
30
|
+
tail = "\n".join(f" │ {line}" for line in recent) or " (nothing new on the console)"
|
|
31
|
+
super().__init__(f"{text!r} didn't appear within {within:g} s of simulated time; the console since:\n{tail}")
|
|
32
|
+
self.text = text
|
|
33
|
+
self.within = within
|
|
34
|
+
self.recent = recent
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SimulationStopped(HardLabsError):
|
|
38
|
+
"""Simulated time stopped moving: the simulation is no longer running."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Line:
|
|
43
|
+
n: int
|
|
44
|
+
text: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class Control:
|
|
49
|
+
name: str
|
|
50
|
+
kind: str
|
|
51
|
+
label: str
|
|
52
|
+
usage: str
|
|
53
|
+
min: float | None = None
|
|
54
|
+
max: float | None = None
|
|
55
|
+
unit: str | None = None
|
|
56
|
+
fields: tuple = ()
|
|
57
|
+
aliases: tuple = () # the names it answers to by reference designator: ("U4.enable",)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class Reading:
|
|
62
|
+
name: str
|
|
63
|
+
label: str
|
|
64
|
+
value: Any
|
|
65
|
+
unit: str | None
|
|
66
|
+
text: str
|
|
67
|
+
aliases: tuple = ()
|
|
68
|
+
|
|
69
|
+
def __float__(self) -> float:
|
|
70
|
+
return float(self.value)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# A name that reads as a reference designator: U4, IC3, J_BAT1, LED2.
|
|
74
|
+
_REF = re.compile(r"^[A-Z][A-Z_]*\d+[A-Z]?$")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class Part:
|
|
79
|
+
"""One part of the board by its schematic reference designator, with what
|
|
80
|
+
you can do to it and read from it. ``board.U4`` or ``board.part("U4")``;
|
|
81
|
+
``print(part)`` lists its actions."""
|
|
82
|
+
|
|
83
|
+
ref: str
|
|
84
|
+
part: str | None # what the parts list calls it: "MAX890LESA+"
|
|
85
|
+
what: str | None # "Load switch"
|
|
86
|
+
where: str | None # "I2C20 0x6B", when it is on a bus
|
|
87
|
+
status: str | None # simulated, partial, folded, not_simulated, not_fitted
|
|
88
|
+
note: str | None # what the simulation leaves out, when it says
|
|
89
|
+
controls: tuple = () # Control, named "U4.<control>"
|
|
90
|
+
reading_names: tuple = () # "U4.<reading>"
|
|
91
|
+
bus: str | None = None # as the board names it: "i2c0", "I2C20", "spi0"
|
|
92
|
+
address: int | None = None # on an I2C bus: 0x6B
|
|
93
|
+
cs: str | None = None # an SPI part's chip select
|
|
94
|
+
behind: str | None = None # the part its bus runs through (an I2C switch)
|
|
95
|
+
mcu_pins: tuple = () # the processor pins it reaches: ({"pin": "P2.05", "net": "IMU_INT"}, ...)
|
|
96
|
+
connected_refs: tuple = () # the parts it is wired to, the processor first
|
|
97
|
+
is_mcu: bool = False # the processor itself
|
|
98
|
+
board: "Board | None" = field(default=None, repr=False, compare=False)
|
|
99
|
+
|
|
100
|
+
def connected(self) -> list["Part"]:
|
|
101
|
+
"""The parts it is wired to: the processor (over a bus or a pin), an
|
|
102
|
+
I2C switch it sits behind, the parts on its signal nets. On the
|
|
103
|
+
processor, every part it reaches."""
|
|
104
|
+
return [self.board.part(r) for r in self.connected_refs]
|
|
105
|
+
|
|
106
|
+
def _name(self, name: str) -> str:
|
|
107
|
+
return name if name.lower().startswith(self.ref.lower() + ".") else f"{self.ref}.{name}"
|
|
108
|
+
|
|
109
|
+
def do(self, control: str, value: Any = None, **values: float) -> str:
|
|
110
|
+
"""Work one of its controls: ``board.U4.do("extra-load-current", 0.5)``."""
|
|
111
|
+
return self.board.do(self._name(control), value, **values)
|
|
112
|
+
|
|
113
|
+
def read(self, name: str) -> Any:
|
|
114
|
+
"""One of its readings' value: ``board.U4.read("load-current")``."""
|
|
115
|
+
return self.board.read(self._name(name))
|
|
116
|
+
|
|
117
|
+
def readings(self) -> dict[str, Reading]:
|
|
118
|
+
"""Every value it shows now, by its ``U4.<reading>`` name."""
|
|
119
|
+
if not self.reading_names:
|
|
120
|
+
return {}
|
|
121
|
+
got = self.board.readings(*self.reading_names)
|
|
122
|
+
return {alias: r for alias, r in zip(self.reading_names, got.values())}
|
|
123
|
+
|
|
124
|
+
def __str__(self) -> str:
|
|
125
|
+
head = " ".join(x for x in (self.ref, self.part) if x)
|
|
126
|
+
facts = ", ".join(x for x in (self.what, self.where, (self.status or "").replace("_", " ")) if x)
|
|
127
|
+
lines = [head + (f" — {facts}" if facts else "")]
|
|
128
|
+
if self.note:
|
|
129
|
+
lines.append(f" {self.note}")
|
|
130
|
+
if self.connected_refs:
|
|
131
|
+
lines.append(f" wired to {', '.join(self.connected_refs)}")
|
|
132
|
+
lines += [f" do {c.usage}" for c in self.controls]
|
|
133
|
+
if self.reading_names:
|
|
134
|
+
lines.append(f" read {', '.join(self.reading_names)}")
|
|
135
|
+
if not self.controls and not self.reading_names:
|
|
136
|
+
lines.append(" (nothing to do or read on this part)")
|
|
137
|
+
return "\n".join(lines)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
FirmwareArg = Union[str, Path, Image, Iterable[Union[str, Path, Image]], None]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass
|
|
144
|
+
class _Console:
|
|
145
|
+
next: int = 0 # last line number read
|
|
146
|
+
cursor: int = 0 # nothing at or before this line number may match any more
|
|
147
|
+
lines: list = field(default_factory=list)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class Board:
|
|
151
|
+
"""One simulation you can open, and your session on it. Use it as a
|
|
152
|
+
context manager, or call :meth:`stop` when done."""
|
|
153
|
+
|
|
154
|
+
def __init__(self, client: "Client", sim: dict) -> None:
|
|
155
|
+
self._client = client
|
|
156
|
+
self._http = client.http
|
|
157
|
+
self.sim = sim
|
|
158
|
+
self.id: str = sim["id"]
|
|
159
|
+
self.name: str = sim.get("address") or sim.get("title") or sim["id"]
|
|
160
|
+
self._images: list[Image] | None = None
|
|
161
|
+
self.started: dict = {}
|
|
162
|
+
self._consoles: dict[str | None, _Console] = {}
|
|
163
|
+
self._bridges: list["SerialBridge"] = []
|
|
164
|
+
self._parts: dict[str, Part] | None = None
|
|
165
|
+
self._mcu: str | None = None
|
|
166
|
+
|
|
167
|
+
def __repr__(self) -> str:
|
|
168
|
+
return f"<Board {self.name}>"
|
|
169
|
+
|
|
170
|
+
def __getattr__(self, name: str) -> "Part":
|
|
171
|
+
# board.U4: a part by its reference designator.
|
|
172
|
+
if _REF.match(name):
|
|
173
|
+
try:
|
|
174
|
+
return self.part(name)
|
|
175
|
+
except KeyError as e:
|
|
176
|
+
raise AttributeError(e.args[0]) from None
|
|
177
|
+
raise AttributeError(f"{type(self).__name__!r} object has no attribute {name!r}")
|
|
178
|
+
|
|
179
|
+
def __dir__(self) -> list[str]:
|
|
180
|
+
# Tab completion offers the parts once they have been listed (no request here).
|
|
181
|
+
return [*super().__dir__(), *(self._parts or {})]
|
|
182
|
+
|
|
183
|
+
def _path(self, rest: str) -> str:
|
|
184
|
+
return f"/v1/jobs/{self.id}/sim" + (f"/{rest}" if rest else "")
|
|
185
|
+
|
|
186
|
+
# --- the session --------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
def start(self, firmware: FirmwareArg = None) -> "Board":
|
|
189
|
+
"""Boot the board afresh with *firmware*: an ELF, or the images a
|
|
190
|
+
flash holds (``"bootloader.bin@0x00400000"``, ``Image(path, address)``,
|
|
191
|
+
Intel HEX), packed by the server into one image. Without firmware, the
|
|
192
|
+
board runs its own. A board already running is restarted."""
|
|
193
|
+
if firmware is not None:
|
|
194
|
+
self._images = as_images(firmware)
|
|
195
|
+
for bridge in self._bridges:
|
|
196
|
+
bridge.reconnect_later()
|
|
197
|
+
if self._images:
|
|
198
|
+
if len(self._images) == 1 and self._images[0].kind == "elf" and self._images[0].address is None:
|
|
199
|
+
img = self._images[0]
|
|
200
|
+
self.started = self._http.call("POST", self._path("start"), files=[("firmware", img.name, img.data)],
|
|
201
|
+
timeout=300)
|
|
202
|
+
else:
|
|
203
|
+
self.started = self._http.call(
|
|
204
|
+
"POST", self._path("start"), timeout=300,
|
|
205
|
+
files=[("image", img.name, img.data) for img in self._images],
|
|
206
|
+
form=[("address", "" if img.address is None else f"0x{img.address:08X}") for img in self._images])
|
|
207
|
+
else:
|
|
208
|
+
self.started = self._http.call("POST", self._path("start"), {}, timeout=300)
|
|
209
|
+
self._consoles = {}
|
|
210
|
+
self._parts = None
|
|
211
|
+
return self
|
|
212
|
+
|
|
213
|
+
restart = start
|
|
214
|
+
|
|
215
|
+
def stop(self) -> None:
|
|
216
|
+
"""End the session (also on leaving a ``with`` block)."""
|
|
217
|
+
for bridge in list(self._bridges):
|
|
218
|
+
bridge.close()
|
|
219
|
+
try:
|
|
220
|
+
self._http.call("POST", self._path("stop"), {})
|
|
221
|
+
except HardLabsError:
|
|
222
|
+
pass
|
|
223
|
+
|
|
224
|
+
def __enter__(self) -> "Board":
|
|
225
|
+
return self
|
|
226
|
+
|
|
227
|
+
def __exit__(self, *exc) -> None:
|
|
228
|
+
self.stop()
|
|
229
|
+
|
|
230
|
+
def status(self) -> dict:
|
|
231
|
+
return self._http.call("GET", self._path(""))
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def firmware(self) -> dict | None:
|
|
235
|
+
"""What the session runs: name, sha256 and, for images, where each one went."""
|
|
236
|
+
return self.started.get("firmware")
|
|
237
|
+
|
|
238
|
+
# --- simulated time -----------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
def clock(self) -> float:
|
|
241
|
+
"""Seconds of simulated time since the board booted."""
|
|
242
|
+
return float(self._http.call("GET", self._path("clock"))["seconds"])
|
|
243
|
+
|
|
244
|
+
def wait(self, seconds: float) -> float:
|
|
245
|
+
"""Let *seconds* of simulated time pass; returns the clock."""
|
|
246
|
+
body: dict = {"seconds": float(seconds)}
|
|
247
|
+
while True:
|
|
248
|
+
try:
|
|
249
|
+
got = self._http.call("POST", self._path("wait"), body)
|
|
250
|
+
except HardLabsError as e:
|
|
251
|
+
if e.body.get("stalled"):
|
|
252
|
+
raise SimulationStopped(str(e), e.status, e.code, e.body) from None
|
|
253
|
+
raise
|
|
254
|
+
if got.get("reached"):
|
|
255
|
+
return float(got["sim_seconds"])
|
|
256
|
+
body = {"until": got["until"]}
|
|
257
|
+
|
|
258
|
+
# --- the console --------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
def _console(self, console: str | None) -> _Console:
|
|
261
|
+
return self._consoles.setdefault(console, _Console())
|
|
262
|
+
|
|
263
|
+
def _read(self, console: str | None) -> _Console:
|
|
264
|
+
c = self._console(console)
|
|
265
|
+
q = f"console?after={c.next}" + (f"&console={console}" if console else "")
|
|
266
|
+
got = self._http.call("GET", self._path(q))
|
|
267
|
+
c.lines.extend(Line(x["n"], x["text"]) for x in got["lines"])
|
|
268
|
+
del c.lines[:-2000]
|
|
269
|
+
c.next = got["next"]
|
|
270
|
+
return c
|
|
271
|
+
|
|
272
|
+
def send(self, line: str, *, console: str | None = None) -> None:
|
|
273
|
+
"""Type one line into the firmware's console (its shell). Output
|
|
274
|
+
already printed is not the reply to it."""
|
|
275
|
+
c = self._read(console)
|
|
276
|
+
c.cursor = c.next
|
|
277
|
+
self._http.call("POST", self._path("console"), {"text": line, **({"console": console} if console else {})})
|
|
278
|
+
|
|
279
|
+
def lines(self, *, console: str | None = None) -> list[Line]:
|
|
280
|
+
"""Every console line read so far (the last 2000)."""
|
|
281
|
+
return list(self._read(console).lines)
|
|
282
|
+
|
|
283
|
+
def wait_for(self, text: str, *, within: float = 30.0, console: str | None = None) -> Line:
|
|
284
|
+
"""The next console line holding *text*, within *within* simulated
|
|
285
|
+
seconds; ConsoleTimeout otherwise. The line and those before it are
|
|
286
|
+
used up: a second wait_for finds a later line."""
|
|
287
|
+
return self.expect_console(text, within=within, console=console)[0]
|
|
288
|
+
|
|
289
|
+
def expect_console(self, *texts: str, within: float = 30.0, console: str | None = None) -> list[Line]:
|
|
290
|
+
"""Each of *texts*, in order, each on a later line than the one before,
|
|
291
|
+
all within *within* simulated seconds."""
|
|
292
|
+
c = self._console(console)
|
|
293
|
+
deadline = None
|
|
294
|
+
found = []
|
|
295
|
+
for text in texts:
|
|
296
|
+
while True:
|
|
297
|
+
body = {"text": text, "after": c.cursor, **({"console": console} if console else {})}
|
|
298
|
+
body.update({"deadline": deadline} if deadline is not None else {"within": within})
|
|
299
|
+
try:
|
|
300
|
+
got = self._http.call("POST", self._path("console/wait"), body)
|
|
301
|
+
except HardLabsError as e:
|
|
302
|
+
if e.body.get("stalled"):
|
|
303
|
+
raise SimulationStopped(str(e), e.status, e.code, e.body) from None
|
|
304
|
+
raise
|
|
305
|
+
deadline = got.get("deadline", deadline)
|
|
306
|
+
if got.get("matched"):
|
|
307
|
+
line = Line(got["line"]["n"], got["line"]["text"])
|
|
308
|
+
c.cursor = line.n
|
|
309
|
+
found.append(line)
|
|
310
|
+
break
|
|
311
|
+
if got.get("timed_out"):
|
|
312
|
+
raise ConsoleTimeout(text, within, [x["text"] for x in got.get("recent") or []])
|
|
313
|
+
c.cursor = max(c.cursor, int(got.get("next") or c.cursor))
|
|
314
|
+
return found
|
|
315
|
+
|
|
316
|
+
# --- controls and readings ----------------------------------------------------
|
|
317
|
+
|
|
318
|
+
def controls(self) -> list[Control]:
|
|
319
|
+
"""Everything you can do to the board, under the names the CLI and scenarios use."""
|
|
320
|
+
return [_control(c) for c in self._http.call("GET", self._path("controls"))["controls"]]
|
|
321
|
+
|
|
322
|
+
def do(self, control: str, value: Any = None, **values: float) -> str:
|
|
323
|
+
"""Work a control: ``do("input-source.usb", True)``,
|
|
324
|
+
``do("battery.level", 30)``, ``do("bench-supply.set", volts=5.0)``,
|
|
325
|
+
``do("reset.press")``, or a part's by its reference designator,
|
|
326
|
+
``do("U4.extra-load-current", 0.5)``. Returns its output, if any."""
|
|
327
|
+
body: dict = {"control": control}
|
|
328
|
+
if value is not None:
|
|
329
|
+
body["value"] = value
|
|
330
|
+
if values:
|
|
331
|
+
body["values"] = values
|
|
332
|
+
return self._http.call("POST", self._path("do"), body).get("output", "")
|
|
333
|
+
|
|
334
|
+
def readings(self, *names: str) -> dict[str, Reading]:
|
|
335
|
+
"""The values the panels show now, by name (all when none given)."""
|
|
336
|
+
q = "&".join(f"name={n}" for n in names)
|
|
337
|
+
got = self._http.call("GET", self._path("readings" + (f"?{q}" if q else "")))
|
|
338
|
+
return {r["name"]: Reading(r["name"], r["label"], r["value"], r.get("unit"), r["text"], tuple(r.get("aliases") or ()))
|
|
339
|
+
for r in got["readings"]}
|
|
340
|
+
|
|
341
|
+
def reading(self, name: str) -> Reading:
|
|
342
|
+
return next(iter(self.readings(name).values()))
|
|
343
|
+
|
|
344
|
+
def read(self, name: str) -> Any:
|
|
345
|
+
"""A reading's value: a number, True/False, a name, or None when the board doesn't report it."""
|
|
346
|
+
return self.reading(name).value
|
|
347
|
+
|
|
348
|
+
# --- parts by reference designator ---------------------------------------------
|
|
349
|
+
|
|
350
|
+
def _all_parts(self) -> list[Part]:
|
|
351
|
+
if self._parts is None:
|
|
352
|
+
got = self._http.call("GET", self._path("parts"))
|
|
353
|
+
self._mcu = got.get("mcu")
|
|
354
|
+
self._parts = {
|
|
355
|
+
p["ref"]: Part(p["ref"], p.get("part"), p.get("what"), p.get("where"), p.get("status"), p.get("note"),
|
|
356
|
+
tuple(_control(c) for c in p.get("controls") or ()),
|
|
357
|
+
tuple(r["name"] for r in p.get("readings") or ()),
|
|
358
|
+
p.get("bus"), p.get("address"), p.get("cs"), p.get("behind"),
|
|
359
|
+
tuple(p.get("mcu_pins") or ()), tuple(p.get("connected") or ()), bool(p.get("mcu")),
|
|
360
|
+
self)
|
|
361
|
+
for p in got["parts"]
|
|
362
|
+
}
|
|
363
|
+
return list(self._parts.values())
|
|
364
|
+
|
|
365
|
+
def parts(self, *, bus: str | None = None, address: int | str | None = None,
|
|
366
|
+
connected_to: "str | Part | None" = None, status: str | None = None) -> list[Part]:
|
|
367
|
+
"""Every part on the board by its schematic reference designator
|
|
368
|
+
(passives left out), each with what it is, how far it is simulated,
|
|
369
|
+
where it sits, and the controls and readings it answers to as
|
|
370
|
+
``<ref>.<name>``. Narrow it like a DOM query, any filters combined:
|
|
371
|
+
``parts(bus="i2c0")``, ``parts(address=0x6B)``,
|
|
372
|
+
``parts(connected_to="U1")`` (the processor's include the parts on its
|
|
373
|
+
buses), ``parts(status="not_simulated")``."""
|
|
374
|
+
every = self._all_parts()
|
|
375
|
+
want = _as_address(address) if address is not None else None
|
|
376
|
+
to = (connected_to.ref if isinstance(connected_to, Part) else connected_to or "").lower()
|
|
377
|
+
return [p for p in every
|
|
378
|
+
if (bus is None or (p.bus or "").lower() == bus.lower())
|
|
379
|
+
and (want is None or p.address == want)
|
|
380
|
+
and (not to or to in (r.lower() for r in p.connected_refs))
|
|
381
|
+
and (status is None or p.status == status)]
|
|
382
|
+
|
|
383
|
+
def find(self, **filters: Any) -> Part:
|
|
384
|
+
"""The one part :meth:`parts` finds with *filters*:
|
|
385
|
+
``find(bus="i2c0", address=0x6B)``. Raises when none or several match."""
|
|
386
|
+
found = self.parts(**filters)
|
|
387
|
+
if len(found) != 1:
|
|
388
|
+
said = ", ".join(f"{k}={v!r}" for k, v in filters.items())
|
|
389
|
+
raise LookupError(f"{len(found) or 'no'} parts with {said}" +
|
|
390
|
+
(f": {', '.join(p.ref for p in found)}" if found else ""))
|
|
391
|
+
return found[0]
|
|
392
|
+
|
|
393
|
+
@property
|
|
394
|
+
def mcu(self) -> Part:
|
|
395
|
+
"""The processor: ``board.mcu.connected()`` is every part it reaches."""
|
|
396
|
+
self._all_parts()
|
|
397
|
+
if not self._mcu:
|
|
398
|
+
raise LookupError("this board's parts list doesn't say which part is the processor")
|
|
399
|
+
return self.part(self._mcu)
|
|
400
|
+
|
|
401
|
+
def part(self, ref: str) -> Part:
|
|
402
|
+
"""One part by its reference designator, in any case: ``part("U4")``."""
|
|
403
|
+
parts = {p.ref.lower(): p for p in self._all_parts()}
|
|
404
|
+
found = parts.get(ref.lower())
|
|
405
|
+
if found is None:
|
|
406
|
+
raise KeyError(f"no part {ref!r} on this board — parts: {', '.join(p.ref for p in parts.values()) or 'none listed'}")
|
|
407
|
+
return found
|
|
408
|
+
|
|
409
|
+
def problems(self) -> list[dict]:
|
|
410
|
+
"""What went wrong in the run so far: the firmware's crashes, asserts
|
|
411
|
+
and restarts, and what may be the simulation's limit."""
|
|
412
|
+
return self._http.call("GET", self._path("state?displays=0")).get("problems") or []
|
|
413
|
+
|
|
414
|
+
# --- serial ports and unit tests ----------------------------------------------
|
|
415
|
+
|
|
416
|
+
def serial_ports(self) -> list[str]:
|
|
417
|
+
"""The ports :meth:`serial` can open: ``console``, the board's declared ports, ``usb:<controller>``."""
|
|
418
|
+
return list(self.status().get("serial") or [])
|
|
419
|
+
|
|
420
|
+
def serial(self, port: str = "console", *, pty: bool = False) -> "SerialBridge":
|
|
421
|
+
"""A local port your own framework opens like the real one: its
|
|
422
|
+
``url`` (``socket://127.0.0.1:N``) for pyserial's serial_for_url, or
|
|
423
|
+
with *pty* a pseudo-terminal ``path`` (macOS and Linux)."""
|
|
424
|
+
from .serial import SerialBridge
|
|
425
|
+
|
|
426
|
+
bridge = SerialBridge(self, port, pty=pty)
|
|
427
|
+
self._bridges.append(bridge)
|
|
428
|
+
return bridge
|
|
429
|
+
|
|
430
|
+
def _forget(self, bridge: "SerialBridge") -> None:
|
|
431
|
+
if bridge in self._bridges:
|
|
432
|
+
self._bridges.remove(bridge)
|
|
433
|
+
|
|
434
|
+
def run_unit_tests(self, *, within: float = 300.0, done: str | None = None,
|
|
435
|
+
console: str | None = None) -> "UnitTestReport":
|
|
436
|
+
"""Collect an on-target test binary's results from the console:
|
|
437
|
+
Unity or GoogleTest output, until its summary (or *done*) appears."""
|
|
438
|
+
from .unit_tests import collect
|
|
439
|
+
|
|
440
|
+
return collect(self, within=within, done=done, console=console)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _control(c: dict) -> Control:
|
|
444
|
+
return Control(c["name"], c["kind"], c["label"], c["usage"], c.get("min"), c.get("max"),
|
|
445
|
+
c.get("unit"), tuple(c.get("fields") or ()), tuple(c.get("aliases") or ()))
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _as_address(v: int | str) -> int:
|
|
449
|
+
if isinstance(v, int) and not isinstance(v, bool):
|
|
450
|
+
return v
|
|
451
|
+
try:
|
|
452
|
+
return int(str(v).strip(), 0)
|
|
453
|
+
except ValueError:
|
|
454
|
+
raise ValueError(f"address: {v!r} is not a number (0x6B or 107)") from None
|