embeddedci 0.2.0__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {embeddedci-0.2.0 → embeddedci-0.2.2}/PKG-INFO +38 -10
- {embeddedci-0.2.0 → embeddedci-0.2.2}/README.md +37 -9
- embeddedci-0.2.2/docs/event-uart-design.md +182 -0
- {embeddedci-0.2.0 → embeddedci-0.2.2}/examples/README.md +15 -9
- {embeddedci-0.2.0 → embeddedci-0.2.2}/examples/test_bmp280.py +35 -19
- {embeddedci-0.2.0 → embeddedci-0.2.2}/pyproject.toml +1 -1
- {embeddedci-0.2.0 → embeddedci-0.2.2}/src/embeddedci/benchpod/__init__.py +4 -1
- {embeddedci-0.2.0 → embeddedci-0.2.2}/src/embeddedci/benchpod/client.py +30 -0
- {embeddedci-0.2.0 → embeddedci-0.2.2}/src/embeddedci/benchpod/cloud_auth.py +24 -3
- {embeddedci-0.2.0 → embeddedci-0.2.2}/src/embeddedci/benchpod/errors.py +12 -0
- {embeddedci-0.2.0 → embeddedci-0.2.2}/src/embeddedci/benchpod/flash.py +136 -116
- {embeddedci-0.2.0 → embeddedci-0.2.2}/src/embeddedci/benchpod/pytest_plugin.py +52 -43
- {embeddedci-0.2.0 → embeddedci-0.2.2}/src/embeddedci/benchpod/transport/base.py +9 -0
- {embeddedci-0.2.0 → embeddedci-0.2.2}/src/embeddedci/benchpod/transport/cloud.py +49 -3
- {embeddedci-0.2.0 → embeddedci-0.2.2}/src/embeddedci/benchpod/transport/serial.py +8 -0
- {embeddedci-0.2.0 → embeddedci-0.2.2}/src/embeddedci/benchpod/transport/tcp.py +6 -0
- embeddedci-0.2.2/src/embeddedci/benchpod/uart.py +257 -0
- {embeddedci-0.2.0 → embeddedci-0.2.2}/tests/examples/test_bmp280_hil.py +58 -38
- {embeddedci-0.2.0 → embeddedci-0.2.2}/tests/test_cloud.py +53 -6
- {embeddedci-0.2.0 → embeddedci-0.2.2}/tests/test_flash_bridge.py +11 -5
- {embeddedci-0.2.0 → embeddedci-0.2.2}/tests/test_integration.py +8 -8
- embeddedci-0.2.2/tests/test_uart_session.py +114 -0
- embeddedci-0.2.0/src/embeddedci/benchpod/uart.py +0 -90
- {embeddedci-0.2.0 → embeddedci-0.2.2}/.gitignore +0 -0
- {embeddedci-0.2.0 → embeddedci-0.2.2}/src/embeddedci/__init__.py +0 -0
- {embeddedci-0.2.0 → embeddedci-0.2.2}/src/embeddedci/benchpod/connection.py +0 -0
- {embeddedci-0.2.0 → embeddedci-0.2.2}/src/embeddedci/benchpod/constants.py +0 -0
- {embeddedci-0.2.0 → embeddedci-0.2.2}/src/embeddedci/benchpod/i2c.py +0 -0
- {embeddedci-0.2.0 → embeddedci-0.2.2}/src/embeddedci/benchpod/protocol.py +0 -0
- {embeddedci-0.2.0 → embeddedci-0.2.2}/src/embeddedci/benchpod/sensor.py +0 -0
- {embeddedci-0.2.0 → embeddedci-0.2.2}/src/embeddedci/benchpod/transport/__init__.py +0 -0
- {embeddedci-0.2.0 → embeddedci-0.2.2}/src/embeddedci/py.typed +0 -0
- {embeddedci-0.2.0 → embeddedci-0.2.2}/tests/conftest.py +0 -0
- {embeddedci-0.2.0 → embeddedci-0.2.2}/tests/examples/test_smoke.py +0 -0
- {embeddedci-0.2.0 → embeddedci-0.2.2}/tests/test_connection.py +0 -0
- {embeddedci-0.2.0 → embeddedci-0.2.2}/tests/test_i2c_decode.py +0 -0
- {embeddedci-0.2.0 → embeddedci-0.2.2}/tests/test_protocol.py +0 -0
- {embeddedci-0.2.0 → embeddedci-0.2.2}/tests/test_sensor.py +0 -0
- {embeddedci-0.2.0 → embeddedci-0.2.2}/tests/test_serial_json.py +0 -0
- {embeddedci-0.2.0 → embeddedci-0.2.2}/tests/test_tcp_transport.py +0 -0
- {embeddedci-0.2.0 → embeddedci-0.2.2}/tests/test_timed_power.py +0 -0
- {embeddedci-0.2.0 → embeddedci-0.2.2}/tests/test_uart_capture.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: embeddedci
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: BenchPod pytest client for EmbeddedCI hardware-in-the-loop testing
|
|
5
5
|
Project-URL: Homepage, https://embeddedci.com
|
|
6
6
|
Project-URL: Repository, https://github.com/embeddedci-com/embeddedci-python
|
|
@@ -68,10 +68,36 @@ pip install embeddedci
|
|
|
68
68
|
pip install "embeddedci[cloud]"
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
71
|
+
The `[cloud]` extra pulls in a WebSocket client used only by the `embeddedci:` destination;
|
|
72
|
+
local wifi/serial use needs nothing extra.
|
|
73
|
+
|
|
74
|
+
### OpenOCD (required for flashing)
|
|
75
|
+
|
|
76
|
+
Flashing shells out to **OpenOCD**, which must be on your `PATH`. It drives the pod's SWD probe
|
|
77
|
+
through OpenOCD's `remote_bitbang` adapter in **SWD** mode — and **SWD support for `remote_bitbang`
|
|
78
|
+
only exists in OpenOCD *master* (post‑0.12.0)**. The stock packages (`apt install openocd`,
|
|
79
|
+
`brew install open-ocd`) are **0.12.0**, whose `remote_bitbang` is `jtag_only`; flashing with them
|
|
80
|
+
fails immediately:
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
Info : only one transport option; autoselect 'jtag'
|
|
84
|
+
Error: Can't change session's transport after the initial selection was made
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Install a master snapshot instead — the easiest is **xPack OpenOCD**:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
# macOS / Linux via npm (xpm), or grab a release tarball directly:
|
|
91
|
+
npm install -g @xpack-dev-tools/openocd
|
|
92
|
+
# or: https://github.com/xpack-dev-tools/openocd-xpack/releases (extract, add bin/ to PATH)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Verify your OpenOCD can do SWD over `remote_bitbang`:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
openocd -c "adapter driver remote_bitbang" -c "transport list" -c "exit"
|
|
99
|
+
# must list: jtag swd (if only 'jtag', it's too old)
|
|
100
|
+
```
|
|
75
101
|
|
|
76
102
|
## Named constants (no magic numbers)
|
|
77
103
|
|
|
@@ -191,10 +217,10 @@ from embeddedci import benchpod
|
|
|
191
217
|
|
|
192
218
|
with benchpod.BenchPod("192.168.1.213") as bp:
|
|
193
219
|
bp.flash(file="app.elf", target="target/stm32f4x.cfg",
|
|
194
|
-
swclk=benchpod.
|
|
220
|
+
swclk=benchpod.PIN11, swdio=benchpod.PIN12, target_power=benchpod.INTERNAL)
|
|
195
221
|
|
|
196
|
-
# I2C is open-drain — enable the pod's pull-ups on SDA/SCL
|
|
197
|
-
#
|
|
222
|
+
# I2C is open-drain — enable the pod's pull-ups on SDA/SCL. Pull-ups exist
|
|
223
|
+
# only on LA1-8 (LA1/2=4.7k, LA3/4=2.2k, LA5-8=10k), so put SDA/SCL on LA1/2.
|
|
198
224
|
bp.enable_pullup(benchpod.PIN1, benchpod.PIN2)
|
|
199
225
|
bp.enable_i2c_sensor(benchpod.Sensor.BMP280, sda=benchpod.PIN1, scl=benchpod.PIN2,
|
|
200
226
|
temperature_c=22.5, pressure_pa=101000)
|
|
@@ -263,8 +289,10 @@ pytest examples/test_bmp280.py \
|
|
|
263
289
|
|
|
264
290
|
It uses the plugin's `benchpod`, `pins` and `firmware` fixtures and the
|
|
265
291
|
`@pytest.mark.hardware` marker, and **skips automatically** without a connection.
|
|
266
|
-
The
|
|
267
|
-
|
|
292
|
+
The `pins` fixture exposes the pod's 12 generic LA channels (`pins.pin_1` …
|
|
293
|
+
`pins.pin_12`) plus `pins.efuse` — there are no role-named pins, since any DUT
|
|
294
|
+
signal can be on any channel. The example maps its own wiring (which signal is on
|
|
295
|
+
which channel) in a small `wiring` fixture at the top. See
|
|
268
296
|
[`examples/README.md`](examples/README.md) for the full wiring table.
|
|
269
297
|
|
|
270
298
|
A more thorough multi-case version (present/absent + I2C-bus decode) lives in
|
|
@@ -38,10 +38,36 @@ pip install embeddedci
|
|
|
38
38
|
pip install "embeddedci[cloud]"
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
The `[cloud]` extra pulls in a WebSocket client used only by the `embeddedci:` destination;
|
|
42
|
+
local wifi/serial use needs nothing extra.
|
|
43
|
+
|
|
44
|
+
### OpenOCD (required for flashing)
|
|
45
|
+
|
|
46
|
+
Flashing shells out to **OpenOCD**, which must be on your `PATH`. It drives the pod's SWD probe
|
|
47
|
+
through OpenOCD's `remote_bitbang` adapter in **SWD** mode — and **SWD support for `remote_bitbang`
|
|
48
|
+
only exists in OpenOCD *master* (post‑0.12.0)**. The stock packages (`apt install openocd`,
|
|
49
|
+
`brew install open-ocd`) are **0.12.0**, whose `remote_bitbang` is `jtag_only`; flashing with them
|
|
50
|
+
fails immediately:
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
Info : only one transport option; autoselect 'jtag'
|
|
54
|
+
Error: Can't change session's transport after the initial selection was made
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Install a master snapshot instead — the easiest is **xPack OpenOCD**:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# macOS / Linux via npm (xpm), or grab a release tarball directly:
|
|
61
|
+
npm install -g @xpack-dev-tools/openocd
|
|
62
|
+
# or: https://github.com/xpack-dev-tools/openocd-xpack/releases (extract, add bin/ to PATH)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Verify your OpenOCD can do SWD over `remote_bitbang`:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
openocd -c "adapter driver remote_bitbang" -c "transport list" -c "exit"
|
|
69
|
+
# must list: jtag swd (if only 'jtag', it's too old)
|
|
70
|
+
```
|
|
45
71
|
|
|
46
72
|
## Named constants (no magic numbers)
|
|
47
73
|
|
|
@@ -161,10 +187,10 @@ from embeddedci import benchpod
|
|
|
161
187
|
|
|
162
188
|
with benchpod.BenchPod("192.168.1.213") as bp:
|
|
163
189
|
bp.flash(file="app.elf", target="target/stm32f4x.cfg",
|
|
164
|
-
swclk=benchpod.
|
|
190
|
+
swclk=benchpod.PIN11, swdio=benchpod.PIN12, target_power=benchpod.INTERNAL)
|
|
165
191
|
|
|
166
|
-
# I2C is open-drain — enable the pod's pull-ups on SDA/SCL
|
|
167
|
-
#
|
|
192
|
+
# I2C is open-drain — enable the pod's pull-ups on SDA/SCL. Pull-ups exist
|
|
193
|
+
# only on LA1-8 (LA1/2=4.7k, LA3/4=2.2k, LA5-8=10k), so put SDA/SCL on LA1/2.
|
|
168
194
|
bp.enable_pullup(benchpod.PIN1, benchpod.PIN2)
|
|
169
195
|
bp.enable_i2c_sensor(benchpod.Sensor.BMP280, sda=benchpod.PIN1, scl=benchpod.PIN2,
|
|
170
196
|
temperature_c=22.5, pressure_pa=101000)
|
|
@@ -233,8 +259,10 @@ pytest examples/test_bmp280.py \
|
|
|
233
259
|
|
|
234
260
|
It uses the plugin's `benchpod`, `pins` and `firmware` fixtures and the
|
|
235
261
|
`@pytest.mark.hardware` marker, and **skips automatically** without a connection.
|
|
236
|
-
The
|
|
237
|
-
|
|
262
|
+
The `pins` fixture exposes the pod's 12 generic LA channels (`pins.pin_1` …
|
|
263
|
+
`pins.pin_12`) plus `pins.efuse` — there are no role-named pins, since any DUT
|
|
264
|
+
signal can be on any channel. The example maps its own wiring (which signal is on
|
|
265
|
+
which channel) in a small `wiring` fixture at the top. See
|
|
238
266
|
[`examples/README.md`](examples/README.md) for the full wiring table.
|
|
239
267
|
|
|
240
268
|
A more thorough multi-case version (present/absent + I2C-bus decode) lives in
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# Design: event-based UART for pytest (`open_uart`)
|
|
2
|
+
|
|
3
|
+
**Status: design only — not implemented.**
|
|
4
|
+
|
|
5
|
+
## Problem
|
|
6
|
+
|
|
7
|
+
Today UART is *capture-window* based:
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
cap = bp.capture_uart(rx=5, tx=4, baud=115200, duration=6, until="APP_OK")
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`capture()` ([uart.py](../src/embeddedci/benchpod/uart.py)) opens the proxy, then
|
|
14
|
+
**blocks** reading for `duration` (or until a match) and closes. Because the read
|
|
15
|
+
loop owns the calling thread, you cannot do anything else — including powering
|
|
16
|
+
the target — while it runs. To catch a boot banner you must therefore use
|
|
17
|
+
`power_cycle_and_capture`, which schedules a **delayed, pod-side** eFuse power-on
|
|
18
|
+
so it fires *inside* the capture window:
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
# the power-on is delayed so the banner lands in the window
|
|
22
|
+
cap = bp.power_cycle_and_capture(rx=5, tx=4, efuse=1, delay=1.0, duration=6.0, until="APP_OK")
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
That delay is a workaround. We want to **start the eFuse immediately** (no
|
|
26
|
+
pod-side timer) and still see the boot message. The only way is to begin
|
|
27
|
+
*listening before* powering the target, then read at a later point.
|
|
28
|
+
|
|
29
|
+
## Goal
|
|
30
|
+
|
|
31
|
+
An event-based session: open the UART (start buffering in the background),
|
|
32
|
+
power the target with a plain non-delayed `power_on`, then read.
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
with bp.open_uart(rx=5, tx=4, baud=115200) as uart:
|
|
36
|
+
bp.power_on(bp.INTERNAL) # immediate — no pod-side delay
|
|
37
|
+
assert uart.read_until("APP_OK", timeout=6)
|
|
38
|
+
# ... later, a second read from the same stream:
|
|
39
|
+
uart.write(b"status\r\n")
|
|
40
|
+
print(uart.read_until("rx_byte_count", timeout=2))
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Because the background reader is already draining the proxy when `power_on`
|
|
44
|
+
fires, the banner emitted microseconds after power-up is buffered and the first
|
|
45
|
+
`read_until` finds it.
|
|
46
|
+
|
|
47
|
+
## Proposed API
|
|
48
|
+
|
|
49
|
+
A new `UartSession` returned by `BenchPod.open_uart(...)`:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
class UartSession:
|
|
53
|
+
# --- reading ---
|
|
54
|
+
def read_until(self, pattern: Until, *, timeout: float) -> Optional[re.Match | str]:
|
|
55
|
+
"""Block until `pattern` (substring/regex/predicate) appears in the
|
|
56
|
+
accumulated text or `timeout` elapses. Returns the match (truthy) or
|
|
57
|
+
None on timeout. Does NOT consume — `text` keeps growing."""
|
|
58
|
+
|
|
59
|
+
def read(self, *, timeout: float = 0.0) -> str:
|
|
60
|
+
"""Return all text received so far. timeout>0 waits up to that long for
|
|
61
|
+
at least one new byte; timeout=0 is non-blocking."""
|
|
62
|
+
|
|
63
|
+
def expect(self, pattern: Until, *, timeout: float) -> re.Match | str:
|
|
64
|
+
"""Like read_until but raises UartTimeout instead of returning None."""
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def text(self) -> str: ... # everything decoded so far
|
|
68
|
+
@property
|
|
69
|
+
def lines(self) -> list[str]: ... # convenience split
|
|
70
|
+
|
|
71
|
+
def drain(self) -> str:
|
|
72
|
+
"""Return + clear the buffer (so the next read_until only sees new data)."""
|
|
73
|
+
|
|
74
|
+
# --- writing (proxy is bidirectional) ---
|
|
75
|
+
def write(self, data: bytes | str) -> None: ...
|
|
76
|
+
|
|
77
|
+
# --- lifecycle ---
|
|
78
|
+
def close(self) -> None: ... # stop reader, leave the proxy
|
|
79
|
+
def __enter__(self) -> "UartSession": ...
|
|
80
|
+
def __exit__(self, *exc) -> None: ... # calls close()
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`BenchPod`:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
def open_uart(self, *, rx, tx, baud=115200, max_buffer=1<<20) -> UartSession: ...
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`UartTimeout(BenchPodError)` is raised by `expect`.
|
|
90
|
+
|
|
91
|
+
## Internals
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
open_uart()
|
|
95
|
+
link = transport.uart_proxy_start(rx, tx, baud) # existing RawLink, raw 8N1
|
|
96
|
+
start a daemon thread: loop link.read(chunk) -> append under lock -> notify
|
|
97
|
+
return UartSession(link, thread, buffer, condition)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
- **Buffer**: a `bytearray` guarded by a `threading.Lock`; a `threading.Condition`
|
|
101
|
+
signals readers when new bytes arrive or the link closes.
|
|
102
|
+
- **Reader thread**: `while not stopped: data = link.read(chunk); if not data: mark
|
|
103
|
+
closed + notify; break; with lock: buffer += data; notify_all()`.
|
|
104
|
+
`link.read` already blocks until data/EOF, so the thread parks when the DUT is
|
|
105
|
+
quiet — no busy-poll.
|
|
106
|
+
- **`read_until`**: under the condition, re-evaluate the predicate against
|
|
107
|
+
`buffer.decode("utf-8", errors="replace")` (decode the whole buffer each time, as
|
|
108
|
+
`capture()` does, so multi-byte chars split across chunks resolve); `wait(timeout)`
|
|
109
|
+
until it matches, the deadline passes, or the link closes.
|
|
110
|
+
- **`close()`**: set `stopped`, `link.close()` (which unblocks the reader's
|
|
111
|
+
in-flight `read` and tells the firmware to leave PROTO_UART), `thread.join()`.
|
|
112
|
+
- **Bounded buffer**: cap at `max_buffer`; on overflow drop oldest and set an
|
|
113
|
+
`overflowed` flag (surfaced on read) so a chatty DUT can't OOM a long session.
|
|
114
|
+
- **Decode position**: keep raw bytes; decode lazily in `text`/`read_until`. Track a
|
|
115
|
+
`consumed` offset for `drain()`.
|
|
116
|
+
|
|
117
|
+
`capture_uart` can be re-expressed on top of this (open → `read_until` or sleep
|
|
118
|
+
`duration` → snapshot `text`/`lines` → close), keeping the existing one-shot API
|
|
119
|
+
backward-compatible.
|
|
120
|
+
|
|
121
|
+
## The key concurrency question: powering while listening
|
|
122
|
+
|
|
123
|
+
`open_uart` holds the proxy connection for the whole session. `power_on` must run
|
|
124
|
+
**concurrently** on a *different* path. This differs by transport:
|
|
125
|
+
|
|
126
|
+
- **TCP (`TcpTransport`)** — every command dials a *fresh* socket
|
|
127
|
+
([tcp.py](../src/embeddedci/benchpod/transport/tcp.py) `_dial`). The proxy holds
|
|
128
|
+
socket A; `power_on` opens socket B. The firmware services multiple AT
|
|
129
|
+
connections, and the soft-UART (FPGA) and the eFuse (target-power GPIO) are
|
|
130
|
+
independent subsystems — so they run concurrently. **Works as-is.**
|
|
131
|
+
|
|
132
|
+
- **Cloud (`CloudTransport`)** — **implemented.** `CloudTransport.command()` now
|
|
133
|
+
routes non-streaming commands over the cloud **command channel**
|
|
134
|
+
(`POST /api/cloud/devices/command`, which the server turns into a
|
|
135
|
+
`command.request`/`command.response`) instead of dialing a second byte tunnel.
|
|
136
|
+
The firmware services the command channel (`CH_CLOUD_CONN`) and the byte tunnel
|
|
137
|
+
(`CH_CLOUD_TUNNEL_CONN`) as independent connections and announces
|
|
138
|
+
`"command":true` in its capabilities, so `power_on`/`power_off`/`gpio_set`/etc.
|
|
139
|
+
run *while* `uart_proxy_start` holds the tunnel. Streaming modes
|
|
140
|
+
(`dap_start`/`uart_proxy_start`) still use the tunnel via `_dial`. This both
|
|
141
|
+
removes the two-concurrent-tunnels requirement and makes every command faster
|
|
142
|
+
(one HTTP round-trip vs a fresh WebSocket dial).
|
|
143
|
+
|
|
144
|
+
- **Thread-safety of the link** — the reader thread calls `link.read` while the
|
|
145
|
+
main thread may call `link.write`/`close`. Sockets allow concurrent
|
|
146
|
+
read/write in opposite directions; the cloud WS socket is created with
|
|
147
|
+
`enable_multithread=True` already, so this holds. `close()` from the main thread
|
|
148
|
+
unblocking a `read()` in the reader thread is the same pattern `capture()`'s
|
|
149
|
+
timer already relies on.
|
|
150
|
+
|
|
151
|
+
## Edge cases to cover in implementation
|
|
152
|
+
|
|
153
|
+
- **close() races**: idempotent close; reader thread exits on EOF or `stopped`.
|
|
154
|
+
- **timeout returns vs raises**: `read_until` returns `None`; `expect` raises
|
|
155
|
+
`UartTimeout` carrying the buffered text (mirrors how `flash` surfaces context).
|
|
156
|
+
- **partial UTF-8 / binary**: decode the whole buffer with `errors="replace"`;
|
|
157
|
+
never split a decode across a chunk boundary.
|
|
158
|
+
- **predicate cost**: re-decoding the whole buffer per notify is O(n) per wakeup;
|
|
159
|
+
for very large/long sessions, decode incrementally or cap the scan window.
|
|
160
|
+
- **proxy escape**: `close()` relies on `RawLink.close()` to leave the proxy
|
|
161
|
+
(TCP: socket close; serial: the proxy escape). No in-band sentinel needed.
|
|
162
|
+
- **teardown ordering**: `__exit__` closes the UART session; target power is the
|
|
163
|
+
caller's responsibility (or a `power_off` in a fixture finalizer), unchanged.
|
|
164
|
+
|
|
165
|
+
## Worked example (the motivating case)
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
def test_boot_banner(benchpod):
|
|
169
|
+
bp = benchpod
|
|
170
|
+
with bp.open_uart(rx=5, tx=4, baud=115200) as uart: # listening starts now
|
|
171
|
+
bp.power_on(bp.INTERNAL) # immediate eFuse start
|
|
172
|
+
m = uart.read_until(r"APP_OK", timeout=6)
|
|
173
|
+
assert m, f"no boot banner; got:\n{uart.text}"
|
|
174
|
+
|
|
175
|
+
# a second, independent read on the same live stream
|
|
176
|
+
uart.write(b"ping\r\n")
|
|
177
|
+
assert uart.expect("pong", timeout=2)
|
|
178
|
+
bp.power_off(bp.INTERNAL)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
No `delay=`, no `power_cycle_and_capture` — the banner is caught because listening
|
|
182
|
+
began before power-up.
|
|
@@ -27,19 +27,25 @@ Without `--benchpod-connection` the test **skips** (so it's safe in CI). The
|
|
|
27
27
|
|
|
28
28
|
### Wiring
|
|
29
29
|
|
|
30
|
-
The
|
|
30
|
+
The pod has **no dedicated SWD/UART/I2C pins** — it exposes 12 generic LA channels
|
|
31
|
+
(`pins.pin_1` … `pins.pin_12`) and any DUT signal can be on any of them. The example
|
|
32
|
+
maps its own wiring in a `wiring` fixture at the top of `test_bmp280.py`; the table
|
|
33
|
+
below is what that fixture uses — edit it to match your board.
|
|
31
34
|
|
|
32
|
-
| DUT pin | Pod LA channel |
|
|
35
|
+
| DUT pin | Pod LA channel | `wiring` field |
|
|
33
36
|
|---|---|---|
|
|
34
|
-
| SWCLK | LA11 |
|
|
35
|
-
| SWDIO | LA12 |
|
|
36
|
-
| NRST | LA3 |
|
|
37
|
-
| UART TX (DUT→pod samples) | LA5 |
|
|
38
|
-
| UART RX (pod→DUT drives) | LA4 |
|
|
39
|
-
| I2C SDA (
|
|
40
|
-
| I2C SCL (
|
|
37
|
+
| SWCLK | LA11 | `wiring.swclk` |
|
|
38
|
+
| SWDIO | LA12 | `wiring.swdio` |
|
|
39
|
+
| NRST | LA3 | `wiring.nreset` |
|
|
40
|
+
| UART TX (DUT→pod samples) | LA5 | `wiring.uart_rx` |
|
|
41
|
+
| UART RX (pod→DUT drives) | LA4 | `wiring.uart_tx` |
|
|
42
|
+
| I2C SDA (needs a pull-up) | LA2 | `wiring.i2c_sda` |
|
|
43
|
+
| I2C SCL (needs a pull-up) | LA1 | `wiring.i2c_scl` |
|
|
41
44
|
| Target 5V eFuse (1=int, 2=ext) | — | `--benchpod-efuse` |
|
|
42
45
|
|
|
46
|
+
Pull-ups exist only on **LA1–8** (LA1/2 = 4.7k, LA3/4 = 2.2k, LA5–8 = 10k); LA9–12
|
|
47
|
+
have none, so the open-drain I2C lines must sit on a pull-up-capable channel.
|
|
48
|
+
|
|
43
49
|
### Notes
|
|
44
50
|
|
|
45
51
|
- **Serial vs wifi.** Over a serial console, SWD flashing is bit-banged and
|
|
@@ -12,23 +12,27 @@
|
|
|
12
12
|
# --benchpod-connection=/dev/tty.usbserial-0001 \ # or 192.168.1.50 for wifi
|
|
13
13
|
# --benchpod-firmware=path/to/your_app.elf
|
|
14
14
|
#
|
|
15
|
-
# Wiring
|
|
15
|
+
# Wiring — the pod has no dedicated SWD/UART/I2C pins. It exposes 12 generic LA
|
|
16
|
+
# channels (pins.pin_1 .. pins.pin_12) and any DUT signal can be on any of them.
|
|
17
|
+
# The `wiring` fixture below is THIS bench's map; edit it to match your board:
|
|
16
18
|
#
|
|
17
|
-
# DUT pin Pod LA channel
|
|
18
|
-
# ------------------------------ --------------
|
|
19
|
-
# SWCLK LA11
|
|
20
|
-
# SWDIO LA12
|
|
21
|
-
# NRST LA3
|
|
22
|
-
# UART TX (DUT -> pod samples) LA5
|
|
23
|
-
# UART RX (pod -> DUT drives) LA4
|
|
24
|
-
# I2C SDA (
|
|
25
|
-
# I2C SCL (
|
|
19
|
+
# DUT pin Pod LA channel wiring fixture
|
|
20
|
+
# ------------------------------ -------------- --------------
|
|
21
|
+
# SWCLK LA11 wiring.swclk
|
|
22
|
+
# SWDIO LA12 wiring.swdio
|
|
23
|
+
# NRST LA3 wiring.nreset
|
|
24
|
+
# UART TX (DUT -> pod samples) LA5 wiring.uart_rx
|
|
25
|
+
# UART RX (pod -> DUT drives) LA4 wiring.uart_tx
|
|
26
|
+
# I2C SDA (needs a pull-up) LA2 (4.7k) wiring.i2c_sda
|
|
27
|
+
# I2C SCL (needs a pull-up) LA1 (4.7k) wiring.i2c_scl
|
|
26
28
|
# 5V power eFuse (1=int, 2=ext) --benchpod-efuse
|
|
27
29
|
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
+
# Pull-ups exist only on LA1-8 (LA1/2=4.7k, LA3/4=2.2k, LA5-8=10k), so the I2C
|
|
31
|
+
# lines must sit on one of those. The `benchpod`, `pins` and `firmware` fixtures
|
|
32
|
+
# come from the installed plugin; the test skips if no --benchpod-connection is set.
|
|
30
33
|
|
|
31
34
|
import re
|
|
35
|
+
from types import SimpleNamespace
|
|
32
36
|
|
|
33
37
|
import pytest
|
|
34
38
|
from embeddedci import benchpod as bp
|
|
@@ -37,8 +41,20 @@ APP_OK = re.compile(r"APP_OK")
|
|
|
37
41
|
PRESENT = re.compile(r"chip id match=0x58|bmp280_detected=yes")
|
|
38
42
|
|
|
39
43
|
|
|
44
|
+
@pytest.fixture
|
|
45
|
+
def wiring(pins):
|
|
46
|
+
"""This bench's wiring: DUT signal → BenchPod LA channel. Bench-specific —
|
|
47
|
+
any signal can be on any LA channel, except I2C SDA/SCL need a pull-up (LA1-8)."""
|
|
48
|
+
return SimpleNamespace(
|
|
49
|
+
swclk=pins.pin_11, swdio=pins.pin_12, nreset=pins.pin_3,
|
|
50
|
+
uart_rx=pins.pin_5, uart_tx=pins.pin_4,
|
|
51
|
+
i2c_sda=pins.pin_2, i2c_scl=pins.pin_1, # LA1/2 — 4.7k pull-ups
|
|
52
|
+
efuse=pins.efuse,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
40
56
|
@pytest.mark.hardware
|
|
41
|
-
def test_bmp280_sensor_present(benchpod,
|
|
57
|
+
def test_bmp280_sensor_present(benchpod, wiring, firmware):
|
|
42
58
|
"""Flash the DUT, emulate a BMP280 on I2C, power-cycle, assert on UART."""
|
|
43
59
|
|
|
44
60
|
try:
|
|
@@ -46,18 +62,18 @@ def test_bmp280_sensor_present(benchpod, pins, firmware):
|
|
|
46
62
|
result = benchpod.flash(
|
|
47
63
|
file=firmware,
|
|
48
64
|
target="target/stm32f4x.cfg",
|
|
49
|
-
swclk=
|
|
50
|
-
nreset=
|
|
65
|
+
swclk=wiring.swclk, swdio=wiring.swdio,
|
|
66
|
+
nreset=wiring.nreset, target_power=wiring.efuse,
|
|
51
67
|
verify=False, # bit-bang-over-serial: program reliably, skip the slow verify
|
|
52
68
|
)
|
|
53
69
|
assert result.ok
|
|
54
70
|
|
|
55
71
|
# 2. Have the pod emulate a BMP280 on the DUT's I2C bus.
|
|
56
72
|
# I2C is open-drain, so enable the pod's pull-ups to idle the bus high.
|
|
57
|
-
benchpod.enable_pullup(
|
|
73
|
+
benchpod.enable_pullup(wiring.i2c_sda, wiring.i2c_scl)
|
|
58
74
|
benchpod.enable_i2c_sensor(
|
|
59
75
|
bp.Sensor.BMP280,
|
|
60
|
-
sda=
|
|
76
|
+
sda=wiring.i2c_sda, scl=wiring.i2c_scl,
|
|
61
77
|
address=bp.BMP280_ADDR_PRIMARY,
|
|
62
78
|
temperature_c=22.5, pressure_pa=101000,
|
|
63
79
|
)
|
|
@@ -67,7 +83,7 @@ def test_bmp280_sensor_present(benchpod, pins, firmware):
|
|
|
67
83
|
# we don't miss the boot banner. Stop at APP_OK, which the app prints last
|
|
68
84
|
# (after the BMP280 probe), so the capture contains both markers.
|
|
69
85
|
cap = benchpod.power_cycle_and_capture(
|
|
70
|
-
rx=
|
|
86
|
+
rx=wiring.uart_rx, tx=wiring.uart_tx, efuse=wiring.efuse,
|
|
71
87
|
delay=1.5, duration=8.0, until=APP_OK,
|
|
72
88
|
)
|
|
73
89
|
|
|
@@ -78,4 +94,4 @@ def test_bmp280_sensor_present(benchpod, pins, firmware):
|
|
|
78
94
|
finally:
|
|
79
95
|
# Always leave the DUT powered down — cut the eFuse whether the test
|
|
80
96
|
# passed or failed so the bench is left in a safe, known state.
|
|
81
|
-
benchpod.power_off(
|
|
97
|
+
benchpod.power_off(wiring.efuse)
|
|
@@ -49,14 +49,17 @@ from .errors import (
|
|
|
49
49
|
FlashError,
|
|
50
50
|
TargetUnreachableError,
|
|
51
51
|
TransportError,
|
|
52
|
+
UartTimeout,
|
|
52
53
|
)
|
|
53
54
|
from .flash import FlashResult
|
|
54
|
-
from .uart import UartCapture
|
|
55
|
+
from .uart import UartCapture, UartSession
|
|
55
56
|
|
|
56
57
|
__all__ = [
|
|
57
58
|
"BenchPod",
|
|
58
59
|
"FlashResult",
|
|
59
60
|
"UartCapture",
|
|
61
|
+
"UartSession",
|
|
62
|
+
"UartTimeout",
|
|
60
63
|
"i2c",
|
|
61
64
|
"I2CByte",
|
|
62
65
|
"I2CMessage",
|
|
@@ -301,6 +301,36 @@ class BenchPod:
|
|
|
301
301
|
return self.capture_uart(rx=rx, tx=tx, baud=baud, duration=duration,
|
|
302
302
|
until=until)
|
|
303
303
|
|
|
304
|
+
def open_uart(
|
|
305
|
+
self,
|
|
306
|
+
*,
|
|
307
|
+
rx: Union[Pin, int],
|
|
308
|
+
tx: Union[Pin, int],
|
|
309
|
+
baud: int = 115200,
|
|
310
|
+
max_buffer: int = 1 << 20,
|
|
311
|
+
) -> "_uart.UartSession":
|
|
312
|
+
"""Open an event-based UART session (background reader) on the DUT.
|
|
313
|
+
|
|
314
|
+
Unlike :meth:`capture_uart` (a fixed window) this starts buffering
|
|
315
|
+
immediately and lets you read at a later point, so you can begin
|
|
316
|
+
listening *before* an action and still catch its output — e.g. a
|
|
317
|
+
**non-delayed** eFuse power-on whose boot banner you still want to see::
|
|
318
|
+
|
|
319
|
+
with bp.open_uart(rx=5, tx=4, baud=115200) as uart:
|
|
320
|
+
bp.power_on(bp.INTERNAL) # immediate, no pod-side delay
|
|
321
|
+
uart.expect("APP_OK", timeout=6) # banner buffered since power-up
|
|
322
|
+
|
|
323
|
+
``rx`` is the LA channel the pod samples (wire the DUT's TX here); ``tx``
|
|
324
|
+
is driven (DUT's RX). Use as a context manager (or call ``.close()``).
|
|
325
|
+
Over the cloud transport, running other commands (``power_on``, ...) while
|
|
326
|
+
a session is open needs them to use the cloud command channel rather than
|
|
327
|
+
a second tunnel — see ``docs/event-uart-design.md``.
|
|
328
|
+
"""
|
|
329
|
+
link = self._transport.uart_proxy_start(
|
|
330
|
+
coerce_pin(rx, "rx"), coerce_pin(tx, "tx"), int(baud)
|
|
331
|
+
)
|
|
332
|
+
return _uart.UartSession(link, max_buffer=max_buffer)
|
|
333
|
+
|
|
304
334
|
# -- low-level pass-throughs (TCP transport only) -----------------------
|
|
305
335
|
|
|
306
336
|
def command(self, req: dict) -> Any:
|
|
@@ -19,8 +19,20 @@ import urllib.request
|
|
|
19
19
|
|
|
20
20
|
from .errors import CloudAuthError
|
|
21
21
|
|
|
22
|
+
# The OIDC audience is a logical identifier the server matches against the token's `aud` claim; it
|
|
23
|
+
# does not have to be a reachable URL. The API base is the actual canonical host (note: the apex
|
|
24
|
+
# embeddedci.com 301-redirects to www, which urllib would mishandle on a POST).
|
|
22
25
|
DEFAULT_AUDIENCE = "https://embeddedci.com"
|
|
23
|
-
DEFAULT_API_BASE = "https://embeddedci.com"
|
|
26
|
+
DEFAULT_API_BASE = "https://www.embeddedci.com"
|
|
27
|
+
|
|
28
|
+
# A real User-Agent is required: the apex/edge (Cloudflare) bans the default "Python-urllib/x.y"
|
|
29
|
+
# signature (HTTP 403, error 1010). Identify the SDK explicitly instead.
|
|
30
|
+
try: # pragma: no cover - trivial
|
|
31
|
+
from importlib.metadata import version as _pkg_version
|
|
32
|
+
|
|
33
|
+
USER_AGENT = f"embeddedci-python/{_pkg_version('embeddedci')}"
|
|
34
|
+
except Exception: # pragma: no cover
|
|
35
|
+
USER_AGENT = "embeddedci-python"
|
|
24
36
|
|
|
25
37
|
_HTTP_TIMEOUT = 15.0
|
|
26
38
|
|
|
@@ -52,7 +64,12 @@ def mint_oidc_token(audience: str = DEFAULT_AUDIENCE) -> str:
|
|
|
52
64
|
sep = "&" if "?" in req_url else "?"
|
|
53
65
|
url = f"{req_url}{sep}audience={urllib.parse.quote(audience, safe='')}"
|
|
54
66
|
request = urllib.request.Request(
|
|
55
|
-
url,
|
|
67
|
+
url,
|
|
68
|
+
headers={
|
|
69
|
+
"Authorization": f"Bearer {req_token}",
|
|
70
|
+
"Accept": "application/json",
|
|
71
|
+
"User-Agent": USER_AGENT,
|
|
72
|
+
},
|
|
56
73
|
)
|
|
57
74
|
try:
|
|
58
75
|
with urllib.request.urlopen(request, timeout=_HTTP_TIMEOUT) as resp:
|
|
@@ -75,7 +92,11 @@ def exchange_token(api_base: str, oidc_token: str) -> dict:
|
|
|
75
92
|
data = json.dumps({"token": oidc_token}).encode("utf-8")
|
|
76
93
|
request = urllib.request.Request(
|
|
77
94
|
url, data=data, method="POST",
|
|
78
|
-
headers={
|
|
95
|
+
headers={
|
|
96
|
+
"Content-Type": "application/json",
|
|
97
|
+
"Accept": "application/json",
|
|
98
|
+
"User-Agent": USER_AGENT,
|
|
99
|
+
},
|
|
79
100
|
)
|
|
80
101
|
try:
|
|
81
102
|
with urllib.request.urlopen(request, timeout=_HTTP_TIMEOUT) as resp:
|
|
@@ -47,3 +47,15 @@ class TargetUnreachableError(FlashError):
|
|
|
47
47
|
|
|
48
48
|
Almost always means the target is unpowered, mis-wired, or held in reset.
|
|
49
49
|
"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class UartTimeout(BenchPodError):
|
|
53
|
+
"""An event-based UART read (``UartSession.expect``) timed out.
|
|
54
|
+
|
|
55
|
+
Carries the text accumulated so far in :attr:`text` so the caller can see
|
|
56
|
+
what the DUT actually sent.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, message: str, *, text: str = "") -> None:
|
|
60
|
+
self.text = text
|
|
61
|
+
super().__init__(message)
|