embeddedci 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.
embeddedci/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """embeddedci — Python tooling for EmbeddedCI hardware.
2
+
3
+ The :mod:`embeddedci.benchpod` subpackage is a pytest-friendly client for a
4
+ BenchPod device. Import it as::
5
+
6
+ from embeddedci import benchpod
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from importlib.metadata import PackageNotFoundError, version
12
+
13
+ from . import benchpod
14
+
15
+ try:
16
+ __version__ = version("embeddedci")
17
+ except PackageNotFoundError: # running from a source tree without install
18
+ __version__ = "0.0.0+unknown"
19
+
20
+ __all__ = ["benchpod", "__version__"]
@@ -0,0 +1,95 @@
1
+ """BenchPod — a pytest-friendly client for an EmbeddedCI BenchPod device.
2
+
3
+ Connect over wifi/network or serial, power the target, flash firmware and assert
4
+ the result, all from a test::
5
+
6
+ from embeddedci import benchpod
7
+
8
+ def test_boots(benchpod_device): # the `benchpod` fixture is also available
9
+ benchpod_device.power_on(benchpod.INTERNAL)
10
+ result = benchpod_device.flash(
11
+ file="firmware.elf", target="target/stm32f1x.cfg",
12
+ swclk=benchpod.PIN1, swdio=benchpod.PIN2, nreset=benchpod.PIN3,
13
+ target_power=benchpod.INTERNAL,
14
+ )
15
+ assert result.ok
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from . import i2c
21
+ from .client import BenchPod
22
+ from .connection import ConnSpec, parse_connection, resolve_connection
23
+ from .i2c import I2CByte, I2CMessage, I2CTransaction
24
+ from .constants import (
25
+ BMP280_ADDR_PRIMARY,
26
+ BMP280_ADDR_SECONDARY,
27
+ EXTERNAL,
28
+ INTERNAL,
29
+ PIN1,
30
+ PIN2,
31
+ PIN3,
32
+ PIN4,
33
+ PIN5,
34
+ PIN6,
35
+ PIN7,
36
+ PIN8,
37
+ PIN9,
38
+ PIN10,
39
+ PIN11,
40
+ PIN12,
41
+ Efuse,
42
+ Pin,
43
+ Sensor,
44
+ )
45
+ from .errors import (
46
+ BenchPodError,
47
+ ConnectionConfigError,
48
+ FirmwareError,
49
+ FlashError,
50
+ TargetUnreachableError,
51
+ TransportError,
52
+ )
53
+ from .flash import FlashResult
54
+ from .uart import UartCapture
55
+
56
+ __all__ = [
57
+ "BenchPod",
58
+ "FlashResult",
59
+ "UartCapture",
60
+ "i2c",
61
+ "I2CByte",
62
+ "I2CMessage",
63
+ "I2CTransaction",
64
+ # connection
65
+ "ConnSpec",
66
+ "resolve_connection",
67
+ "parse_connection",
68
+ # constants
69
+ "Efuse",
70
+ "Pin",
71
+ "Sensor",
72
+ "INTERNAL",
73
+ "EXTERNAL",
74
+ "BMP280_ADDR_PRIMARY",
75
+ "BMP280_ADDR_SECONDARY",
76
+ "PIN1",
77
+ "PIN2",
78
+ "PIN3",
79
+ "PIN4",
80
+ "PIN5",
81
+ "PIN6",
82
+ "PIN7",
83
+ "PIN8",
84
+ "PIN9",
85
+ "PIN10",
86
+ "PIN11",
87
+ "PIN12",
88
+ # errors
89
+ "BenchPodError",
90
+ "ConnectionConfigError",
91
+ "TransportError",
92
+ "FirmwareError",
93
+ "FlashError",
94
+ "TargetUnreachableError",
95
+ ]
@@ -0,0 +1,314 @@
1
+ """The :class:`BenchPod` facade — the user-facing API.
2
+
3
+ Wraps a :class:`~embeddedci.benchpod.transport.base.Transport` with intuitive,
4
+ named operations: connect, power on/off, flash (and assert ok/not ok), plus a
5
+ stubbed I2C sensor hook. Designed to drop straight into pytest::
6
+
7
+ from embeddedci import benchpod
8
+
9
+ with benchpod.BenchPod("192.168.1.213") as bp:
10
+ bp.power_on(benchpod.INTERNAL)
11
+ assert bp.flash(file="fw.elf", target="target/stm32f1x.cfg",
12
+ swclk=benchpod.PIN1, swdio=benchpod.PIN2).ok
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import time
18
+ from typing import Any, List, Optional, Sequence, Union
19
+
20
+ from . import flash as _flash
21
+ from . import i2c as _i2c
22
+ from . import sensor as _sensor
23
+ from . import uart as _uart
24
+ from .constants import Efuse, Pin, Sensor, coerce_efuse, coerce_pin
25
+ from .connection import resolve_connection
26
+ from .errors import BenchPodError
27
+ from .flash import FlashResult
28
+ from .transport import Transport, open_transport
29
+
30
+
31
+ class BenchPod:
32
+ """A connected BenchPod device."""
33
+
34
+ def __init__(
35
+ self,
36
+ connection: Optional[str] = None,
37
+ *,
38
+ timeout: float = 30.0,
39
+ transport: Optional[Transport] = None,
40
+ ) -> None:
41
+ """Open a BenchPod.
42
+
43
+ ``connection`` is a host[:port], a serial device path, or ``"serial"``;
44
+ when omitted the ``BENCHPOD_CONNECTION`` environment variable is used.
45
+ Pass ``transport`` directly to inject a custom/standalone backend.
46
+ """
47
+ self.timeout = timeout
48
+ if transport is not None:
49
+ self._transport: Transport = transport
50
+ else:
51
+ spec = resolve_connection(connection)
52
+ self._transport = open_transport(spec, timeout=timeout)
53
+
54
+ # -- lifecycle ----------------------------------------------------------
55
+
56
+ @property
57
+ def transport(self) -> Transport:
58
+ return self._transport
59
+
60
+ def close(self) -> None:
61
+ self._transport.close()
62
+
63
+ def __enter__(self) -> "BenchPod":
64
+ return self
65
+
66
+ def __exit__(self, *exc: object) -> None:
67
+ self.close()
68
+
69
+ # -- basic status -------------------------------------------------------
70
+
71
+ def ping(self) -> Any:
72
+ """Confirm the pod is reachable."""
73
+ return self._transport.ping()
74
+
75
+ def status(self) -> Any:
76
+ """Return firmware/connection status."""
77
+ return self._transport.status()
78
+
79
+ # -- power --------------------------------------------------------------
80
+
81
+ def target_power(self, efuse: Union[Efuse, int] = Efuse.INTERNAL, *,
82
+ on: bool, delay: Optional[float] = None) -> None:
83
+ """Enable or disable a target-power eFuse.
84
+
85
+ ``delay`` (seconds) schedules the change pod-side and returns
86
+ immediately — handy to power-on *during* a UART capture.
87
+ """
88
+ delay_ms = int(round(delay * 1000)) if delay else 0
89
+ self._transport.target_power(coerce_efuse(efuse), on, delay_ms)
90
+
91
+ def power_on(self, efuse: Union[Efuse, int] = Efuse.INTERNAL,
92
+ *, delay: Optional[float] = None) -> None:
93
+ """Power the target on via the given eFuse (default INTERNAL 5V)."""
94
+ self.target_power(efuse, on=True, delay=delay)
95
+
96
+ def power_off(self, efuse: Union[Efuse, int] = Efuse.INTERNAL,
97
+ *, delay: Optional[float] = None) -> None:
98
+ """Power the target off."""
99
+ self.target_power(efuse, on=False, delay=delay)
100
+
101
+ # -- flash --------------------------------------------------------------
102
+
103
+ def flash(
104
+ self,
105
+ *,
106
+ swclk: Union[Pin, int],
107
+ swdio: Union[Pin, int],
108
+ nreset: Optional[Union[Pin, int]] = None,
109
+ target: str = "",
110
+ file: str = "",
111
+ load_address: str = "",
112
+ target_power: Optional[Union[Efuse, int]] = None,
113
+ verify: bool = True,
114
+ reset: bool = True,
115
+ connect_under_reset: Optional[bool] = None,
116
+ clear_reset_events: bool = True,
117
+ openocd_bin: Optional[str] = None,
118
+ extra_configs: Sequence[str] = (),
119
+ extra_args: Sequence[str] = (),
120
+ timeout: float = 300.0,
121
+ connect_attempts: int = 5,
122
+ check: bool = True,
123
+ ) -> FlashResult:
124
+ """Flash an SWD target and report the result.
125
+
126
+ ``swclk``/``swdio``/``nreset`` are LA pins (``benchpod.PIN1``..``PIN12``
127
+ or 1-12). ``target_power`` of ``benchpod.INTERNAL``/``EXTERNAL`` powers
128
+ the target first. By default (``check=True``) a failed flash raises
129
+ :class:`FlashError`/:class:`TargetUnreachableError`; pass ``check=False``
130
+ to get the :class:`FlashResult` and ``assert result.ok`` yourself.
131
+ """
132
+ swclk_i = coerce_pin(swclk, "swclk")
133
+ swdio_i = coerce_pin(swdio, "swdio")
134
+ if swclk_i == swdio_i:
135
+ raise BenchPodError("swclk and swdio must be different LA pins")
136
+ nreset_i = coerce_pin(nreset, "nreset") if nreset is not None else None
137
+ power = coerce_efuse(target_power) if target_power is not None else None
138
+
139
+ result = _flash.flash(
140
+ self._transport,
141
+ swclk=swclk_i, swdio=swdio_i, nreset=nreset_i,
142
+ target=target, file=file, load_address=load_address,
143
+ target_power=power, verify=verify, reset=reset,
144
+ connect_under_reset=connect_under_reset,
145
+ clear_reset_events=clear_reset_events,
146
+ openocd_bin=openocd_bin,
147
+ extra_configs=extra_configs, extra_args=extra_args,
148
+ timeout=timeout, connect_attempts=connect_attempts,
149
+ )
150
+ if check:
151
+ _flash.raise_for_result(result)
152
+ return result
153
+
154
+ # -- LA pin pull-ups (LA1-8) --------------------------------------------
155
+
156
+ def pullup(self, la: Union[Pin, int], on: Optional[bool] = None) -> dict:
157
+ """Switch (or query) an LA pin's pull-up resistor. LA1-8 only.
158
+
159
+ The pod has fixed pull-ups (LA1/2 = 4.7k, LA3/4 = 2.2k, LA5-8 = 10k) —
160
+ enable them on the I2C SDA/SCL lines so an open-drain bus idles high.
161
+ ``on=None`` queries without changing. Returns e.g.
162
+ ``{"la":1,"pullup":1,"ohms":"4.7k"}``.
163
+ """
164
+ la_i = coerce_pin(la, "la")
165
+ if not 1 <= la_i <= 8:
166
+ raise BenchPodError("pull-ups are only on LA1-8")
167
+ req: dict = {"cmd": "pullup", "la": la_i}
168
+ if on is not None:
169
+ req["state"] = "on" if on else "off"
170
+ return self.command(req)
171
+
172
+ def enable_pullup(self, *las: Union[Pin, int]) -> None:
173
+ """Enable the pull-up on one or more LA pins (LA1-8)."""
174
+ for la in las:
175
+ self.pullup(la, on=True)
176
+
177
+ def disable_pullup(self, *las: Union[Pin, int]) -> None:
178
+ """Disable the pull-up on one or more LA pins (LA1-8)."""
179
+ for la in las:
180
+ self.pullup(la, on=False)
181
+
182
+ def pullup_status(self) -> dict:
183
+ """Return ``{"la_pullup_mask": <bitmask>}`` (bit la-1 set = pull-up on)."""
184
+ return self.command({"cmd": "pullup_status"})
185
+
186
+ # -- emulated I2C sensor (TCP transport only) ---------------------------
187
+
188
+ def enable_i2c_sensor(
189
+ self,
190
+ sensor: Union[Sensor, str] = Sensor.BMP280,
191
+ *,
192
+ sda: Union[Pin, int],
193
+ scl: Union[Pin, int],
194
+ address: int = 0x76,
195
+ temperature_c: Optional[float] = None,
196
+ pressure_pa: Optional[float] = None,
197
+ ) -> dict:
198
+ """Make the pod emulate an I2C sensor (e.g. BMP280) on ``sda``/``scl``.
199
+
200
+ The pod becomes an I2C slave the DUT's master can read. Optionally seed
201
+ initial ``temperature_c``/``pressure_pa``. Returns the start response.
202
+ """
203
+ result = _sensor.sensor_start(
204
+ self._transport, sensor, sda=sda, scl=scl, address=address
205
+ )
206
+ if temperature_c is not None or pressure_pa is not None:
207
+ _sensor.sensor_set(self._transport, temperature_c=temperature_c,
208
+ pressure_pa=pressure_pa)
209
+ return result
210
+
211
+ def set_i2c_sensor(self, *, temperature_c: Optional[float] = None,
212
+ pressure_pa: Optional[float] = None) -> dict:
213
+ """Update the emulated sensor's reported values."""
214
+ return _sensor.sensor_set(self._transport, temperature_c=temperature_c,
215
+ pressure_pa=pressure_pa)
216
+
217
+ def disable_i2c_sensor(self) -> None:
218
+ """Disarm the emulated sensor (safe if none is active)."""
219
+ _sensor.sensor_stop(self._transport)
220
+
221
+ def i2c_sensor_status(self) -> dict:
222
+ """Return sensor + I2C-bus activity counters."""
223
+ return _sensor.sensor_status(self._transport)
224
+
225
+ def i2c_sensor_regs(self, start: int = 0, length: int = 256) -> List[int]:
226
+ """Read the emulated register image."""
227
+ return _sensor.sensor_regs(self._transport, start, length)
228
+
229
+ def i2c_sensor_la(self, samples: int = 256,
230
+ sample_rate_mhz: Optional[float] = None) -> List[int]:
231
+ """Raw I2C-bus logic capture (packed bytes)."""
232
+ return _sensor.sensor_la(self._transport, samples, sample_rate_mhz)
233
+
234
+ def i2c_sensor_la_decoded(self, samples: int = 1024,
235
+ sample_rate_mhz: Optional[float] = None
236
+ ) -> "List[_i2c.I2CTransaction]":
237
+ """Capture the I2C bus and decode it into transactions.
238
+
239
+ Convenience over :meth:`i2c_sensor_la` + :func:`benchpod.i2c.decode`.
240
+ Sample fast enough to resolve the bus: ~1 MS/s (the default) gives ~10
241
+ samples per bit at 100 kHz I2C over a ~4 ms window per 1024 bytes.
242
+ """
243
+ raw = self.i2c_sensor_la(samples, sample_rate_mhz)
244
+ return _i2c.decode(raw)
245
+
246
+ # -- UART capture -------------------------------------------------------
247
+
248
+ def capture_uart(
249
+ self,
250
+ *,
251
+ rx: Union[Pin, int],
252
+ tx: Union[Pin, int],
253
+ baud: int = 115200,
254
+ duration: float,
255
+ until: Optional[_uart.Until] = None,
256
+ ) -> "_uart.UartCapture":
257
+ """Capture the DUT's UART output for ``duration`` seconds.
258
+
259
+ ``rx`` is the LA channel the pod samples (wire the DUT's TX here); ``tx``
260
+ is driven (DUT's RX). ``until`` (substring/regex/predicate) stops early.
261
+ """
262
+ link = self._transport.uart_proxy_start(
263
+ coerce_pin(rx, "rx"), coerce_pin(tx, "tx"), int(baud)
264
+ )
265
+ return _uart.capture(link, duration=duration, until=until)
266
+
267
+ def power_cycle_and_capture(
268
+ self,
269
+ *,
270
+ rx: Union[Pin, int],
271
+ tx: Union[Pin, int],
272
+ efuse: Union[Efuse, int] = Efuse.INTERNAL,
273
+ delay: float = 1.0,
274
+ duration: float = 4.0,
275
+ baud: int = 115200,
276
+ until: Optional[_uart.Until] = None,
277
+ off_settle: float = 0.3,
278
+ ) -> "_uart.UartCapture":
279
+ """Power-cycle the target while capturing its boot output.
280
+
281
+ Powers the eFuse off, schedules a power-on ``delay`` seconds out (pod-
282
+ side timer), then enters UART capture so the scheduled power-on — and the
283
+ DUT's boot banner — land *inside* the capture window. ``duration`` should
284
+ comfortably exceed ``delay``.
285
+ """
286
+ self.power_off(efuse)
287
+ if off_settle:
288
+ time.sleep(off_settle)
289
+ self.power_on(efuse, delay=delay)
290
+ return self.capture_uart(rx=rx, tx=tx, baud=baud, duration=duration,
291
+ until=until)
292
+
293
+ # -- low-level pass-throughs (TCP transport only) -----------------------
294
+
295
+ def command(self, req: dict) -> Any:
296
+ """Send a raw JSON command (TCP transport only)."""
297
+ cmd = getattr(self._transport, "command", None)
298
+ if cmd is None:
299
+ raise BenchPodError("raw JSON commands are only available on the TCP transport")
300
+ return cmd(req)
301
+
302
+ def capture(self, samples: int = 256, *, sample_rate_mhz: Optional[float] = None) -> List[int]:
303
+ """Capture ADC samples (TCP transport only)."""
304
+ fn = getattr(self._transport, "samples", None)
305
+ if fn is None:
306
+ raise BenchPodError("capture is only available on the TCP transport")
307
+ req: dict = {"cmd": "capture", "samples": samples}
308
+ if sample_rate_mhz is not None:
309
+ req["sample_rate_mhz"] = sample_rate_mhz
310
+ return fn(req)
311
+
312
+ def gpio_set(self, la: Union[Pin, int], state: Union[int, str]) -> Any:
313
+ """Drive an LA channel high/low/high-Z (TCP transport only)."""
314
+ return self.command({"cmd": "gpio_set", "la": coerce_pin(la, "la"), "state": state})
@@ -0,0 +1,84 @@
1
+ """Resolve a connection string into a concrete transport spec.
2
+
3
+ Mirrors the Go CLI's ``connection.go``:
4
+
5
+ * ``host`` or ``host:port`` -> TCP/wifi (default port 8080)
6
+ * ``/dev/tty*`` / ``COM3`` / ``\\\\.\\COM3`` -> serial device path
7
+ * ``serial`` / ``usb`` -> serial, auto-detect by USB VID 0x2E8A
8
+
9
+ Precedence is handled by the caller: an explicit argument wins over the
10
+ ``BENCHPOD_CONNECTION`` environment variable.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import re
17
+ from dataclasses import dataclass
18
+
19
+ from .errors import ConnectionConfigError
20
+
21
+ DEFAULT_PORT = 8080
22
+ ENV_VAR = "BENCHPOD_CONNECTION"
23
+
24
+ _COM_RE = re.compile(r"^COM[0-9]+$", re.IGNORECASE)
25
+
26
+
27
+ @dataclass
28
+ class ConnSpec:
29
+ """A resolved connection target."""
30
+
31
+ kind: str # "tcp" or "serial"
32
+ addr: str = "" # "host:port" when kind == "tcp"
33
+ device: str = "" # device path when kind == "serial"; "" means auto-detect
34
+
35
+ def is_wifi(self) -> bool:
36
+ return self.kind == "tcp"
37
+
38
+ def is_serial(self) -> bool:
39
+ return self.kind == "serial"
40
+
41
+
42
+ def _is_device_path(s: str) -> bool:
43
+ if s.startswith("/dev/"):
44
+ return True
45
+ if s.startswith("\\\\.\\"): # Windows \\.\COM10 form
46
+ return True
47
+ if _COM_RE.match(s):
48
+ return True
49
+ return False
50
+
51
+
52
+ def _normalize_addr(s: str) -> str:
53
+ # Bracketed IPv6 literal, optionally with a port.
54
+ if s.startswith("["):
55
+ return s if "]:" in s else f"{s}:{DEFAULT_PORT}"
56
+ # A single colon means host:port; more than one and no brackets means a bare
57
+ # IPv6 address, which needs the default port appended as-is.
58
+ if s.count(":") == 1:
59
+ return s
60
+ return f"{s}:{DEFAULT_PORT}"
61
+
62
+
63
+ def parse_connection(raw: str) -> ConnSpec:
64
+ """Parse a connection string into a :class:`ConnSpec`."""
65
+ s = raw.strip()
66
+ if not s:
67
+ raise ConnectionConfigError("connection string is empty")
68
+ if s.lower() in ("serial", "usb"):
69
+ return ConnSpec(kind="serial", device="")
70
+ if _is_device_path(s):
71
+ return ConnSpec(kind="serial", device=s)
72
+ return ConnSpec(kind="tcp", addr=_normalize_addr(s))
73
+
74
+
75
+ def resolve_connection(connection: "str | None" = None) -> ConnSpec:
76
+ """Resolve a connection from an explicit value or the environment."""
77
+ raw = connection if connection is not None else os.environ.get(ENV_VAR)
78
+ if not raw or not str(raw).strip():
79
+ raise ConnectionConfigError(
80
+ "no BenchPod connection configured; pass connection=... or set "
81
+ f"the {ENV_VAR} environment variable "
82
+ "(e.g. '192.168.1.213', '/dev/ttyACM0', or 'serial')"
83
+ )
84
+ return parse_connection(str(raw))
@@ -0,0 +1,95 @@
1
+ """Static, named constants for the BenchPod API.
2
+
3
+ The firmware speaks raw integers — eFuse ``1``/``2`` and LA channels ``1``-``12``.
4
+ These :class:`~enum.IntEnum` types give those wire values intuitive names so test
5
+ code reads ``benchpod.INTERNAL`` / ``benchpod.PIN1`` instead of bare numbers.
6
+ Because they are ``IntEnum``s they serialize as their integer on the wire, and
7
+ the coercion helpers below accept either an enum member or a plain int.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from enum import Enum, IntEnum
13
+ from typing import Union
14
+
15
+ from .errors import BenchPodError
16
+
17
+
18
+ class Efuse(IntEnum):
19
+ """Target-power eFuse rail."""
20
+
21
+ INTERNAL = 1 # internal 5V supply
22
+ EXTERNAL = 2 # external supply
23
+
24
+
25
+ class Sensor(str, Enum):
26
+ """Emulated I2C sensor model (firmware ships BMP280 today)."""
27
+
28
+ BMP280 = "bmp280"
29
+
30
+
31
+ # Common BMP280 7-bit I2C addresses (datasheet: SDO low / high).
32
+ BMP280_ADDR_PRIMARY = 0x76
33
+ BMP280_ADDR_SECONDARY = 0x77
34
+
35
+
36
+ class Pin(IntEnum):
37
+ """Logic-analyzer channel (LA1..LA12) on the iCE40 FPGA bank."""
38
+
39
+ PIN1 = 1
40
+ PIN2 = 2
41
+ PIN3 = 3
42
+ PIN4 = 4
43
+ PIN5 = 5
44
+ PIN6 = 6
45
+ PIN7 = 7
46
+ PIN8 = 8
47
+ PIN9 = 9
48
+ PIN10 = 10
49
+ PIN11 = 11
50
+ PIN12 = 12
51
+
52
+
53
+ # Module-level aliases, re-exported from ``benchpod`` so callers can write
54
+ # ``benchpod.INTERNAL`` and ``benchpod.PIN1``.
55
+ INTERNAL = Efuse.INTERNAL
56
+ EXTERNAL = Efuse.EXTERNAL
57
+
58
+ PIN1 = Pin.PIN1
59
+ PIN2 = Pin.PIN2
60
+ PIN3 = Pin.PIN3
61
+ PIN4 = Pin.PIN4
62
+ PIN5 = Pin.PIN5
63
+ PIN6 = Pin.PIN6
64
+ PIN7 = Pin.PIN7
65
+ PIN8 = Pin.PIN8
66
+ PIN9 = Pin.PIN9
67
+ PIN10 = Pin.PIN10
68
+ PIN11 = Pin.PIN11
69
+ PIN12 = Pin.PIN12
70
+
71
+
72
+ def coerce_efuse(value: Union[Efuse, int]) -> int:
73
+ """Validate and normalize an eFuse selector to ``1`` or ``2``."""
74
+ try:
75
+ ivalue = int(value)
76
+ except (TypeError, ValueError):
77
+ raise BenchPodError(f"efuse must be 1 (INTERNAL) or 2 (EXTERNAL), got {value!r}") from None
78
+ if ivalue not in (Efuse.INTERNAL, Efuse.EXTERNAL):
79
+ raise BenchPodError(
80
+ f"efuse must be 1 (INTERNAL) or 2 (EXTERNAL), got {value!r}"
81
+ )
82
+ return ivalue
83
+
84
+
85
+ def coerce_pin(value: Union[Pin, int], name: str = "pin") -> int:
86
+ """Validate and normalize an LA pin selector to an int in ``1``..``12``."""
87
+ try:
88
+ ivalue = int(value)
89
+ except (TypeError, ValueError):
90
+ raise BenchPodError(f"{name} must be an LA pin 1-12 (e.g. benchpod.PIN1), got {value!r}") from None
91
+ if not 1 <= ivalue <= 12:
92
+ raise BenchPodError(
93
+ f"{name} must be an LA pin 1-12 (e.g. benchpod.PIN1), got {value!r}"
94
+ )
95
+ return ivalue
@@ -0,0 +1,40 @@
1
+ """Exception hierarchy for the BenchPod client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+
8
+ class BenchPodError(Exception):
9
+ """Base class for every error this package raises."""
10
+
11
+
12
+ class ConnectionConfigError(BenchPodError):
13
+ """No usable connection was configured, or the spec could not be parsed."""
14
+
15
+
16
+ class TransportError(BenchPodError):
17
+ """A transport-level failure: could not reach or talk to the pod."""
18
+
19
+
20
+ class FirmwareError(BenchPodError):
21
+ """The pod accepted the request but replied ``{"status":"error"}``."""
22
+
23
+ def __init__(self, message: str, *, cmd: Optional[str] = None) -> None:
24
+ self.firmware_message = message
25
+ self.cmd = cmd
26
+ if cmd:
27
+ super().__init__(f"{cmd}: {message}")
28
+ else:
29
+ super().__init__(message)
30
+
31
+
32
+ class FlashError(BenchPodError):
33
+ """Flashing failed — OpenOCD exited non-zero (see ``stderr``)."""
34
+
35
+
36
+ class TargetUnreachableError(FlashError):
37
+ """OpenOCD's probe worked but the target never answered on SWD.
38
+
39
+ Almost always means the target is unpowered, mis-wired, or held in reset.
40
+ """