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.
@@ -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())
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