embeddedci 2.1.0__tar.gz → 2.2.0__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 (104) hide show
  1. {embeddedci-2.1.0 → embeddedci-2.2.0}/CHANGELOG.md +24 -0
  2. {embeddedci-2.1.0 → embeddedci-2.2.0}/PKG-INFO +14 -7
  3. {embeddedci-2.1.0 → embeddedci-2.2.0}/README.md +13 -6
  4. {embeddedci-2.1.0 → embeddedci-2.2.0}/examples/README.md +2 -2
  5. {embeddedci-2.1.0 → embeddedci-2.2.0}/examples/test_bmp280.py +2 -2
  6. {embeddedci-2.1.0 → embeddedci-2.2.0}/pyproject.toml +1 -1
  7. {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/client.py +17 -7
  8. {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/cloud_auth.py +65 -22
  9. {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/control_loop.py +3 -0
  10. {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/flash.py +13 -3
  11. {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/gpio.py +2 -2
  12. {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/i2c.py +1 -1
  13. {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/lease.py +31 -9
  14. {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/pytest_plugin.py +4 -4
  15. {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/results.py +1 -1
  16. {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/transport/__init__.py +5 -1
  17. {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/transport/base.py +8 -1
  18. {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/transport/cloud.py +90 -17
  19. {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/transport/serial.py +3 -1
  20. {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/transport/tcp.py +8 -1
  21. {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/wiring.py +2 -2
  22. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/api_surface.json +4 -3
  23. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/e2e/README.md +1 -0
  24. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/e2e/conftest.py +4 -0
  25. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/e2e/test_e2e_cloud.py +16 -1
  26. embeddedci-2.2.0/tests/e2e/test_e2e_la13_14.py +130 -0
  27. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/e2e/test_e2e_pod.py +97 -0
  28. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/e2e/test_e2e_rev3.py +6 -0
  29. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/examples/test_bmp280_hil.py +2 -2
  30. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_cloud.py +129 -0
  31. embeddedci-2.2.0/tests/test_lease.py +144 -0
  32. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_timing.py +1 -1
  33. embeddedci-2.1.0/tests/test_lease.py +0 -71
  34. {embeddedci-2.1.0 → embeddedci-2.2.0}/.gitignore +0 -0
  35. {embeddedci-2.1.0 → embeddedci-2.2.0}/LICENSE +0 -0
  36. {embeddedci-2.1.0 → embeddedci-2.2.0}/docs/event-uart-design.md +0 -0
  37. {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/__init__.py +0 -0
  38. {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/__init__.py +0 -0
  39. {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/can.py +0 -0
  40. {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/capabilities.py +0 -0
  41. {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/capture.py +0 -0
  42. {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/ci.py +0 -0
  43. {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/connection.py +0 -0
  44. {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/constants.py +0 -0
  45. {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/decode.py +0 -0
  46. {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/discovery.py +0 -0
  47. {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/dsp.py +0 -0
  48. {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/errors.py +0 -0
  49. {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/lowlevel.py +0 -0
  50. {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/power.py +0 -0
  51. {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/protocol.py +0 -0
  52. {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/replay.py +0 -0
  53. {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/sensor.py +0 -0
  54. {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/server_api.py +0 -0
  55. {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/state.py +0 -0
  56. {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/uart.py +0 -0
  57. {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/waveforms.py +0 -0
  58. {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/py.typed +0 -0
  59. {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/upload_build.py +0 -0
  60. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/conftest.py +0 -0
  61. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/e2e/e2e_helpers.py +0 -0
  62. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/e2e/test_e2e_dut.py +0 -0
  63. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/e2e/test_e2e_gpio.py +0 -0
  64. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/e2e/test_e2e_usb.py +0 -0
  65. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/examples/test_can_ecu_demo.py +0 -0
  66. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/examples/test_can_loopback_hil.py +0 -0
  67. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/examples/test_smoke.py +0 -0
  68. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_adc_fft_hw.py +0 -0
  69. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_api_surface.py +0 -0
  70. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_can.py +0 -0
  71. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_capabilities.py +0 -0
  72. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_capture.py +0 -0
  73. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_capture_replay_hw.py +0 -0
  74. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_ci.py +0 -0
  75. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_client_v2.py +0 -0
  76. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_connection.py +0 -0
  77. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_control_loop.py +0 -0
  78. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_control_loop_hw.py +0 -0
  79. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_dac_path_ranges.py +0 -0
  80. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_decode.py +0 -0
  81. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_dsp.py +0 -0
  82. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_flash_bridge.py +0 -0
  83. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_gpio.py +0 -0
  84. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_i2c_decode.py +0 -0
  85. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_i2c_parity.py +0 -0
  86. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_image_switching.py +0 -0
  87. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_integration.py +0 -0
  88. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_la_voltage_fixture.py +0 -0
  89. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_pins_bias.py +0 -0
  90. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_protocol.py +0 -0
  91. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_replay.py +0 -0
  92. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_sensor.py +0 -0
  93. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_serial_autodetect.py +0 -0
  94. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_serial_json.py +0 -0
  95. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_serial_text_console.py +0 -0
  96. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_tcp_transport.py +0 -0
  97. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_timed_power.py +0 -0
  98. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_trigger_power.py +0 -0
  99. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_uart_capture.py +0 -0
  100. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_uart_session.py +0 -0
  101. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_upload_build.py +0 -0
  102. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_waveforms.py +0 -0
  103. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_wiring.py +0 -0
  104. {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/testdata/i2c_decode_vectors.json +0 -0
@@ -2,6 +2,30 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 2.2.0
6
+
7
+ - Cloud: the session token the SDK mints is renewed shortly before it expires, and once more if the
8
+ server rejects it (tunnel, command channel and lease heartbeat). A connection held longer than
9
+ the token's hour used to start failing mid-session. A `cloud_token` you pass is not renewed.
10
+ - Cloud: new `BenchPod(cloud_user_token=callable)` authenticates with a logged-in user's access
11
+ token (exchanged via `POST /api/auth/token`), between an API key and GitHub Actions OIDC.
12
+ `cloud_auth.mint_session_token()` returns the token with its expiry; `exchange_user_token()` added.
13
+ The server API (wiring profile, waveform library) uses that user token too: the device and
14
+ waveform routes refuse the cloud session token minted from it.
15
+ - Cloud: an offline pod fails with "BenchPod '…' is offline" and an unknown name with
16
+ `ConnectionConfigError: no cloud BenchPod named '…'`, instead of the raw HTTP error. The lease used
17
+ to read the server's 404 for an unknown name as "no lease support" and carry on unlocked.
18
+ - Cloud: `DeviceBusyError` says until when the holder's lease runs, and how to wait longer outside
19
+ pytest (`lease_wait`).
20
+
21
+ ## 2.1.1
22
+
23
+ - The README, docstrings and examples say 14 LA channels (LA1-LA14); 2.1.0 still said 12.
24
+ No code changes.
25
+ - New e2e tier `tests/e2e/test_e2e_la13_14.py`: LA13/LA14 in the pin table, GPIO drive and
26
+ read-back, captures, triggers, step trains and a UART session. The cloud tier also checks
27
+ LA13/LA14, and it no longer requires every LA word to fit 12 bits.
28
+
5
29
  ## 2.1.0
6
30
 
7
31
  - BenchPod v3 pods now expose LA13/LA14 (14 LA channels instead of 12): every LA-channel
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: embeddedci
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: BenchPod SDK and pytest plugin 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
@@ -47,7 +47,7 @@ can:
47
47
  * **flash** firmware over SWD and assert it worked
48
48
  * capture the DUT's **UART**, or hold an interactive UART session
49
49
  * **emulate an I2C sensor** (BMP280) and decode the bus traffic
50
- * capture **ADC** (calibrated volts), a 12-channel **logic analyzer**, or both from one trigger
50
+ * capture **ADC** (calibrated volts), a 14-channel **logic analyzer**, or both from one trigger
51
51
  * drive the **DAC**: DC levels, generated waveforms, arbitrary replay, fault injection, and an
52
52
  in-fabric **control loop**
53
53
  * talk **CAN**, including an autonomous ECU simulator
@@ -196,7 +196,7 @@ is selected. You can also call `bp.set_la_voltage(3.3)` later; `bp.get_la_voltag
196
196
  `LaVoltage` whose `voltage` is `None` until one is chosen. 1.8 V needs a rev3 pod.
197
197
 
198
198
  Other constructor arguments: `timeout` (seconds, default 30), `api_key`, `api_base`,
199
- `cloud_token`, `cloud_audience`, `lease`, `lease_wait`, `lease_ttl` (see [Cloud](#cloud-embeddedcidevice-name))
199
+ `cloud_token`, `cloud_audience`, `cloud_user_token`, `lease`, `lease_wait`, `lease_ttl` (see [Cloud](#cloud-embeddedcidevice-name))
200
200
  and `transport` (inject a custom backend).
201
201
 
202
202
  ### Connection strings
@@ -207,7 +207,7 @@ and `transport` (inject a custom backend).
207
207
  | `/dev/ttyACM0`, `COM3`, `\\.\COM10` | USB console, explicit device path |
208
208
  | `usb` | USB console, auto-detected by probing the serial ports (`serial` also accepted) |
209
209
  | `discover` (or `mdns`, `auto`) | find exactly one pod on the LAN via mDNS (needs `[discovery]`); errors on zero or several |
210
- | `embeddedci:<device-name>` | a named device through embeddedci.com (needs `[cloud]`; an API key anywhere, or GitHub Actions OIDC) |
210
+ | `embeddedci:<device-name>` | a named device through embeddedci.com (needs `[cloud]`; an API key or a user token anywhere, or GitHub Actions OIDC) |
211
211
 
212
212
  **Use the network (or the cloud) for testing.** The STM32 pod's USB console is a text shell for
213
213
  setup and diagnostics with no JSON mode, so over USB the SDK can only read `status()`, `ping()`,
@@ -253,7 +253,7 @@ automatically.)
253
253
  | Concept | Constants | Wire value |
254
254
  |---|---|---|
255
255
  | Target-power eFuse | `INTERNAL`, `EXTERNAL` (`Efuse`) | 1, 2 |
256
- | LA channels | `PIN1` … `PIN12` (`Pin`) | 1 … 12 |
256
+ | LA channels | `PIN1` … `PIN14` (`Pin`) | 1 … 14 |
257
257
  | Emulated sensor | `Sensor.BMP280` | `"bmp280"` |
258
258
  | BMP280 addresses | `BMP280_ADDR_PRIMARY`, `BMP280_ADDR_SECONDARY` | 0x76, 0x77 |
259
259
  | Gateware image | `FpgaImage.LOOP`, `FpgaImage.DEEP_REPLAY` | 0, 1 |
@@ -371,7 +371,7 @@ benchpod_connection = 192.168.1.213
371
371
  | `benchpod_dac` | function | `benchpod`; stops any DAC output (generate, replay, control loop) at teardown |
372
372
  | `benchpod_capabilities` | session | `benchpod.capabilities` |
373
373
  | `benchpod_waveforms` | function | the cloud `WaveformLibrary`; deletes waveforms saved through it during the test; skips without server access |
374
- | `benchpod_pins` / `pins` | session | `pin_1` … `pin_12`, `efuse`, and `has_pullup()`, `has_pulldown()`, `pull_ohms()`, `pullup_ohms()`, `pull_direction()` |
374
+ | `benchpod_pins` / `pins` | session | `pin_1` … `pin_14`, `efuse`, and `has_pullup()`, `has_pulldown()`, `pull_ohms()`, `pullup_ohms()`, `pull_direction()` |
375
375
  | `firmware` | function | the `--benchpod-firmware` path (skips when unset) |
376
376
  | `build_report` | function | a build reporter — see [Build reporting](#build-reporting) |
377
377
 
@@ -1046,9 +1046,16 @@ devices you may drive:
1046
1046
 
1047
1047
  1. **An API key** (`eci_…`) via `api_key=`, `--benchpod-api-key` or `BENCHPOD_API_KEY`. Works
1048
1048
  anywhere: your desk, any CI system.
1049
- 2. **GitHub Actions OIDC** when no API key is set. No secret is stored — the workflow proves which
1049
+ 2. **A logged-in user's token** via `cloud_user_token=`, a callable returning the access token (the
1050
+ MCP server passes the `benchpod login` session this way).
1051
+ 3. **GitHub Actions OIDC** when neither is set. No secret is stored — the workflow proves which
1050
1052
  repo it is, like PyPI Trusted Publishing. Only works inside a GitHub Actions job.
1051
1053
 
1054
+ The SDK renews that session token shortly before it expires (and once more if the server rejects
1055
+ it), so a connection held for hours keeps working. A token you pass yourself as `cloud_token=` is
1056
+ used as is. An offline pod fails with "BenchPod '…' is offline", a wrong name with "no cloud
1057
+ BenchPod named '…'", and a busy pod names who holds it and until when.
1058
+
1052
1059
  ```python
1053
1060
  from embeddedci.benchpod import BenchPod
1054
1061
 
@@ -9,7 +9,7 @@ can:
9
9
  * **flash** firmware over SWD and assert it worked
10
10
  * capture the DUT's **UART**, or hold an interactive UART session
11
11
  * **emulate an I2C sensor** (BMP280) and decode the bus traffic
12
- * capture **ADC** (calibrated volts), a 12-channel **logic analyzer**, or both from one trigger
12
+ * capture **ADC** (calibrated volts), a 14-channel **logic analyzer**, or both from one trigger
13
13
  * drive the **DAC**: DC levels, generated waveforms, arbitrary replay, fault injection, and an
14
14
  in-fabric **control loop**
15
15
  * talk **CAN**, including an autonomous ECU simulator
@@ -158,7 +158,7 @@ is selected. You can also call `bp.set_la_voltage(3.3)` later; `bp.get_la_voltag
158
158
  `LaVoltage` whose `voltage` is `None` until one is chosen. 1.8 V needs a rev3 pod.
159
159
 
160
160
  Other constructor arguments: `timeout` (seconds, default 30), `api_key`, `api_base`,
161
- `cloud_token`, `cloud_audience`, `lease`, `lease_wait`, `lease_ttl` (see [Cloud](#cloud-embeddedcidevice-name))
161
+ `cloud_token`, `cloud_audience`, `cloud_user_token`, `lease`, `lease_wait`, `lease_ttl` (see [Cloud](#cloud-embeddedcidevice-name))
162
162
  and `transport` (inject a custom backend).
163
163
 
164
164
  ### Connection strings
@@ -169,7 +169,7 @@ and `transport` (inject a custom backend).
169
169
  | `/dev/ttyACM0`, `COM3`, `\\.\COM10` | USB console, explicit device path |
170
170
  | `usb` | USB console, auto-detected by probing the serial ports (`serial` also accepted) |
171
171
  | `discover` (or `mdns`, `auto`) | find exactly one pod on the LAN via mDNS (needs `[discovery]`); errors on zero or several |
172
- | `embeddedci:<device-name>` | a named device through embeddedci.com (needs `[cloud]`; an API key anywhere, or GitHub Actions OIDC) |
172
+ | `embeddedci:<device-name>` | a named device through embeddedci.com (needs `[cloud]`; an API key or a user token anywhere, or GitHub Actions OIDC) |
173
173
 
174
174
  **Use the network (or the cloud) for testing.** The STM32 pod's USB console is a text shell for
175
175
  setup and diagnostics with no JSON mode, so over USB the SDK can only read `status()`, `ping()`,
@@ -215,7 +215,7 @@ automatically.)
215
215
  | Concept | Constants | Wire value |
216
216
  |---|---|---|
217
217
  | Target-power eFuse | `INTERNAL`, `EXTERNAL` (`Efuse`) | 1, 2 |
218
- | LA channels | `PIN1` … `PIN12` (`Pin`) | 1 … 12 |
218
+ | LA channels | `PIN1` … `PIN14` (`Pin`) | 1 … 14 |
219
219
  | Emulated sensor | `Sensor.BMP280` | `"bmp280"` |
220
220
  | BMP280 addresses | `BMP280_ADDR_PRIMARY`, `BMP280_ADDR_SECONDARY` | 0x76, 0x77 |
221
221
  | Gateware image | `FpgaImage.LOOP`, `FpgaImage.DEEP_REPLAY` | 0, 1 |
@@ -333,7 +333,7 @@ benchpod_connection = 192.168.1.213
333
333
  | `benchpod_dac` | function | `benchpod`; stops any DAC output (generate, replay, control loop) at teardown |
334
334
  | `benchpod_capabilities` | session | `benchpod.capabilities` |
335
335
  | `benchpod_waveforms` | function | the cloud `WaveformLibrary`; deletes waveforms saved through it during the test; skips without server access |
336
- | `benchpod_pins` / `pins` | session | `pin_1` … `pin_12`, `efuse`, and `has_pullup()`, `has_pulldown()`, `pull_ohms()`, `pullup_ohms()`, `pull_direction()` |
336
+ | `benchpod_pins` / `pins` | session | `pin_1` … `pin_14`, `efuse`, and `has_pullup()`, `has_pulldown()`, `pull_ohms()`, `pullup_ohms()`, `pull_direction()` |
337
337
  | `firmware` | function | the `--benchpod-firmware` path (skips when unset) |
338
338
  | `build_report` | function | a build reporter — see [Build reporting](#build-reporting) |
339
339
 
@@ -1008,9 +1008,16 @@ devices you may drive:
1008
1008
 
1009
1009
  1. **An API key** (`eci_…`) via `api_key=`, `--benchpod-api-key` or `BENCHPOD_API_KEY`. Works
1010
1010
  anywhere: your desk, any CI system.
1011
- 2. **GitHub Actions OIDC** when no API key is set. No secret is stored — the workflow proves which
1011
+ 2. **A logged-in user's token** via `cloud_user_token=`, a callable returning the access token (the
1012
+ MCP server passes the `benchpod login` session this way).
1013
+ 3. **GitHub Actions OIDC** when neither is set. No secret is stored — the workflow proves which
1012
1014
  repo it is, like PyPI Trusted Publishing. Only works inside a GitHub Actions job.
1013
1015
 
1016
+ The SDK renews that session token shortly before it expires (and once more if the server rejects
1017
+ it), so a connection held for hours keeps working. A token you pass yourself as `cloud_token=` is
1018
+ used as is. An offline pod fails with "BenchPod '…' is offline", a wrong name with "no cloud
1019
+ BenchPod named '…'", and a busy pod names who holds it and until when.
1020
+
1014
1021
  ```python
1015
1022
  from embeddedci.benchpod import BenchPod
1016
1023
 
@@ -41,8 +41,8 @@ Without a connection or a firmware image the test **skips** (so it's safe in CI)
41
41
 
42
42
  ### Wiring
43
43
 
44
- The pod has **no dedicated SWD/UART/I2C pins** — it exposes 12 generic LA channels
45
- (`pins.pin_1` … `pins.pin_12`) and any DUT signal can be on any of them. The example
44
+ The pod has **no dedicated SWD/UART/I2C pins** — it exposes 14 generic LA channels
45
+ (`pins.pin_1` … `pins.pin_14`) and any DUT signal can be on any of them. The example
46
46
  maps its own wiring in a `wiring` fixture at the top of `test_bmp280.py`; the table
47
47
  below is what that fixture uses — edit it to match your board.
48
48
 
@@ -21,7 +21,7 @@
21
21
  # (--benchpod-connection also takes "usb", a serial device path, "discover", or
22
22
  # "embeddedci:<device-name>" for a pod reached through embeddedci.com.)
23
23
  #
24
- # Wiring — the pod has no dedicated SWD/UART/I2C pins. It exposes 12 generic LA
24
+ # Wiring — the pod has no dedicated SWD/UART/I2C pins. It exposes 14 generic LA
25
25
  # channels (pins.pin_1 .. pins.pin_12) and any DUT signal can be on any of them.
26
26
  # The `wiring` fixture below is THIS bench's map; edit it to match your board:
27
27
  #
@@ -37,7 +37,7 @@
37
37
  # Target 5V power eFuse 1 (internal) --benchpod-efuse
38
38
  #
39
39
  # Switchable pull-ups exist only on LA1-LA6 (LA1/2 = 4.7k, LA3/4 = 2.2k,
40
- # LA5/6 = 10k). LA7/LA8 carry 10k pull-DOWNs and LA9-LA12 nothing, so the
40
+ # LA5/6 = 10k). LA7/LA8 carry 10k pull-DOWNs and LA9-LA14 nothing, so the
41
41
  # open-drain I2C lines must sit on LA1-LA6. The resistors are referenced to 3V3,
42
42
  # which is why this example runs the LA bank at 3.3 V.
43
43
  #
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "embeddedci"
7
- version = "2.1.0"
7
+ version = "2.2.0"
8
8
  description = "BenchPod SDK and pytest plugin for EmbeddedCI hardware-in-the-loop testing"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -30,7 +30,7 @@ import logging
30
30
  import os
31
31
  import time
32
32
  from contextlib import contextmanager
33
- from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Mapping, Optional, Sequence, Tuple, Union
33
+ from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Mapping, Optional, Sequence, Tuple, Union
34
34
 
35
35
  from . import can as _can
36
36
  from . import capture as _capture
@@ -176,6 +176,7 @@ class BenchPod:
176
176
  api_key: Optional[str] = None,
177
177
  cloud_token: Optional[str] = None,
178
178
  cloud_audience: Optional[str] = None,
179
+ cloud_user_token: Optional[Callable[[], str]] = None,
179
180
  lease: bool = True,
180
181
  lease_wait: float = DEFAULT_LEASE_WAIT,
181
182
  lease_ttl: int = DEFAULT_LEASE_TTL,
@@ -192,9 +193,12 @@ class BenchPod:
192
193
  I/O-bank voltage right after connecting. The pod refuses every LA-bank operation —
193
194
  flashing, UART, LA capture, pull resistors, I2C-sensor emulation — until one is selected.
194
195
 
195
- Cloud options: ``api_key`` (or ``BENCHPOD_API_KEY``) authenticates anywhere; without it the
196
- cloud destination uses GitHub Actions OIDC (``cloud_token``/``cloud_audience`` override
197
- that). ``api_base`` (or ``BENCHPOD_API_BASE``) points at another embeddedci server. An API
196
+ Cloud options: ``api_key`` (or ``BENCHPOD_API_KEY``) authenticates anywhere; else
197
+ ``cloud_user_token``, a callable returning a logged-in user's access token (such as the
198
+ ``benchpod login`` session), is exchanged for a device session; without either the cloud
199
+ destination uses GitHub Actions OIDC (``cloud_token``/``cloud_audience`` override that).
200
+ A session token the SDK mints is renewed before it expires, so a long session keeps
201
+ working; a ``cloud_token`` you pass is used as is. ``api_base`` (or ``BENCHPOD_API_BASE``) points at another embeddedci server. An API
198
202
  key also unlocks the cloud waveform library on a LAN/serial connection.
199
203
 
200
204
  The cloud device is *shared*, so the client takes an exclusive **lease** on it for the life
@@ -210,6 +214,7 @@ class BenchPod:
210
214
  self._lease: Optional[DeviceLease] = None
211
215
  self._api_base = api_base or os.environ.get(API_BASE_ENV)
212
216
  self._api_key = api_key or os.environ.get(API_KEY_ENV)
217
+ self._cloud_user_token = cloud_user_token
213
218
  self._caps: Optional[Capabilities] = None
214
219
  self._server_api: Optional["ServerApi"] = None
215
220
  self._waveforms: Optional["WaveformLibrary"] = None
@@ -228,12 +233,14 @@ class BenchPod:
228
233
  token=cloud_token,
229
234
  audience=cloud_audience,
230
235
  api_key=self._api_key,
236
+ user_token=cloud_user_token,
231
237
  )
232
238
  try:
233
239
  if lease and isinstance(self._transport, CloudTransport):
234
240
  self._lease = DeviceLease(
235
241
  api_base=self._transport.api_base,
236
242
  token_provider=self._transport._session_token,
243
+ invalidate_token=self._transport._invalidate_token,
237
244
  device_name=self._transport.device_name,
238
245
  ttl_seconds=lease_ttl,
239
246
  )
@@ -1105,7 +1112,8 @@ class BenchPod:
1105
1112
  return _control_loop.IVPoint(
1106
1113
  i=int(d.get("i", 0)), v=int(d.get("v", 0)),
1107
1114
  input_code=None if raw_in is None else int(raw_in),
1108
- source=d.get("source"))
1115
+ source=d.get("source"),
1116
+ tripped=None if d.get("tripped") is None else bool(d.get("tripped")))
1109
1117
 
1110
1118
  # -- gateware image -------------------------------------------------------
1111
1119
 
@@ -1389,10 +1397,12 @@ class BenchPod:
1389
1397
  # -- cloud waveform library + server API ----------------------------------
1390
1398
 
1391
1399
  def _try_server_api(self) -> Optional["ServerApi"]:
1392
- """Build a :class:`ServerApi` if possible (API key or a cloud session token), else None."""
1400
+ """Build a :class:`ServerApi` if possible, else None. Credentials, in order: the API key; the
1401
+ logged-in user's token (the server's device and waveform routes take a user, not a cloud
1402
+ session token minted from one); the cloud transport's session token (API key or OIDC)."""
1393
1403
  if self._server_api is not None:
1394
1404
  return self._server_api
1395
- token_provider = getattr(self._transport, "_session_token", None)
1405
+ token_provider = self._cloud_user_token or getattr(self._transport, "_session_token", None)
1396
1406
  if not self._api_key and token_provider is None:
1397
1407
  return None
1398
1408
  from .server_api import ServerApi
@@ -11,17 +11,21 @@ not run the pytest suite over the cloud at all, so the cloud transport went effe
11
11
  outside CI while the identical tests ran fine over a direct LAN connection. A **user API key**
12
12
  (``BENCHPOD_API_KEY``/``api_key=``) is therefore accepted as an alternative credential and
13
13
  exchanged via ``POST /api/auth/token`` — the same endpoint and the same ``ApiKey`` scheme the Go
14
- hwe2e cloud suite authenticates with. The API key is preferred when present; OIDC remains the
15
- zero-secret path for GitHub Actions.
14
+ hwe2e cloud suite authenticates with. The API key is preferred when present; a logged-in user's
15
+ access token (``Bearer``, e.g. the ``benchpod login`` session) is exchanged at the same endpoint;
16
+ OIDC remains the zero-secret path for GitHub Actions.
16
17
  """
17
18
 
18
19
  from __future__ import annotations
19
20
 
20
21
  import json
21
22
  import os
23
+ import time
22
24
  import urllib.error
23
25
  import urllib.parse
24
26
  import urllib.request
27
+ from datetime import datetime
28
+ from typing import Callable, Tuple
25
29
 
26
30
  from .errors import CloudAuthError
27
31
 
@@ -30,6 +34,8 @@ from .errors import CloudAuthError
30
34
  # embeddedci.com 301-redirects to www, which urllib would mishandle on a POST).
31
35
  DEFAULT_AUDIENCE = "https://embeddedci.com"
32
36
  DEFAULT_API_BASE = "https://www.embeddedci.com"
37
+ #: Lifetime asked for when exchanging an API key or user token; the cloud transport renews it.
38
+ DEFAULT_SESSION_TTL = 3600
33
39
 
34
40
  # A real User-Agent is required: the apex/edge (Cloudflare) bans the default "Python-urllib/x.y"
35
41
  # signature (HTTP 403, error 1010). Identify the SDK explicitly instead.
@@ -119,19 +125,16 @@ def exchange_token(api_base: str, oidc_token: str) -> dict:
119
125
  raise CloudAuthError(f"embeddedci token exchange failed: {exc}") from exc
120
126
 
121
127
 
122
- def exchange_api_key(api_base: str, api_key: str, ttl_seconds: int = 3600) -> dict:
123
- """Exchange a user API key for a short-lived ``cloud_session`` token via POST /api/auth/token.
124
-
125
- Mirrors the Go hwe2e cloud suite's ``/auth/token`` call: the key authenticates the request
126
- (``Authorization: ApiKey eci_…``) and the reply carries a bearer token scoped to the devices
127
- that key may drive.
128
- """
128
+ def _exchange_for_cloud_session(api_base: str, authorization: str, what: str,
129
+ ttl_seconds: int) -> dict:
130
+ """POST /api/auth/token with ``authorization``: the reply carries a short-lived ``cloud_session``
131
+ bearer token scoped to the devices that credential may drive."""
129
132
  url = api_base.rstrip("/") + "/api/auth/token"
130
133
  data = json.dumps({"ttl_seconds": int(ttl_seconds)}).encode("utf-8")
131
134
  request = urllib.request.Request(
132
135
  url, data=data, method="POST",
133
136
  headers={
134
- "Authorization": f"ApiKey {api_key}",
137
+ "Authorization": authorization,
135
138
  "Content-Type": "application/json",
136
139
  "Accept": "application/json",
137
140
  "User-Agent": USER_AGENT,
@@ -146,30 +149,70 @@ def exchange_api_key(api_base: str, api_key: str, ttl_seconds: int = 3600) -> di
146
149
  msg = json.loads(msg).get("error", msg)
147
150
  except Exception:
148
151
  pass
149
- raise CloudAuthError(f"embeddedci API-key token exchange failed (HTTP {exc.code}): {msg}") from exc
152
+ raise CloudAuthError(f"embeddedci {what} token exchange failed (HTTP {exc.code}): {msg}") from exc
150
153
  except Exception as exc:
151
- raise CloudAuthError(f"embeddedci API-key token exchange failed: {exc}") from exc
154
+ raise CloudAuthError(f"embeddedci {what} token exchange failed: {exc}") from exc
152
155
 
153
156
 
154
- def get_session_token(api_base: str = DEFAULT_API_BASE, audience: str = DEFAULT_AUDIENCE,
155
- api_key: "str | None" = None) -> str:
156
- """Mint an embeddedci session token for the cloud destination.
157
+ def exchange_api_key(api_base: str, api_key: str, ttl_seconds: int = DEFAULT_SESSION_TTL) -> dict:
158
+ """Exchange a user API key for a short-lived ``cloud_session`` token via POST /api/auth/token.
157
159
 
158
- Prefers an explicit ``api_key`` (or ``BENCHPOD_API_KEY``), which works anywhere; falls back to
159
- GitHub Actions OIDC, which works only inside a workflow with ``id-token: write``.
160
+ Mirrors the Go hwe2e cloud suite's ``/auth/token`` call: the key authenticates the request
161
+ (``Authorization: ApiKey eci_…``) and the reply carries a bearer token scoped to the devices
162
+ that key may drive.
163
+ """
164
+ return _exchange_for_cloud_session(api_base, f"ApiKey {api_key}", "API-key", ttl_seconds)
165
+
166
+
167
+ def exchange_user_token(api_base: str, access_token: str, ttl_seconds: int = DEFAULT_SESSION_TTL) -> dict:
168
+ """Exchange a logged-in user's access token (e.g. from ``benchpod login``) for a
169
+ ``cloud_session`` token via the same POST /api/auth/token, authenticated as ``Bearer``."""
170
+ return _exchange_for_cloud_session(api_base, f"Bearer {access_token}", "user", ttl_seconds)
171
+
172
+
173
+ def _expires_at(resp: dict) -> "float | None":
174
+ """The token's expiry as a ``time.time()`` value, from ``expires_at`` or ``expires_in``."""
175
+ raw = resp.get("expires_at")
176
+ if isinstance(raw, str) and raw:
177
+ try:
178
+ return datetime.fromisoformat(raw.replace("Z", "+00:00")).timestamp()
179
+ except ValueError:
180
+ pass
181
+ try:
182
+ return time.time() + float(resp["expires_in"])
183
+ except (KeyError, TypeError, ValueError):
184
+ return None
185
+
186
+
187
+ def mint_session_token(api_base: str = DEFAULT_API_BASE, audience: str = DEFAULT_AUDIENCE,
188
+ api_key: "str | None" = None,
189
+ user_token: "Callable[[], str] | None" = None) -> "Tuple[str, float | None]":
190
+ """Mint an embeddedci session token for the cloud destination, and when it expires.
191
+
192
+ Credentials, in order: an explicit ``api_key`` (or ``BENCHPOD_API_KEY``), which works anywhere;
193
+ ``user_token``, a callable returning a logged-in user's access token; GitHub Actions OIDC, which
194
+ works only inside a workflow with ``id-token: write``. Returns ``(token, expires_at)`` with
195
+ ``expires_at`` a ``time.time()`` value, or None when the server did not say.
160
196
  """
161
197
  key = api_key or os.environ.get("BENCHPOD_API_KEY")
162
- if key:
163
- resp = exchange_api_key(api_base, key.strip())
198
+ if key or user_token is not None:
199
+ resp = (exchange_api_key(api_base, key.strip()) if key
200
+ else exchange_user_token(api_base, user_token())) # type: ignore[misc]
164
201
  # /auth/token replies {"token": …}; accept access_token too so either shape works.
165
202
  token = resp.get("token") or resp.get("access_token")
166
203
  if not token:
167
- raise CloudAuthError("embeddedci API-key token exchange returned no token")
168
- return token
204
+ raise CloudAuthError("embeddedci token exchange returned no token")
205
+ return token, _expires_at(resp)
169
206
 
170
207
  oidc = mint_oidc_token(audience)
171
208
  resp = exchange_token(api_base, oidc)
172
209
  token = resp.get("access_token")
173
210
  if not token:
174
211
  raise CloudAuthError("embeddedci token exchange returned no access_token")
175
- return token
212
+ return token, _expires_at(resp)
213
+
214
+
215
+ def get_session_token(api_base: str = DEFAULT_API_BASE, audience: str = DEFAULT_AUDIENCE,
216
+ api_key: "str | None" = None) -> str:
217
+ """Mint an embeddedci session token for the cloud destination (see :func:`mint_session_token`)."""
218
+ return mint_session_token(api_base, audience, api_key)[0]
@@ -230,12 +230,15 @@ class IVPoint:
230
230
  in a closed-loop run, but in a fixed/sweep run the ADC is not in the path at all and ``i`` is
231
231
  a reading of something nothing is using, so plot and assert against ``input_code``.
232
232
  ``source`` names the input source the device reports (``None`` on firmware without it).
233
+ ``tripped`` is True once the loop's over-range trip has latched: the output is parked at vmin
234
+ until the loop is re-armed (``None`` on firmware that does not report it).
233
235
  """
234
236
 
235
237
  i: int
236
238
  v: int
237
239
  input_code: Optional[int] = None
238
240
  source: Optional[str] = None
241
+ tripped: Optional[bool] = None
239
242
 
240
243
  @property
241
244
  def loop_input(self) -> int:
@@ -28,6 +28,10 @@ from .errors import FlashError, TargetUnreachableError
28
28
  from .transport.base import RawLink, Transport
29
29
 
30
30
  TARGET_POWER_SETTLE = 0.25 # seconds for the target to boot after power-on
31
+ # After switching the target's rail on, the pod also waits (up to this long) for the target to
32
+ # answer SWD before handing over: an STM32 NUCLEO needs ~2.1 s (its ST-LINK boots first), which a
33
+ # fixed settle cannot cover for every board. Firmware that predates it ignores the field.
34
+ TARGET_ANSWER_WAIT_MS = 5000
31
35
 
32
36
  # OpenOCD stderr substrings that mean the probe worked but no target answered.
33
37
  _SWD_CONNECT_MARKERS = (b"cannot read IDR", b"Error connecting DP")
@@ -37,7 +41,11 @@ _SWD_CONNECT_MARKERS = (b"cannot read IDR", b"Error connecting DP")
37
41
  _DAP_TCP_SIG = b"\x44\x41\x50\x00" # "DAP\0" little-endian u32 packet signature
38
42
  _DAP_TCP_HDR = 8 # signature(4) + len(2) + type(1) + reserved(1)
39
43
  _DAP_PKT_RESPONSE = 0x02 # packet_type OpenOCD expects on device->host frames
40
- _DAP_MAX_PACKET = 256 # mirrors DAP_PACKET_SIZE in the firmware (dap.h)
44
+ # CMSIS-DAP packets the pod is asked to advertise (dap.h DAP_PACKET_SIZE / DAP_PACKET_COUNT_MAX).
45
+ # Each packet is a host + network round trip, so bigger packets and several in flight cut the
46
+ # waiting between them; firmware that predates the fields keeps 256 x 1.
47
+ _DAP_MAX_PACKET = 1024
48
+ _DAP_PACKET_COUNT = 4
41
49
 
42
50
  # Empty the target cfg's clock-boost reset events. The just-after-reset PLL/clock
43
51
  # writes (`adapter speed`, reset-init) race the still-coming-up core over the SWD
@@ -160,7 +168,7 @@ def flash(
160
168
  ) -> FlashResult:
161
169
  """Arm the CMSIS-DAP probe and run OpenOCD. Returns a :class:`FlashResult`.
162
170
 
163
- ``swclk``/``swdio`` are LA channels 1-12 (already coerced by the caller).
171
+ ``swclk``/``swdio`` are LA channels 1-14 (already coerced by the caller).
164
172
  ``target_power`` of 1/2 enables that eFuse first; ``None`` leaves power
165
173
  untouched.
166
174
 
@@ -207,7 +215,9 @@ def flash(
207
215
  attempts = max(1, connect_attempts)
208
216
  result: Optional[FlashResult] = None
209
217
  for attempt in range(attempts):
210
- pod_link = transport.dap_start(swclk, swdio)
218
+ pod_link = transport.dap_start(
219
+ swclk, swdio, packet_size=_DAP_MAX_PACKET, packet_count=_DAP_PACKET_COUNT,
220
+ wait_ms=TARGET_ANSWER_WAIT_MS if (target_power is not None and attempt == 0) else None)
211
221
  try:
212
222
  result = _run_bridge(bin_path, args, pod_link, timeout=timeout)
213
223
  finally:
@@ -1,6 +1,6 @@
1
1
  """GPIO on the LA pins, and which function owns each pin.
2
2
 
3
- Each of the pod's 12 LA channels has exactly one function at a time:
3
+ Each of the pod's 14 LA channels has exactly one function at a time:
4
4
 
5
5
  * ``none`` — the default "LA mode": high-Z, watched by captures;
6
6
  * ``gpio`` — an input, a push-pull output or an open-drain output you control;
@@ -38,7 +38,7 @@ class LaPinState:
38
38
  gpio: Optional[str] = None
39
39
  #: The commanded level of a GPIO output or open-drain pin.
40
40
  level: Optional[int] = None
41
- #: ``"up"``, ``"down"`` or ``None`` (LA9-LA12 have no bias resistor).
41
+ #: ``"up"``, ``"down"`` or ``None`` (LA9-LA14 have no bias resistor).
42
42
  pull: Optional[str] = None
43
43
  pull_ohms: Optional[str] = None
44
44
  pull_on: bool = False
@@ -161,7 +161,7 @@ def decode(packed: Iterable[int]) -> List[I2CTransaction]:
161
161
 
162
162
 
163
163
  def decode_from_la(words: Iterable[int], sda_ch: int, scl_ch: int) -> List[I2CTransaction]:
164
- """Decode I2C from a raw 12-channel LA capture (the unified-capture path).
164
+ """Decode I2C from a raw 14-channel LA capture (the unified-capture path).
165
165
 
166
166
  ``words`` are 12-bit LA samples (bit ``n`` = channel ``LA{n+1}``); ``sda_ch`` /
167
167
  ``scl_ch`` are 1-based channel numbers. This is how I2C is decoded now that the
@@ -21,7 +21,7 @@ from typing import Callable, Optional, Tuple
21
21
  from urllib.parse import quote
22
22
 
23
23
  from .cloud_auth import USER_AGENT
24
- from .errors import CloudAuthError, DeviceBusyError
24
+ from .errors import CloudAuthError, ConnectionConfigError, DeviceBusyError
25
25
 
26
26
  # Server defaults: lease TTL 120s. Heartbeat at ~TTL/3 keeps it alive through long flashes.
27
27
  DEFAULT_LEASE_TTL = 120
@@ -65,9 +65,12 @@ class DeviceLease:
65
65
  device_name: str,
66
66
  ttl_seconds: int = DEFAULT_LEASE_TTL,
67
67
  run_label: Optional[str] = None,
68
+ invalidate_token: Optional[Callable[[], bool]] = None,
68
69
  ) -> None:
69
70
  self._api_base = api_base.rstrip("/")
70
71
  self._token_provider = token_provider
72
+ # Drops a token the server rejected (HTTP 401) so the provider mints a fresh one.
73
+ self._invalidate_token = invalidate_token
71
74
  self._device = device_name
72
75
  self._ttl = max(15, int(ttl_seconds))
73
76
  self._run_label = run_label or _default_run_label()
@@ -108,7 +111,8 @@ class DeviceLease:
108
111
  if time.monotonic() >= deadline:
109
112
  raise DeviceBusyError(
110
113
  f"BenchPod {self._device!r} is in use by {holder}; waited {wait_timeout:.0f}s "
111
- "for it to free. Increase the wait (--benchpod-lease-wait) or stagger your runs."
114
+ "for it to free. Wait longer (lease_wait, or --benchpod-lease-wait under "
115
+ "pytest), try again later, or stagger your runs."
112
116
  )
113
117
  if not warned:
114
118
  warnings.warn(
@@ -127,7 +131,7 @@ class DeviceLease:
127
131
  if self._held:
128
132
  try:
129
133
  self._post("lease/release")
130
- except CloudAuthError:
134
+ except (CloudAuthError, ConnectionConfigError):
131
135
  pass # best-effort; the lease will expire on its own
132
136
  self._held = False
133
137
 
@@ -140,7 +144,7 @@ class DeviceLease:
140
144
  while not self._stop.wait(interval):
141
145
  try:
142
146
  holder, _ = self._post("lease/renew")
143
- except CloudAuthError:
147
+ except (CloudAuthError, ConnectionConfigError):
144
148
  continue # transient; try again next tick
145
149
  if holder is not None:
146
150
  warnings.warn(
@@ -154,9 +158,10 @@ class DeviceLease:
154
158
  self._thread = threading.Thread(target=loop, name="benchpod-lease", daemon=True)
155
159
  self._thread.start()
156
160
 
157
- def _post(self, path: str) -> Tuple[Optional[str], Optional[dict]]:
161
+ def _post(self, path: str, *, renewed: bool = False) -> Tuple[Optional[str], Optional[dict]]:
158
162
  """POST to /api/cloud/devices/<path>. Returns (None, payload) on success, or
159
- (holder, None) when the device is busy (HTTP 409). Raises CloudAuthError on other failures."""
163
+ (holder, None) when the device is busy (HTTP 409). Raises ConnectionConfigError when the
164
+ server has no such device, CloudAuthError on other failures."""
160
165
  url = f"{self._api_base}/api/cloud/devices/{path}?device={quote(self._device, safe='')}"
161
166
  body = json.dumps(
162
167
  {"lease_id": self._lease_id, "run_label": self._run_label, "ttl_seconds": self._ttl}
@@ -170,16 +175,33 @@ class DeviceLease:
170
175
  with urllib.request.urlopen(request, timeout=_LEASE_HTTP_TIMEOUT) as resp:
171
176
  return None, json.loads(resp.read().decode("utf-8"))
172
177
  except urllib.error.HTTPError as exc:
178
+ detail = exc.read().decode("utf-8", "replace")[:300]
173
179
  if exc.code == 409:
174
180
  holder = "another run"
175
181
  try:
176
- holder = json.loads(exc.read().decode("utf-8")).get("holder", holder)
182
+ busy = json.loads(detail)
183
+ holder = busy.get("holder") or holder
184
+ if busy.get("expires_at"):
185
+ holder += f" (its lease runs until {busy['expires_at']} unless renewed)"
177
186
  except Exception:
178
187
  pass
179
188
  return holder, None
180
- if exc.code in (404, 405, 501):
189
+ if exc.code == 401 and not renewed and self._invalidate_token and self._invalidate_token():
190
+ return self._post(path, renewed=True)
191
+ if exc.code == 404:
192
+ # A JSON error is the server answering for the device; a bare 404 is an older
193
+ # server without the lease route.
194
+ try:
195
+ msg = json.loads(detail).get("error")
196
+ except Exception:
197
+ msg = None
198
+ if msg:
199
+ raise ConnectionConfigError(
200
+ f"no cloud BenchPod named {self._device!r} in this organization ({msg}); "
201
+ "list the devices to see the names") from exc
202
+ raise _LeaseUnsupported() from exc
203
+ if exc.code in (405, 501):
181
204
  raise _LeaseUnsupported() from exc
182
- detail = exc.read().decode("utf-8", "replace")[:300]
183
205
  raise CloudAuthError(f"device lease {path} failed (HTTP {exc.code}): {detail}") from exc
184
206
  except Exception as exc:
185
207
  raise CloudAuthError(f"device lease {path} failed: {exc}") from exc
@@ -26,11 +26,11 @@ _PULLDOWN_CHANNELS = frozenset(PULLDOWN_CHANNELS)
26
26
 
27
27
 
28
28
  class BenchPodPins:
29
- """The pod's 12 generic logic-analyzer channels (``pin_1`` .. ``pin_12``)
29
+ """The pod's 14 generic logic-analyzer channels (``pin_1`` .. ``pin_14``)
30
30
  plus the target-power ``efuse``.
31
31
 
32
- The pod has **no dedicated SWD/UART/I2C pins** — it exposes 12 identical LA
33
- channels (LA1..LA12) and any DUT signal can be wired to any of them. So this
32
+ The pod has **no dedicated SWD/UART/I2C pins** — it exposes 14 identical LA
33
+ channels (LA1..LA14) and any DUT signal can be wired to any of them. So this
34
34
  fixture names the channels by number, not by role: ``pins.pin_11`` is LA
35
35
  channel 11, nothing more. A test maps its own bench wiring at the top of the
36
36
  file, e.g. ``swclk = pins.pin_11`` — that mapping is bench-specific and lives
@@ -387,7 +387,7 @@ def benchpod_target(benchpod: BenchPod, pytestconfig: "pytest.Config") -> Iterat
387
387
 
388
388
  @pytest.fixture(scope="session")
389
389
  def benchpod_pins(pytestconfig: "pytest.Config") -> BenchPodPins:
390
- """The pod's generic LA channels (``pin_1`` .. ``pin_12``) and the eFuse rail.
390
+ """The pod's generic LA channels (``pin_1`` .. ``pin_14``) and the eFuse rail.
391
391
 
392
392
  Channels are not roles — map your bench wiring (which signal is on which LA
393
393
  channel) in the test itself. The eFuse rail comes from ``--benchpod-efuse`` (default 1).