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.
Files changed (42) hide show
  1. {embeddedci-0.2.0 → embeddedci-0.2.2}/PKG-INFO +38 -10
  2. {embeddedci-0.2.0 → embeddedci-0.2.2}/README.md +37 -9
  3. embeddedci-0.2.2/docs/event-uart-design.md +182 -0
  4. {embeddedci-0.2.0 → embeddedci-0.2.2}/examples/README.md +15 -9
  5. {embeddedci-0.2.0 → embeddedci-0.2.2}/examples/test_bmp280.py +35 -19
  6. {embeddedci-0.2.0 → embeddedci-0.2.2}/pyproject.toml +1 -1
  7. {embeddedci-0.2.0 → embeddedci-0.2.2}/src/embeddedci/benchpod/__init__.py +4 -1
  8. {embeddedci-0.2.0 → embeddedci-0.2.2}/src/embeddedci/benchpod/client.py +30 -0
  9. {embeddedci-0.2.0 → embeddedci-0.2.2}/src/embeddedci/benchpod/cloud_auth.py +24 -3
  10. {embeddedci-0.2.0 → embeddedci-0.2.2}/src/embeddedci/benchpod/errors.py +12 -0
  11. {embeddedci-0.2.0 → embeddedci-0.2.2}/src/embeddedci/benchpod/flash.py +136 -116
  12. {embeddedci-0.2.0 → embeddedci-0.2.2}/src/embeddedci/benchpod/pytest_plugin.py +52 -43
  13. {embeddedci-0.2.0 → embeddedci-0.2.2}/src/embeddedci/benchpod/transport/base.py +9 -0
  14. {embeddedci-0.2.0 → embeddedci-0.2.2}/src/embeddedci/benchpod/transport/cloud.py +49 -3
  15. {embeddedci-0.2.0 → embeddedci-0.2.2}/src/embeddedci/benchpod/transport/serial.py +8 -0
  16. {embeddedci-0.2.0 → embeddedci-0.2.2}/src/embeddedci/benchpod/transport/tcp.py +6 -0
  17. embeddedci-0.2.2/src/embeddedci/benchpod/uart.py +257 -0
  18. {embeddedci-0.2.0 → embeddedci-0.2.2}/tests/examples/test_bmp280_hil.py +58 -38
  19. {embeddedci-0.2.0 → embeddedci-0.2.2}/tests/test_cloud.py +53 -6
  20. {embeddedci-0.2.0 → embeddedci-0.2.2}/tests/test_flash_bridge.py +11 -5
  21. {embeddedci-0.2.0 → embeddedci-0.2.2}/tests/test_integration.py +8 -8
  22. embeddedci-0.2.2/tests/test_uart_session.py +114 -0
  23. embeddedci-0.2.0/src/embeddedci/benchpod/uart.py +0 -90
  24. {embeddedci-0.2.0 → embeddedci-0.2.2}/.gitignore +0 -0
  25. {embeddedci-0.2.0 → embeddedci-0.2.2}/src/embeddedci/__init__.py +0 -0
  26. {embeddedci-0.2.0 → embeddedci-0.2.2}/src/embeddedci/benchpod/connection.py +0 -0
  27. {embeddedci-0.2.0 → embeddedci-0.2.2}/src/embeddedci/benchpod/constants.py +0 -0
  28. {embeddedci-0.2.0 → embeddedci-0.2.2}/src/embeddedci/benchpod/i2c.py +0 -0
  29. {embeddedci-0.2.0 → embeddedci-0.2.2}/src/embeddedci/benchpod/protocol.py +0 -0
  30. {embeddedci-0.2.0 → embeddedci-0.2.2}/src/embeddedci/benchpod/sensor.py +0 -0
  31. {embeddedci-0.2.0 → embeddedci-0.2.2}/src/embeddedci/benchpod/transport/__init__.py +0 -0
  32. {embeddedci-0.2.0 → embeddedci-0.2.2}/src/embeddedci/py.typed +0 -0
  33. {embeddedci-0.2.0 → embeddedci-0.2.2}/tests/conftest.py +0 -0
  34. {embeddedci-0.2.0 → embeddedci-0.2.2}/tests/examples/test_smoke.py +0 -0
  35. {embeddedci-0.2.0 → embeddedci-0.2.2}/tests/test_connection.py +0 -0
  36. {embeddedci-0.2.0 → embeddedci-0.2.2}/tests/test_i2c_decode.py +0 -0
  37. {embeddedci-0.2.0 → embeddedci-0.2.2}/tests/test_protocol.py +0 -0
  38. {embeddedci-0.2.0 → embeddedci-0.2.2}/tests/test_sensor.py +0 -0
  39. {embeddedci-0.2.0 → embeddedci-0.2.2}/tests/test_serial_json.py +0 -0
  40. {embeddedci-0.2.0 → embeddedci-0.2.2}/tests/test_tcp_transport.py +0 -0
  41. {embeddedci-0.2.0 → embeddedci-0.2.2}/tests/test_timed_power.py +0 -0
  42. {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.0
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
- Flashing shells out to **OpenOCD**, which must be on your `PATH`
72
- (`brew install open-ocd` / `apt install openocd`). The `[cloud]` extra pulls in a
73
- WebSocket client used only by the `embeddedci:` destination; local wifi/serial use
74
- needs nothing extra.
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.PIN13, swdio=benchpod.PIN14, target_power=benchpod.INTERNAL)
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 (LA1-8 only),
197
- # then have the pod become a BMP280 on those lines.
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 pin map comes from `--benchpod-swclk/-swdio/-nreset/-uart-rx/-uart-tx/-i2c-sda/-i2c-scl/-efuse`
267
- options (sensible defaults) so tests aren't hardcoded to your wiring. See
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
- Flashing shells out to **OpenOCD**, which must be on your `PATH`
42
- (`brew install open-ocd` / `apt install openocd`). The `[cloud]` extra pulls in a
43
- WebSocket client used only by the `embeddedci:` destination; local wifi/serial use
44
- needs nothing extra.
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.PIN13, swdio=benchpod.PIN14, target_power=benchpod.INTERNAL)
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 (LA1-8 only),
167
- # then have the pod become a BMP280 on those lines.
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 pin map comes from `--benchpod-swclk/-swdio/-nreset/-uart-rx/-uart-tx/-i2c-sda/-i2c-scl/-efuse`
237
- options (sensible defaults) so tests aren't hardcoded to your wiring. See
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 defaults match a typical bench; override any pin with `--benchpod-<name>`:
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 | Flag |
35
+ | DUT pin | Pod LA channel | `wiring` field |
33
36
  |---|---|---|
