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.
- {embeddedci-2.1.0 → embeddedci-2.2.0}/CHANGELOG.md +24 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/PKG-INFO +14 -7
- {embeddedci-2.1.0 → embeddedci-2.2.0}/README.md +13 -6
- {embeddedci-2.1.0 → embeddedci-2.2.0}/examples/README.md +2 -2
- {embeddedci-2.1.0 → embeddedci-2.2.0}/examples/test_bmp280.py +2 -2
- {embeddedci-2.1.0 → embeddedci-2.2.0}/pyproject.toml +1 -1
- {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/client.py +17 -7
- {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/cloud_auth.py +65 -22
- {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/control_loop.py +3 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/flash.py +13 -3
- {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/gpio.py +2 -2
- {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/i2c.py +1 -1
- {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/lease.py +31 -9
- {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/pytest_plugin.py +4 -4
- {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/results.py +1 -1
- {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/transport/__init__.py +5 -1
- {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/transport/base.py +8 -1
- {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/transport/cloud.py +90 -17
- {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/transport/serial.py +3 -1
- {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/transport/tcp.py +8 -1
- {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/wiring.py +2 -2
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/api_surface.json +4 -3
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/e2e/README.md +1 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/e2e/conftest.py +4 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/e2e/test_e2e_cloud.py +16 -1
- embeddedci-2.2.0/tests/e2e/test_e2e_la13_14.py +130 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/e2e/test_e2e_pod.py +97 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/e2e/test_e2e_rev3.py +6 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/examples/test_bmp280_hil.py +2 -2
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_cloud.py +129 -0
- embeddedci-2.2.0/tests/test_lease.py +144 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_timing.py +1 -1
- embeddedci-2.1.0/tests/test_lease.py +0 -71
- {embeddedci-2.1.0 → embeddedci-2.2.0}/.gitignore +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/LICENSE +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/docs/event-uart-design.md +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/__init__.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/__init__.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/can.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/capabilities.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/capture.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/ci.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/connection.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/constants.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/decode.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/discovery.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/dsp.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/errors.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/lowlevel.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/power.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/protocol.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/replay.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/sensor.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/server_api.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/state.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/uart.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/benchpod/waveforms.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/py.typed +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/src/embeddedci/upload_build.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/conftest.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/e2e/e2e_helpers.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/e2e/test_e2e_dut.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/e2e/test_e2e_gpio.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/e2e/test_e2e_usb.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/examples/test_can_ecu_demo.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/examples/test_can_loopback_hil.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/examples/test_smoke.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_adc_fft_hw.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_api_surface.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_can.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_capabilities.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_capture.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_capture_replay_hw.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_ci.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_client_v2.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_connection.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_control_loop.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_control_loop_hw.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_dac_path_ranges.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_decode.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_dsp.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_flash_bridge.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_gpio.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_i2c_decode.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_i2c_parity.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_image_switching.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_integration.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_la_voltage_fixture.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_pins_bias.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_protocol.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_replay.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_sensor.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_serial_autodetect.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_serial_json.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_serial_text_console.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_tcp_transport.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_timed_power.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_trigger_power.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_uart_capture.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_uart_session.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_upload_build.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_waveforms.py +0 -0
- {embeddedci-2.1.0 → embeddedci-2.2.0}/tests/test_wiring.py +0 -0
- {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.
|
|
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
|
|
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` … `
|
|
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` … `
|
|
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. **
|
|
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
|
+
* 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` … `
|
|
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` … `
|
|
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. **
|
|
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
|
|
45
|
-
(`pins.pin_1` … `pins.
|
|
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
|
|
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-
|
|
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
|
#
|
|
@@ -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;
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
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;
|
|
15
|
-
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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":
|
|
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
|
|
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
|
|
154
|
+
raise CloudAuthError(f"embeddedci {what} token exchange failed: {exc}") from exc
|
|
152
155
|
|
|
153
156
|
|
|
154
|
-
def
|
|
155
|
-
|
|
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
|
-
|
|
159
|
-
|
|
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
|
|
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
|
-
|
|
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-
|
|
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(
|
|
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
|
|
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-
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
33
|
-
channels (LA1..
|
|
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`` .. ``
|
|
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).
|