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 +20 -0
- embeddedci/benchpod/__init__.py +95 -0
- embeddedci/benchpod/client.py +314 -0
- embeddedci/benchpod/connection.py +84 -0
- embeddedci/benchpod/constants.py +95 -0
- embeddedci/benchpod/errors.py +40 -0
- embeddedci/benchpod/flash.py +430 -0
- embeddedci/benchpod/i2c.py +252 -0
- embeddedci/benchpod/protocol.py +61 -0
- embeddedci/benchpod/pytest_plugin.py +170 -0
- embeddedci/benchpod/sensor.py +89 -0
- embeddedci/benchpod/transport/__init__.py +46 -0
- embeddedci/benchpod/transport/base.py +73 -0
- embeddedci/benchpod/transport/serial.py +344 -0
- embeddedci/benchpod/transport/tcp.py +212 -0
- embeddedci/benchpod/uart.py +90 -0
- embeddedci/py.typed +0 -0
- embeddedci-0.1.0.dist-info/METADATA +242 -0
- embeddedci-0.1.0.dist-info/RECORD +22 -0
- embeddedci-0.1.0.dist-info/WHEEL +4 -0
- embeddedci-0.1.0.dist-info/entry_points.txt +2 -0
- embeddedci-0.1.0.dist-info/licenses/LICENSE +202 -0
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
|
+
"""
|