34
- | SWCLK | LA11 | `--benchpod-swclk` |
35
- | SWDIO | LA12 | `--benchpod-swdio` |
36
- | NRST | LA3 | `--benchpod-nreset` |
37
- | UART TX (DUT→pod samples) | LA5 | `--benchpod-uart-rx` |
38
- | UART RX (pod→DUT drives) | LA4 | `--benchpod-uart-tx` |
39
- | I2C SDA (LA1–8 have pull-ups) | LA2 | `--benchpod-i2c-sda` |
40
- | I2C SCL (LA1–8 have pull-ups) | LA1 | `--benchpod-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 (override any with --benchpod-<name>=<LA-channel>):
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 flag
18
- # ------------------------------ -------------- --------------------
19
- # SWCLK LA11 --benchpod-swclk
20
- # SWDIO LA12 --benchpod-swdio
21
- # NRST LA3 --benchpod-nreset
22
- # UART TX (DUT -> pod samples) LA5 --benchpod-uart-rx
23
- # UART RX (pod -> DUT drives) LA4 --benchpod-uart-tx
24
- # I2C SDA (has a pull-up) LA2 --benchpod-i2c-sda
25
- # I2C SCL (has a pull-up) LA1 --benchpod-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
- # The `benchpod`, `pins` and `firmware` fixtures come from the installed plugin;
29
- # the whole test skips automatically if no --benchpod-connection is given.
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, pins, firmware):
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=pins.swclk, swdio=pins.swdio,
50
- nreset=pins.nreset, target_power=pins.efuse,
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(pins.i2c_sda, pins.i2c_scl)
73
+ benchpod.enable_pullup(wiring.i2c_sda, wiring.i2c_scl)
58
74
  benchpod.enable_i2c_sensor(
59
75
  bp.Sensor.BMP280,
60
- sda=pins.i2c_sda, scl=pins.i2c_scl,
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=pins.uart_rx, tx=pins.uart_tx, efuse=pins.efuse,
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(pins.efuse)
97
+ benchpod.power_off(wiring.efuse)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "embeddedci"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "BenchPod pytest client for EmbeddedCI hardware-in-the-loop testing"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -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, headers={"Authorization": f"Bearer {req_token}", "Accept": "application/json"}
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={"Content-Type": "application/json", "Accept": "application/json"},
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)