embeddedci 2.7.0__tar.gz → 2.8.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 (117) hide show
  1. {embeddedci-2.7.0 → embeddedci-2.8.0}/CHANGELOG.md +22 -0
  2. {embeddedci-2.7.0 → embeddedci-2.8.0}/PKG-INFO +183 -4
  3. {embeddedci-2.7.0 → embeddedci-2.8.0}/README.md +182 -3
  4. {embeddedci-2.7.0 → embeddedci-2.8.0}/pyproject.toml +1 -1
  5. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/benchpod/__init__.py +2 -0
  6. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/benchpod/client.py +55 -4
  7. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/benchpod/errors.py +29 -0
  8. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/benchpod/pytest_plugin.py +19 -10
  9. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/benchpod/transport/cloud.py +16 -8
  10. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/benchpod/transport/tcp.py +13 -9
  11. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/api_surface.json +13 -0
  12. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_cloud.py +62 -3
  13. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_dac_limits_fixture.py +39 -0
  14. embeddedci-2.8.0/tests/test_docs_coverage.py +166 -0
  15. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_transport_failures.py +4 -4
  16. embeddedci-2.8.0/tests/test_unsupported_feature.py +93 -0
  17. {embeddedci-2.7.0 → embeddedci-2.8.0}/.gitignore +0 -0
  18. {embeddedci-2.7.0 → embeddedci-2.8.0}/LICENSE +0 -0
  19. {embeddedci-2.7.0 → embeddedci-2.8.0}/docs/event-uart-design.md +0 -0
  20. {embeddedci-2.7.0 → embeddedci-2.8.0}/examples/README.md +0 -0
  21. {embeddedci-2.7.0 → embeddedci-2.8.0}/examples/test_bmp280.py +0 -0
  22. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/__init__.py +0 -0
  23. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/benchpod/can.py +0 -0
  24. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/benchpod/capabilities.py +0 -0
  25. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/benchpod/capture.py +0 -0
  26. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/benchpod/ci.py +0 -0
  27. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/benchpod/cloud_auth.py +0 -0
  28. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/benchpod/connection.py +0 -0
  29. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/benchpod/constants.py +0 -0
  30. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/benchpod/control_loop.py +0 -0
  31. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/benchpod/decode.py +0 -0
  32. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/benchpod/discovery.py +0 -0
  33. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/benchpod/dsp.py +0 -0
  34. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/benchpod/flash.py +0 -0
  35. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/benchpod/gpio.py +0 -0
  36. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/benchpod/i2c.py +0 -0
  37. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/benchpod/lease.py +0 -0
  38. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/benchpod/lowlevel.py +0 -0
  39. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/benchpod/motor_emulator.py +0 -0
  40. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/benchpod/power.py +0 -0
  41. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/benchpod/protocol.py +0 -0
  42. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/benchpod/replay.py +0 -0
  43. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/benchpod/results.py +0 -0
  44. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/benchpod/sensor.py +0 -0
  45. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/benchpod/server_api.py +0 -0
  46. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/benchpod/spi.py +0 -0
  47. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/benchpod/state.py +0 -0
  48. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/benchpod/transport/__init__.py +0 -0
  49. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/benchpod/transport/base.py +0 -0
  50. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/benchpod/transport/serial.py +0 -0
  51. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/benchpod/uart.py +0 -0
  52. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/benchpod/waveforms.py +0 -0
  53. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/benchpod/wiring.py +0 -0
  54. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/py.typed +0 -0
  55. {embeddedci-2.7.0 → embeddedci-2.8.0}/src/embeddedci/upload_build.py +0 -0
  56. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/conftest.py +0 -0
  57. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/e2e/README.md +0 -0
  58. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/e2e/conftest.py +0 -0
  59. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/e2e/e2e_helpers.py +0 -0
  60. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/e2e/test_e2e_cloud.py +0 -0
  61. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/e2e/test_e2e_dut.py +0 -0
  62. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/e2e/test_e2e_gpio.py +0 -0
  63. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/e2e/test_e2e_la13_14.py +0 -0
  64. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/e2e/test_e2e_pod.py +0 -0
  65. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/e2e/test_e2e_rev3.py +0 -0
  66. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/e2e/test_e2e_spi.py +0 -0
  67. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/e2e/test_e2e_usb.py +0 -0
  68. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/examples/test_bmp280_hil.py +0 -0
  69. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/examples/test_can_ecu_demo.py +0 -0
  70. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/examples/test_can_loopback_hil.py +0 -0
  71. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/examples/test_smoke.py +0 -0
  72. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_adc_fft_hw.py +0 -0
  73. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_api_surface.py +0 -0
  74. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_can.py +0 -0
  75. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_capabilities.py +0 -0
  76. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_capture.py +0 -0
  77. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_capture_replay_hw.py +0 -0
  78. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_ci.py +0 -0
  79. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_client_v2.py +0 -0
  80. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_connection.py +0 -0
  81. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_control_loop.py +0 -0
  82. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_control_loop_hw.py +0 -0
  83. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_current_out_waveforms.py +0 -0
  84. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_dac_path_ranges.py +0 -0
  85. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_decode.py +0 -0
  86. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_decode_parity.py +0 -0
  87. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_dsp.py +0 -0
  88. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_flash_bridge.py +0 -0
  89. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_gpio.py +0 -0
  90. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_i2c_decode.py +0 -0
  91. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_i2c_parity.py +0 -0
  92. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_image_switching.py +0 -0
  93. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_integration.py +0 -0
  94. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_la_voltage_fixture.py +0 -0
  95. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_lease.py +0 -0
  96. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_motor_emulator.py +0 -0
  97. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_pins_bias.py +0 -0
  98. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_protocol.py +0 -0
  99. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_refusal_errors.py +0 -0
  100. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_replay.py +0 -0
  101. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_sensor.py +0 -0
  102. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_serial_autodetect.py +0 -0
  103. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_serial_json.py +0 -0
  104. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_serial_text_console.py +0 -0
  105. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_spi.py +0 -0
  106. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_tcp_transport.py +0 -0
  107. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_timed_power.py +0 -0
  108. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_timing.py +0 -0
  109. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_trigger_power.py +0 -0
  110. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_uart_capture.py +0 -0
  111. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_uart_session.py +0 -0
  112. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_upload_build.py +0 -0
  113. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_waveforms.py +0 -0
  114. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/test_wiring.py +0 -0
  115. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/testdata/i2c_decode_vectors.json +0 -0
  116. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/testdata/spi_decode_vectors.json +0 -0
  117. {embeddedci-2.7.0 → embeddedci-2.8.0}/tests/testdata/uart_decode_vectors.json +0 -0
@@ -1,5 +1,27 @@
1
1
  # Changelog — `embeddedci`
2
2
 
3
+ ## 2.8.0
4
+
5
+ - `UnsupportedFeatureError` (with `feature` and `firmware_version`): the pod cannot do what was
6
+ asked. Raised before anything is sent when the pod's capabilities lack the feature (it used to
7
+ be a plain `BenchPodError`), and for the pod's own `unknown cmd` and the digital board's "no
8
+ analog front end" refusal (they used to be plain `FirmwareError`s). It subclasses
9
+ `FirmwareError`, so existing handlers still catch it.
10
+ - Capability checks on `BenchPod`: `supports(flag)` for any `Capabilities` flag, and the
11
+ properties `can_gpio`, `can_trigger`, `can_spi`, `can_profile_power`, `can_calibrate`,
12
+ `can_current_out`, `can_reset_target` and `can_analog`, e.g. to skip a test on a pod without the
13
+ feature. (`can_config` … `can_disable` remain the CAN bus commands.)
14
+ - pytest plugin: `--benchpod-api-key`, `--benchpod-api-base` and the lease options also reach the
15
+ connection the `BENCHPOD_LIFT_DAC_LIMITS=1` session fixture opens. A cloud run authenticated
16
+ only by the flag used to fail to lift (and restore) the DAC limits there.
17
+
18
+ ## 2.7.1
19
+
20
+ - Over the cloud, a UART console (`open_uart`) can stay open while a capture, power profile or
21
+ DAC replay runs, as on the LAN. Each streaming tunnel tells the server what it is for
22
+ (`?kind=uart|capture|dac|flash`). A server without kind support still answers HTTP 409
23
+ ("device is busy") for the second tunnel, as before.
24
+
3
25
  ## 2.7.0
4
26
 
5
27
  - `UartSession.write` on a link that is gone (a reset connection, an unplugged pod, a closed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: embeddedci
3
- Version: 2.7.0
3
+ Version: 2.8.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
@@ -73,7 +73,8 @@ No pin numbers: the SWD channels, the UART pair, the baud and the power rail com
73
73
  [Analog paths](#analog-paths-dc-output-and-single-readings) · [Captures](#captures) ·
74
74
  [DAC](#dac-generator-replay-and-faults) · [Control loop](#in-fabric-dac-control-loop) ·
75
75
  [CAN](#can) · [Cloud](#cloud-embeddedcidevice-name) · [Build reporting](#build-reporting) ·
76
- [Errors](#errors) · [Escape hatches](#escape-hatches) · [Releasing](#releasing-maintainers)
76
+ [Errors](#errors) · [Escape hatches](#escape-hatches) · [API reference](#api-reference) ·
77
+ [Releasing](#releasing-maintainers)
77
78
 
78
79
  ## Install
79
80
 
@@ -232,6 +233,8 @@ automatically.)
232
233
  | `BENCHPOD_API_KEY` | no `api_key` argument / `--benchpod-api-key` | EmbeddedCI API key (`eci_…`) for the cloud destination and server features |
233
234
  | `BENCHPOD_API_BASE` | no `api_base` argument / `--benchpod-api-base` | server base URL (default `https://www.embeddedci.com`) |
234
235
  | `BENCHPOD_BUILD_TARGET` | no `--benchpod-build-target` | platform id recorded by the `build_report` fixture |
236
+ | `BENCHPOD_WIRING` | no `wiring` argument / `--benchpod-wiring` / `benchpod_wiring` fixture | wiring profile file (`.json`/`.toml`), see [Wiring profile](#wiring-profile) |
237
+ | `BENCHPOD_LIFT_DAC_LIMITS` | pytest sessions | `1` clears the pod's DAC output limits for the session and restores the same limits at the end, even when tests fail (only with the output stage switched off) |
235
238
  | `BENCHPOD_STALL_TIMEOUT` | flashing | seconds with no SWD traffic before a flash attempt is aborted and retried (default 60) |
236
239
 
237
240
  ## API conventions and stability
@@ -261,6 +264,24 @@ automatically.)
261
264
  Plain ints work too (`efuse=1`, `swclk=11`); they are validated. The target's reset line is not an
262
265
  LA channel — it has a dedicated pin (see [Flashing](#flashing)).
263
266
 
267
+ **Feature checks.** Pods differ by board, firmware and gateware. `bp.supports("spi_master")` says
268
+ whether the pod has any `Capabilities` flag (`False` for a flag this SDK does not know), and the
269
+ properties `can_gpio`, `can_trigger`, `can_spi`, `can_profile_power`, `can_calibrate`,
270
+ `can_current_out`, `can_reset_target` and `can_analog` name the common ones (`can_config` …
271
+ `can_disable` stay the CAN bus commands). A call the pod cannot serve raises
272
+ `UnsupportedFeatureError` (see [Errors](#errors)), before anything is sent when the capabilities
273
+ already say so:
274
+
275
+ ```python
276
+ import pytest
277
+
278
+
279
+ def test_flash_spi_nor(benchpod):
280
+ if not benchpod.can_spi:
281
+ pytest.skip("this pod has no SPI master")
282
+ benchpod.spi_flash("fw.bin")
283
+ ```
284
+
264
285
  **Stability promise.** Everything in `embeddedci.benchpod.__all__` follows semantic versioning:
265
286
  within 2.x names and signatures only grow. `BenchPod.command()`, `BenchPod.transport` and
266
287
  `BenchPod.lowlevel` are escape hatches **outside** the promise. Migrating from 0.x/1.x? The
@@ -343,7 +364,7 @@ The `benchpod` fixture is a `BenchPod` instance, not the module — import const
343
364
  | `--benchpod-efuse` | — | the profile's rail | target-power rail for `benchpod_target` and `pins.efuse` (1 internal, 2 external); `benchpod_target` otherwise follows the wiring profile, `pins.efuse` is 1 |
344
365
  | `--benchpod-firmware` | — | — | firmware image for the `firmware` fixture |
345
366
  | `--benchpod-discover` | — | off | when no connection is configured, find one pod via mDNS (needs `[discovery]`) |
346
- | `--benchpod-api-key` | `BENCHPOD_API_KEY` | — | API key for the cloud destination and the waveform library |
367
+ | `--benchpod-api-key` | `BENCHPOD_API_KEY` | — | API key for the cloud destination and the waveform library; it, `--benchpod-api-base` and the lease options also reach the short connection that lifts the DAC limits (`BENCHPOD_LIFT_DAC_LIMITS=1`) |
347
368
  | `--benchpod-api-base` | `BENCHPOD_API_BASE` | `https://www.embeddedci.com` | embeddedci server |
348
369
  | `--benchpod-lease-wait` | — | `600` | seconds to wait for a busy cloud device |
349
370
  | `--benchpod-no-lease` | — | off | don't lock the cloud device (only when nothing else can use it) |
@@ -378,6 +399,13 @@ benchpod_connection = 192.168.1.213
378
399
  `benchpod` is shared by the whole session, so state a test leaves behind (a running DAC, engaged
379
400
  pull-ups, an armed sensor) carries into the next one — use the teardown fixtures.
380
401
 
402
+ **DAC output limits.** A pod can carry DAC output limits that protect an external output stage (a
403
+ solar simulator module on the 5 V path, say); it then refuses what they forbid. With the stage
404
+ switched off, run the suite with `BENCHPOD_LIFT_DAC_LIMITS=1`: the plugin clears the limits once
405
+ when the session starts and puts the same limits back at the end, even when tests fail. It opens
406
+ its own short connection, so it applies whichever fixture the suite uses. Without the variable
407
+ nothing changes.
408
+
381
409
  ### Markers
382
410
 
383
411
  * `@pytest.mark.hardware` — labels tests that need a real pod, for selection
@@ -657,7 +685,7 @@ won't engage one while the LA bank is at 1.8 V (`PullState.available` is false).
657
685
  | LA3, LA4 | pull-up | 2.2k |
658
686
  | LA5, LA6 | pull-up | 10k |
659
687
  | LA7, LA8 | pull-**down** | 10k |
660
- | LA9–LA12 | none | — |
688
+ | LA9–LA14 | none | — |
661
689
 
662
690
  ```python
663
691
  from embeddedci.benchpod import PIN1, PIN2, PIN7
@@ -1332,6 +1360,7 @@ Invalid arguments raise `ValueError` before anything is sent. Everything else ra
1332
1360
  | `PodBusyError` (a `FirmwareError`) | the pod is busy (`busy: …`), e.g. a capture or upload is running | `firmware_message`, `cmd` |
1333
1361
  | `PodLeasedError` (a `PodBusyError`, `DeviceBusyError` and `TransportError`) | a cloud job holds the pod (its refusal on the LAN, or the server's HTTP 409) | `holder`, `left_s`, `expires_at`, `status` |
1334
1362
  | `PermissionDeniedError` (a `FirmwareError` and `TransportError`) | the pod's `forbidden: …` on a cloud tunnel, or a server HTTP 403 (`ServerPermissionDeniedError`, also a `ServerApiError`, from `ServerApi`) | `status` |
1363
+ | `UnsupportedFeatureError` (a `FirmwareError`) | the pod lacks the feature: its capabilities say so (raised before anything is sent), it answers `unknown cmd`, or the digital-only board has no analog front end | `feature`, `firmware_version`, `firmware_message`, `cmd` |
1335
1364
  | `UartTimeout` | `UartSession.expect` timed out | `text` |
1336
1365
  | `UartLinkError` (a `UartTimeout` and `ConnectionClosedError`) | the UART link died with an error while `UartSession` waited | `text`, `cause` |
1337
1366
  | `PinConflictError` | an LA channel is already used by another function (a `FirmwareError`) | `la`, `function` |
@@ -1351,6 +1380,156 @@ These sit below the stable API and are **not** covered by the stability promise:
1351
1380
  `cal_switch_status()`, `dac_set(code, divider=)`. Prefer the named paths — these can leave the
1352
1381
  front end in a state no named path describes.
1353
1382
 
1383
+ ## API reference
1384
+
1385
+ Every name in `embeddedci.benchpod.__all__`, in one place. The sections above show how the pieces
1386
+ fit together; this is the index. `tests/test_docs_coverage.py` fails when a public name is missing
1387
+ from this README, so the list stays complete.
1388
+
1389
+ ### `BenchPod`
1390
+
1391
+ `BenchPod(connection=None, *, la_voltage=None, timeout=30.0, transport=None, api_base=None,
1392
+ api_key=None, cloud_token=None, cloud_audience=None, cloud_user_token=None, lease=True,
1393
+ lease_wait=600.0, lease_ttl=120, wiring=None)`. A context manager; `close()` is idempotent.
1394
+
1395
+ | Area | Methods and properties |
1396
+ |---|---|
1397
+ | Connection and device | `ping()`, `status()`, `capabilities`, `refresh_capabilities()` (drop the cached capabilities and read them again), `close()`, `leased` (true while this client holds the cloud lease), `wiring`, `save_wiring(wiring=None)` (store a cloud device's profile on embeddedci.com), `signal(name)`, `fpga_image(image)`, `usb_cc()` |
1398
+ | LA voltage | `set_la_voltage(volts)`, `get_la_voltage()` |
1399
+ | Power and reset | `power_on(efuse=None, *, delay=None)`, `power_off(efuse=None, *, delay=None)`, `target_power(efuse=None, *, on, delay=None)`, `target_status()`, `power_status()`, `measure_power(duration, *, efuse=None, rate_hz=500.0, keep_samples=0)`, `power_profile(*, efuse=None, rate_hz=500.0, keep_samples=4096, max_duration=60.0)`, `reset_target(*, pulse=0.1)`, `set_reset(asserted)`, `reset_state()` |
1400
+ | Flashing | `flash(...)`, `spi_flash(image, addr=0, *, erase=True, verify=True, hold_reset=False, sck=, mosi=, miso=, cs=)`, `open_spi(...)`, `open_motor_emulator(...)` |
1401
+ | UART | `capture_uart(...)`, `power_cycle_and_capture(...)`, `open_uart(...)` |
1402
+ | Bias resistors | `enable_pullup(*las)`, `disable_pullup(*las)`, `enable_pulldown(*las)`, `disable_pulldown(*las)` (LA7/LA8), `set_pull(la, enabled)`, `pull_state(la)`, `enabled_pulls()` |
1403
+ | GPIO and pins | `gpio(la, mode="output", *, level=None)`, `gpio_pins(las, mode="output", *, level=None)`, `configure_gpio(las, mode="output", *, level=None)`, `set_gpio(la, level)`, `read_gpio(la)`, `wait_for_level(la, level, *, timeout, poll=0.005)` (`False` when `timeout` passes first), `pin_levels()`, `la_pins()`, `release_gpio(*las)`, `la_step(la, *, steps, delay, dir_la=None, direction=0)` |
1404
+ | I2C sensor | `enable_i2c_sensor(...)`, `set_i2c_sensor(...)`, `disable_i2c_sensor()`, `i2c_sensor_status()`, `i2c_sensor_regs()`, `i2c_sensor_capture(...)` |
1405
+ | Analog | `analog_path(path)`, `adc_read(source="ext")`, `dac_output(path, *, volts=None)`, `current_out(current)`, `current_out_range()`, `calibrate(source="current_in")`, `calibration()`, `clear_calibration()` |
1406
+ | Captures | `capture_adc(...)`, `capture_la(...)`, `capture_correlated(...)`, `decode(source, protocol="i2c", ...)` |
1407
+ | DAC | `generate(...)`, `replay(...)`, `replay_waveform(...)`, `dac_stop()`, `control_loop(...)`, `loop_input(...)`, `loop_probe()` |
1408
+ | CAN | `open_can(*, bitrate=500000, mode="normal", term=False, fd=False)`, `can_config(...)`, `can_disable()`, `can_status()`, `can_term(on)`, `can_write(...)`, `can_read(...)`, `can_respond(...)`, `can_respond_clear()` |
1409
+ | Cloud library and server | `waveforms`, `save_capture_as_recording(...)`, `server_api` (the `ServerApi`; needs an API key or a cloud session) |
1410
+ | Escape hatches | `command(cmd)`, `transport`, `lowlevel` (see [Escape hatches](#escape-hatches)) |
1411
+
1412
+ ### Types
1413
+
1414
+ Results are frozen dataclasses unless noted. A result built from a firmware reply keeps that reply in
1415
+ `raw` and has a `from_reply()` classmethod (`from_dict()`, `from_json()` or `from_chunks()` for the
1416
+ few built from other shapes); `to_dict()` goes the other way where a type is sent back.
1417
+
1418
+ | Type | Returned by / used for | Fields and members |
1419
+ |---|---|---|
1420
+ | `LaVoltage` | `get_la_voltage`, `set_la_voltage` | `voltage`, `readback`, `is_set` |
1421
+ | `TargetStatus` | `target_status` | `internal`, `external` (each an `EfuseState`), `supported`, `efuse(n)` |
1422
+ | `EfuseState` | `TargetStatus.efuse` | `enabled`, `fault`, `valid` |
1423
+ | `PowerStatus` | `power_status` | `internal`, `external` (each a `RailPower`), `pod`, `total_current`, `rail(n)` |
1424
+ | `RailPower` | `PowerStatus.rail` | `ok`, `bus_voltage`, `current` |
1425
+ | `PowerProfile` | `measure_power`, `PowerProfileSession.result` | see [Power profiles](#power-profiles) |
1426
+ | `PowerProfileSession` | `power_profile` (a context manager) | `start()`, `stop()`, `status()`, `result` |
1427
+ | `ResetState` | `reset_target`, `set_reset`, `reset_state` | `asserted`, `supported` |
1428
+ | `UsbCcStatus` | `usb_cc` | `orientation`, `advertised`, `advertised_current` (A), `cc1_voltage`, `cc2_voltage` (V) |
1429
+ | `PullState` | `pull_state`, `set_pull` | `la`, `enabled`, `direction`, `ohms`, `available` |
1430
+ | `LaPinState` | `la_pins`, `configure_gpio` | `la`, `function`, `gpio`, `level`, `pull`, `pull_ohms`, `pull_on`, `in_use` |
1431
+ | `GpioPin` (class) | `gpio`, `gpio_pins`, `signal` | `name`, `configure()`, `set(level)`, `high()`, `low()`, `activate()`, `deactivate()`, `is_active()`, `read()`, `wait_for(level, *, timeout)`, `pulse(width, *, count=1)`, `state()`, `release()` |
1432
+ | `FlashResult` | `flash` | `ok`, `returncode`, `stdout`, `stderr`, `target_unreachable`, `stalled` |
1433
+ | `SpiSession` (class) | `open_spi` | `flash_id()`, `flash_read(addr, length)`, `flash_erase(addr, length)`, `flash_chip_erase()` (whole chip; seconds taken), `flash_write(addr, data)`, `flash_program(data, addr=0, *, erase=True, verify=True)` (erase, write and verify a whole image), `transfer(data)`, `stream(data, *, head=b"", hold_cs=False)`, `close()` |
1434
+ | `SpiFlashInfo` | `SpiSession.flash_id` | `jedec_id`, `present`, `size`, `status` |
1435
+ | `SpiFlashResult` | `spi_flash`, `SpiSession.flash_program` | `jedec_id`, `addr`, `length`, `erased`, `verified`, `seconds` |
1436
+ | `SpiStreamResult` | `SpiSession.stream` | `sent`, `seconds` |
1437
+ | `UartCapture` | `capture_uart`, `power_cycle_and_capture` | `text`, `lines`, `matched`, `match(pattern)`, `contains(needle)` (also `needle in capture`) |
1438
+ | `UartSession` (class) | `open_uart` (a context manager) | `write(data)`, `read(*, timeout=0.0)`, `read_until(pattern, *, timeout)`, `expect(pattern, *, timeout)`, `text`, `lines`, `closed`, `error`, `close()` |
1439
+ | `Sensor` (enum) | `enable_i2c_sensor` | `Sensor.BMP280` |
1440
+ | `I2CTransaction` | `i2c_sensor_capture`, `decode("i2c")` | `messages`, `complete`, `address`, `to(address)` |
1441
+ | `I2CMessage` | `I2CTransaction.messages` | `address`, `read`, `address_ack`, `data`, `values` |
1442
+ | `I2CByte` | `I2CMessage.data` | `value`, `ack` |
1443
+ | `UartFrame` | `decode("uart")` | `index`, `start_us`, `end_us`, `value`, `hex`, `text`, `ok`, `error` |
1444
+ | `SpiFrame` | `decode("spi")` | `index`, `start_us`, `end_us`, `mosi`, `miso`, `mosi_hex`, `miso_hex` |
1445
+ | `AnalogPathState` | `analog_path` | `path`, `dac_mux_register`, `cal_relay_register` |
1446
+ | `AdcReading` | `adc_read` | `source`, `voltage`, `count`, `span`, `offset`, `current` |
1447
+ | `DacOutput` | `dac_output` | `path`, `voltage`, `code` |
1448
+ | `CurrentOutput` | `current_out`, `current_out_range` | `current`, `code`, `min_current`, `max_current` |
1449
+ | `Calibration` | `calibrate`, `calibration`, `clear_calibration` | `source`, `calibrated`, `offset`, `a`, `b`, `count`, `span`, `samples` |
1450
+ | `Capture` | `capture_adc` | `counts`, `volts`, `currents`, `sample_rate_hz`, `source`, `trigger`, `duration`, `times()`, `mean()`, `min()`, `max()`, `rms()`, `rms_ac()`, `peak_to_peak()`, `first_crossing()`, `crossing_times()`, `fft()`, `dominant_frequency()` |
1451
+ | `LaCapture` | `capture_la` | `words`, `channels`, `sample_rate_hz`, `trigger`, `duration`, `channel(la)`, `level_at()`, `edges()`, `edge_times()`, `first_edge()`, `delay()`, `frequency()`, `duty_cycle()`, `pulse_widths()`, `decode()` |
1452
+ | `CorrelatedCapture` | `capture_correlated` | `adc` (a `Capture`), `la` (a `LaCapture`), sharing one hardware trigger |
1453
+ | `Trigger` | `capture_adc`, `capture_la`, `capture_correlated` | `la`, `edge` |
1454
+ | `DacHandle` (class) | `generate` (a context manager) | `stop()` |
1455
+ | `ReplayHandle` (class) | `replay`, `replay_waveform` (a context manager) | a `DacHandle` that also knows what it replays: `stop()` |
1456
+ | `Fault` | `replay(fault=...)` | `type`, `start`, `width`, `level`, `to_dict()` |
1457
+ | `Segment` | `WaveformLibrary.save_segments` | `shape` (`ramp`, `hold`, `step`), `duration`, `v_start`, `v_end`, `to_dict()` |
1458
+ | `ControlLoopHandle` (class) | `control_loop` (a context manager) | `probe()`, `set_input(...)`, `stop()` |
1459
+ | `IVPoint` | `loop_probe`, `ControlLoopHandle.probe` | `i`, `v`, `input_code`, `source`, `tripped`, `loop_input` |
1460
+ | `LoopInputMap` | `control_loop(input_map=...)` | `mv_per_unit`, `range_min`, `range_max`, `mv_at_zero`, `trip`, `to_request()` |
1461
+ | `LoopState` | `loop_input` | `source`, `input_code`, `step`, `output_code` |
1462
+ | `FpgaImage` (enum) | `fpga_image` | `LOOP`, `DEEP_REPLAY` |
1463
+ | `FpgaImageInfo` | `fpga_image` | `image`, `version`, `features` |
1464
+ | `CanBus` (class) | `open_can` | `write()`, `read()`, `read_until()`, `expect()`, `collect()`, `assert_periodic()`, `add_responder()`, `clear_responders()`, `simulate_ecu()`, `set_term()`, `status()`, `close()` |
1465
+ | `CanFrame` | `CanBus.read`, `CanBus.expect` | `id`, `data`, `ext`, `rtr`, `ts`, `dlc` |
1466
+ | `CanReadResult` | `can_read` | `frames`, `overflow` |
1467
+ | `MotorEmulator` (class) | `open_motor_emulator` | `configure()`, `boards()`, `probe()`, `read()`, `read_signed(reg)`, `write()`, `status()`, `trips()`, `clear_trips()`, `arm()`, `set_control()`, `set_pwm()`, `set_duties(a, b, c)`, `set_shape(shape)`, `set_protection()`, `set_battery()`, `battery_state()`, `set_pv_calibration(offset, gain)`, `sample()`, `time()`, `start_log()`, `stop_log()`, `read_log()`, `eeprom_read(addr, length)`, `eeprom_write(addr, data)`, `close()` |
1468
+ | `BatteryModel` | `MotorEmulator.set_battery` | `capacity_ah`, `ocv`, `r0_ohm`, `r1_ohm`, `tau_s`, `soc`, `v_min`, `v_max`, `invert_current` |
1469
+ | `EmulatorCalibration` | `open_motor_emulator(calibration=...)` | `codes_per_amp`, `codes_per_volt`, `offsets`, `sample_rate_hz`, `amps()`, `volts()`, `current_code()`, `bus_code()`, `resistance_code()` |
1470
+ | `Wiring` | `wiring`, `save_wiring` | see [Wiring profile](#wiring-profile); also `defaults()`, `load(path)`, `coerce(value)` (a `Wiring`, dict or file path), `from_dict(data, *, strict=True)` (`strict=False` keeps unknown keys in `extra`), `to_dict()`, `with_changes(**changes)`, `assignments()`, `pins()`, `la(name)`, `signal(name)`, `describe()`, `warnings()`, `la_voltage`, `i2c_address` |
1471
+ | `Signal` | `Wiring.signals` | `name`, `la`, `direction`, `active_low`, `description`, `to_dict()` |
1472
+ | `Waveform` | `WaveformLibrary` | `id`, `name`, `kind`, `sample_count`, `sample_rate_hz`, `bits`, `full_scale_v`, `recording_size_bytes`, `created_at`, `dac_path`, `segments`, `unit`, `is_recording` |
1473
+ | `WaveformLibrary` (class) | `waveforms` | `list()`, `get()`, `find()`, `save_recording()`, `save_waveform()`, `save_segments()`, `rename()`, `delete()`, `preview()`, `download_recording()`, `samples_b64(waveform_id)` |
1474
+ | `BuildReporter`, `NoopBuildReporter` (classes) | `build_report`, `make_build_reporter` | `active`, `build_id`, `record_wiring()`, `upload_artifact()`, `upload_artifacts()`, `upload_logs()`, `set_result()`, `finalize()` |
1475
+ | `DeviceLease` (class) | a cloud `BenchPod` holds one | `acquire(*, wait_timeout=600.0, poll_interval=5.0)`, `held`, `lease_id`, `release()` |
1476
+ | `ConnSpec` | `parse_connection`, `resolve_connection` | `kind`, `addr`, `device`, `device_name`, `is_wifi()`, `is_serial()`, `is_cloud()` |
1477
+ | `LowLevel` (class) | `lowlevel` | see [Escape hatches](#escape-hatches) |
1478
+ | `Pin` / `Efuse` (enums) | channel and rail arguments | `PIN1` … `PIN14`, `INTERNAL`, `EXTERNAL` |
1479
+
1480
+ ### `Capabilities`
1481
+
1482
+ `bp.capabilities` (and the `benchpod_capabilities` fixture) parses the pod's `status` reply
1483
+ (`Capabilities.from_status`) or the server's `cap.*` map for a cloud device
1484
+ (`Capabilities.from_parameters`); `merge(other)` overlays one on another. A flag that was not
1485
+ reported keeps its default (`False`, `0` or `""`), so a feature check is just
1486
+ `if bp.capabilities.spi_master:`. `@pytest.mark.benchpod_capability("name")` takes the same names.
1487
+
1488
+ | Group | Fields |
1489
+ |---|---|
1490
+ | Identity | `board`, `firmware_version`, `board_rev` (`"v2"`, `"v3"`, `"unknown"`, or `""`), `la_vccio_mv` (the LA bank voltage the pod reports) |
1491
+ | ADC | `adc_bits`, `adc_fullscale_mv`, `adc_channels`, `adc_offset_counts`, `adc_affine` (the front-end fit), `adc_max_count`, `counts_to_volts(count)` |
1492
+ | DAC and replay | `dac`, `dac_dc`, `dac_replay`, `dac_deep_replay`, `dac_control_loop`, `dac_loop_sources` (gateware v29+), `dac_loop_input_map` (v30+), `dac_cotrig` (v27+), `dac_bits`, `dac_replay_bits`, `dac_replay_max_samples`, `dac_fullscale_mv`, `dac_channels`, `dac_limits` (output limits on the DAC paths) |
1493
+ | Features | `scope`, `analyzer`, `serial`, `tunnel`, `command`, `ota`, `la_pins`, `gpio_read` (direct pin reads, v35+), `capture_trigger` (v35+), `power_profile`, `capture_b64` (faster ADC read-back, used automatically), `nrst_pin` (the dedicated reset pin, rev3), `usb_cc` (rev3), `spi_master` (v45+), `spi_stream`, `calibrate`, `current_out`, `can`, `pod_current` (`PowerStatus.pod`), `analog` (`False` on the digital-only board, `None` when not announced) |
1494
+ | Firmware updates and policies | `flash_kb` (MCU flash in KiB, 0 when not reported), `blob_slots`, `ota_sig`, `sig_policy` (`"audit"`, `"required"`, ...), `sig_keys`, `sig_policy_cmd`, `lan_policy` (`"open"`, `"locked"`, `"off"`), `lan_policy_cmd`, `tunnel_max_tier`, `lease_state` (the pod refuses LAN writes while a cloud job holds it), `cloud_ca`, `cloud_proxy`, `ws_auth_v2` (server only) |
1495
+ | Boot health | `safe_mode`, `safe_reason`, `last_crash`, `reset_cause`, `boot_warning()` (a one-line warning after safe mode or a crash, else `None`) |
1496
+ | Source | `raw` (the map it was parsed from) |
1497
+
1498
+ ### `ServerApi`
1499
+
1500
+ A thin client for the embeddedci server's HTTP API (`{api_base}/api/...`), for scripts that need
1501
+ the server rather than a pod. `bp.server_api` returns one bound to the connection's credentials;
1502
+ build one yourself with `ServerApi(*, api_base=None, api_key=None, token_provider=None,
1503
+ timeout=60.0, lease_id=None)`. Errors raise `ServerApiError` (`status`), and an HTTP 403 raises
1504
+ `ServerPermissionDeniedError`.
1505
+
1506
+ | Method | Purpose |
1507
+ |---|---|
1508
+ | `list_devices()` | the pods the credentials can see |
1509
+ | `resolve_device_id(name)` | a device name to its server id (raises when unknown) |
1510
+ | `device_parameters(name_or_id)` | the server's `cap.*` map for a device (`Capabilities.from_parameters` parses it) |
1511
+ | `wiring_profile(device_id)` | the stored wiring profile: `{"profile", "stored", "defaults", "warnings"}` |
1512
+ | `put_wiring(device_id, wiring)` | store a wiring profile (the server validates it; errors name the fields) |
1513
+ | `scope_capture_start(device_id, *, samples=256, sample_rate_mhz=1.0)` | start a server-side ADC capture; returns its id |
1514
+ | `dual_capture_start(device_id, *, adc_samples, adc_rate_mhz, la_samples, la_rate_mhz)` | start a server-side ADC + LA capture; returns its id |
1515
+ | `capture_snapshot(capture_id)` | fetch a server-side capture |
1516
+ | `replay_start(payload)` | arm a server-side DAC replay (`POST /dac/replay/start`) |
1517
+ | `request(method, path, *, query=None, json_body=None, raw_body=None, content_type=None, parse_json=True)` | any other endpoint; returns `(status, body)` |
1518
+
1519
+ ### Functions, constants and modules
1520
+
1521
+ | Name | Purpose |
1522
+ |---|---|
1523
+ | `parse_connection(raw)` | parse a connection string into a `ConnSpec` (raises `ConnectionConfigError`) |
1524
+ | `resolve_connection(connection=None)` | the same, falling back to `BENCHPOD_CONNECTION` |
1525
+ | `make_build_reporter(*, api_base=None, audience=None, target="", name="")` | a `BuildReporter` inside GitHub Actions, else a `NoopBuildReporter` |
1526
+ | `build_panel_curve(voc_code, sharpness=4.0, points=256)`, `build_linear_curve(max_code, rising=True, points=256)`, `build_constant_curve(value_code, points=256)` | control-loop curves (see [In-fabric DAC control loop](#in-fabric-dac-control-loop)) |
1527
+ | `curve_output_at(curve, input_code)`, `input_percent_to_code(percent)` | evaluate a curve the way the gateware does; an input percentage as a code |
1528
+ | `encode_curve_b64url(codes)` | 16-bit DAC codes as little-endian base64url, the wire form of a curve |
1529
+ | `INTERNAL`, `EXTERNAL`, `PIN1` … `PIN14`, `BMP280_ADDR_PRIMARY`, `BMP280_ADDR_SECONDARY` | named constants (see [API conventions](#api-conventions-and-stability)) |
1530
+ | `DacPath`, `DacOutputPath`, `AnalogPath`, `AdcSource`, `CalibrateSource` (`"current_in"`), `LoopSource`, `Waveshape`, `ReplayMapping`, `DecodeProtocol`, `CanMode`, `FaultType`, `GpioMode`, `Edge`, `TriggerEdge` | the `Literal` string types |
1531
+ | `decode`, `i2c`, `can`, `control_loop`, `motor_emulator`, `dsp` | submodules: off-device decoders, I2C helpers, CAN types, curve helpers, the emulator's registers, and `dsp`, a pure-Python mirror of the server's record-to-replay processing |
1532
+
1354
1533
  ## Examples
1355
1534
 
1356
1535
  * [`examples/test_bmp280.py`](https://github.com/embeddedci-com/embeddedci-python/blob/main/packages/embeddedci/examples/test_bmp280.py)
@@ -35,7 +35,8 @@ No pin numbers: the SWD channels, the UART pair, the baud and the power rail com
35
35
  [Analog paths](#analog-paths-dc-output-and-single-readings) · [Captures](#captures) ·
36
36
  [DAC](#dac-generator-replay-and-faults) · [Control loop](#in-fabric-dac-control-loop) ·
37
37
  [CAN](#can) · [Cloud](#cloud-embeddedcidevice-name) · [Build reporting](#build-reporting) ·
38
- [Errors](#errors) · [Escape hatches](#escape-hatches) · [Releasing](#releasing-maintainers)
38
+ [Errors](#errors) · [Escape hatches](#escape-hatches) · [API reference](#api-reference) ·
39
+ [Releasing](#releasing-maintainers)
39
40
 
40
41
  ## Install
41
42
 
@@ -194,6 +195,8 @@ automatically.)
194
195
  | `BENCHPOD_API_KEY` | no `api_key` argument / `--benchpod-api-key` | EmbeddedCI API key (`eci_…`) for the cloud destination and server features |
195
196
  | `BENCHPOD_API_BASE` | no `api_base` argument / `--benchpod-api-base` | server base URL (default `https://www.embeddedci.com`) |
196
197
  | `BENCHPOD_BUILD_TARGET` | no `--benchpod-build-target` | platform id recorded by the `build_report` fixture |
198
+ | `BENCHPOD_WIRING` | no `wiring` argument / `--benchpod-wiring` / `benchpod_wiring` fixture | wiring profile file (`.json`/`.toml`), see [Wiring profile](#wiring-profile) |
199
+ | `BENCHPOD_LIFT_DAC_LIMITS` | pytest sessions | `1` clears the pod's DAC output limits for the session and restores the same limits at the end, even when tests fail (only with the output stage switched off) |
197
200
  | `BENCHPOD_STALL_TIMEOUT` | flashing | seconds with no SWD traffic before a flash attempt is aborted and retried (default 60) |
198
201
 
199
202
  ## API conventions and stability
@@ -223,6 +226,24 @@ automatically.)
223
226
  Plain ints work too (`efuse=1`, `swclk=11`); they are validated. The target's reset line is not an
224
227
  LA channel — it has a dedicated pin (see [Flashing](#flashing)).
225
228
 
229
+ **Feature checks.** Pods differ by board, firmware and gateware. `bp.supports("spi_master")` says
230
+ whether the pod has any `Capabilities` flag (`False` for a flag this SDK does not know), and the
231
+ properties `can_gpio`, `can_trigger`, `can_spi`, `can_profile_power`, `can_calibrate`,
232
+ `can_current_out`, `can_reset_target` and `can_analog` name the common ones (`can_config` …
233
+ `can_disable` stay the CAN bus commands). A call the pod cannot serve raises
234
+ `UnsupportedFeatureError` (see [Errors](#errors)), before anything is sent when the capabilities
235
+ already say so:
236
+
237
+ ```python
238
+ import pytest
239
+
240
+
241
+ def test_flash_spi_nor(benchpod):
242
+ if not benchpod.can_spi:
243
+ pytest.skip("this pod has no SPI master")
244
+ benchpod.spi_flash("fw.bin")
245
+ ```
246
+
226
247
  **Stability promise.** Everything in `embeddedci.benchpod.__all__` follows semantic versioning:
227
248
  within 2.x names and signatures only grow. `BenchPod.command()`, `BenchPod.transport` and
228
249
  `BenchPod.lowlevel` are escape hatches **outside** the promise. Migrating from 0.x/1.x? The
@@ -305,7 +326,7 @@ The `benchpod` fixture is a `BenchPod` instance, not the module — import const
305
326
  | `--benchpod-efuse` | — | the profile's rail | target-power rail for `benchpod_target` and `pins.efuse` (1 internal, 2 external); `benchpod_target` otherwise follows the wiring profile, `pins.efuse` is 1 |
306
327
  | `--benchpod-firmware` | — | — | firmware image for the `firmware` fixture |
307
328
  | `--benchpod-discover` | — | off | when no connection is configured, find one pod via mDNS (needs `[discovery]`) |
308
- | `--benchpod-api-key` | `BENCHPOD_API_KEY` | — | API key for the cloud destination and the waveform library |
329
+ | `--benchpod-api-key` | `BENCHPOD_API_KEY` | — | API key for the cloud destination and the waveform library; it, `--benchpod-api-base` and the lease options also reach the short connection that lifts the DAC limits (`BENCHPOD_LIFT_DAC_LIMITS=1`) |
309
330
  | `--benchpod-api-base` | `BENCHPOD_API_BASE` | `https://www.embeddedci.com` | embeddedci server |
310
331
  | `--benchpod-lease-wait` | — | `600` | seconds to wait for a busy cloud device |
311
332
  | `--benchpod-no-lease` | — | off | don't lock the cloud device (only when nothing else can use it) |
@@ -340,6 +361,13 @@ benchpod_connection = 192.168.1.213
340
361
  `benchpod` is shared by the whole session, so state a test leaves behind (a running DAC, engaged
341
362
  pull-ups, an armed sensor) carries into the next one — use the teardown fixtures.
342
363
 
364
+ **DAC output limits.** A pod can carry DAC output limits that protect an external output stage (a
365
+ solar simulator module on the 5 V path, say); it then refuses what they forbid. With the stage
366
+ switched off, run the suite with `BENCHPOD_LIFT_DAC_LIMITS=1`: the plugin clears the limits once
367
+ when the session starts and puts the same limits back at the end, even when tests fail. It opens
368
+ its own short connection, so it applies whichever fixture the suite uses. Without the variable
369
+ nothing changes.
370
+
343
371
  ### Markers
344
372
 
345
373
  * `@pytest.mark.hardware` — labels tests that need a real pod, for selection
@@ -619,7 +647,7 @@ won't engage one while the LA bank is at 1.8 V (`PullState.available` is false).
619
647
  | LA3, LA4 | pull-up | 2.2k |
620
648
  | LA5, LA6 | pull-up | 10k |
621
649
  | LA7, LA8 | pull-**down** | 10k |
622
- | LA9–LA12 | none | — |
650
+ | LA9–LA14 | none | — |
623
651
 
624
652
  ```python
625
653
  from embeddedci.benchpod import PIN1, PIN2, PIN7
@@ -1294,6 +1322,7 @@ Invalid arguments raise `ValueError` before anything is sent. Everything else ra
1294
1322
  | `PodBusyError` (a `FirmwareError`) | the pod is busy (`busy: …`), e.g. a capture or upload is running | `firmware_message`, `cmd` |
1295
1323
  | `PodLeasedError` (a `PodBusyError`, `DeviceBusyError` and `TransportError`) | a cloud job holds the pod (its refusal on the LAN, or the server's HTTP 409) | `holder`, `left_s`, `expires_at`, `status` |
1296
1324
  | `PermissionDeniedError` (a `FirmwareError` and `TransportError`) | the pod's `forbidden: …` on a cloud tunnel, or a server HTTP 403 (`ServerPermissionDeniedError`, also a `ServerApiError`, from `ServerApi`) | `status` |
1325
+ | `UnsupportedFeatureError` (a `FirmwareError`) | the pod lacks the feature: its capabilities say so (raised before anything is sent), it answers `unknown cmd`, or the digital-only board has no analog front end | `feature`, `firmware_version`, `firmware_message`, `cmd` |
1297
1326
  | `UartTimeout` | `UartSession.expect` timed out | `text` |
1298
1327
  | `UartLinkError` (a `UartTimeout` and `ConnectionClosedError`) | the UART link died with an error while `UartSession` waited | `text`, `cause` |
1299
1328
  | `PinConflictError` | an LA channel is already used by another function (a `FirmwareError`) | `la`, `function` |
@@ -1313,6 +1342,156 @@ These sit below the stable API and are **not** covered by the stability promise:
1313
1342
  `cal_switch_status()`, `dac_set(code, divider=)`. Prefer the named paths — these can leave the
1314
1343
  front end in a state no named path describes.
1315
1344
 
1345
+ ## API reference
1346
+
1347
+ Every name in `embeddedci.benchpod.__all__`, in one place. The sections above show how the pieces
1348
+ fit together; this is the index. `tests/test_docs_coverage.py` fails when a public name is missing
1349
+ from this README, so the list stays complete.
1350
+
1351
+ ### `BenchPod`
1352
+
1353
+ `BenchPod(connection=None, *, la_voltage=None, timeout=30.0, transport=None, api_base=None,
1354
+ api_key=None, cloud_token=None, cloud_audience=None, cloud_user_token=None, lease=True,
1355
+ lease_wait=600.0, lease_ttl=120, wiring=None)`. A context manager; `close()` is idempotent.
1356
+
1357
+ | Area | Methods and properties |
1358
+ |---|---|
1359
+ | Connection and device | `ping()`, `status()`, `capabilities`, `refresh_capabilities()` (drop the cached capabilities and read them again), `close()`, `leased` (true while this client holds the cloud lease), `wiring`, `save_wiring(wiring=None)` (store a cloud device's profile on embeddedci.com), `signal(name)`, `fpga_image(image)`, `usb_cc()` |
1360
+ | LA voltage | `set_la_voltage(volts)`, `get_la_voltage()` |
1361
+ | Power and reset | `power_on(efuse=None, *, delay=None)`, `power_off(efuse=None, *, delay=None)`, `target_power(efuse=None, *, on, delay=None)`, `target_status()`, `power_status()`, `measure_power(duration, *, efuse=None, rate_hz=500.0, keep_samples=0)`, `power_profile(*, efuse=None, rate_hz=500.0, keep_samples=4096, max_duration=60.0)`, `reset_target(*, pulse=0.1)`, `set_reset(asserted)`, `reset_state()` |
1362
+ | Flashing | `flash(...)`, `spi_flash(image, addr=0, *, erase=True, verify=True, hold_reset=False, sck=, mosi=, miso=, cs=)`, `open_spi(...)`, `open_motor_emulator(...)` |
1363
+ | UART | `capture_uart(...)`, `power_cycle_and_capture(...)`, `open_uart(...)` |
1364
+ | Bias resistors | `enable_pullup(*las)`, `disable_pullup(*las)`, `enable_pulldown(*las)`, `disable_pulldown(*las)` (LA7/LA8), `set_pull(la, enabled)`, `pull_state(la)`, `enabled_pulls()` |
1365
+ | GPIO and pins | `gpio(la, mode="output", *, level=None)`, `gpio_pins(las, mode="output", *, level=None)`, `configure_gpio(las, mode="output", *, level=None)`, `set_gpio(la, level)`, `read_gpio(la)`, `wait_for_level(la, level, *, timeout, poll=0.005)` (`False` when `timeout` passes first), `pin_levels()`, `la_pins()`, `release_gpio(*las)`, `la_step(la, *, steps, delay, dir_la=None, direction=0)` |
1366
+ | I2C sensor | `enable_i2c_sensor(...)`, `set_i2c_sensor(...)`, `disable_i2c_sensor()`, `i2c_sensor_status()`, `i2c_sensor_regs()`, `i2c_sensor_capture(...)` |
1367
+ | Analog | `analog_path(path)`, `adc_read(source="ext")`, `dac_output(path, *, volts=None)`, `current_out(current)`, `current_out_range()`, `calibrate(source="current_in")`, `calibration()`, `clear_calibration()` |
1368
+ | Captures | `capture_adc(...)`, `capture_la(...)`, `capture_correlated(...)`, `decode(source, protocol="i2c", ...)` |
1369
+ | DAC | `generate(...)`, `replay(...)`, `replay_waveform(...)`, `dac_stop()`, `control_loop(...)`, `loop_input(...)`, `loop_probe()` |
1370
+ | CAN | `open_can(*, bitrate=500000, mode="normal", term=False, fd=False)`, `can_config(...)`, `can_disable()`, `can_status()`, `can_term(on)`, `can_write(...)`, `can_read(...)`, `can_respond(...)`, `can_respond_clear()` |
1371
+ | Cloud library and server | `waveforms`, `save_capture_as_recording(...)`, `server_api` (the `ServerApi`; needs an API key or a cloud session) |
1372
+ | Escape hatches | `command(cmd)`, `transport`, `lowlevel` (see [Escape hatches](#escape-hatches)) |
1373
+
1374
+ ### Types
1375
+
1376
+ Results are frozen dataclasses unless noted. A result built from a firmware reply keeps that reply in
1377
+ `raw` and has a `from_reply()` classmethod (`from_dict()`, `from_json()` or `from_chunks()` for the
1378
+ few built from other shapes); `to_dict()` goes the other way where a type is sent back.
1379
+
1380
+ | Type | Returned by / used for | Fields and members |
1381
+ |---|---|---|
1382
+ | `LaVoltage` | `get_la_voltage`, `set_la_voltage` | `voltage`, `readback`, `is_set` |
1383
+ | `TargetStatus` | `target_status` | `internal`, `external` (each an `EfuseState`), `supported`, `efuse(n)` |
1384
+ | `EfuseState` | `TargetStatus.efuse` | `enabled`, `fault`, `valid` |
1385
+ | `PowerStatus` | `power_status` | `internal`, `external` (each a `RailPower`), `pod`, `total_current`, `rail(n)` |
1386
+ | `RailPower` | `PowerStatus.rail` | `ok`, `bus_voltage`, `current` |
1387
+ | `PowerProfile` | `measure_power`, `PowerProfileSession.result` | see [Power profiles](#power-profiles) |
1388
+ | `PowerProfileSession` | `power_profile` (a context manager) | `start()`, `stop()`, `status()`, `result` |
1389
+ | `ResetState` | `reset_target`, `set_reset`, `reset_state` | `asserted`, `supported` |
1390
+ | `UsbCcStatus` | `usb_cc` | `orientation`, `advertised`, `advertised_current` (A), `cc1_voltage`, `cc2_voltage` (V) |
1391
+ | `PullState` | `pull_state`, `set_pull` | `la`, `enabled`, `direction`, `ohms`, `available` |
1392
+ | `LaPinState` | `la_pins`, `configure_gpio` | `la`, `function`, `gpio`, `level`, `pull`, `pull_ohms`, `pull_on`, `in_use` |
1393
+ | `GpioPin` (class) | `gpio`, `gpio_pins`, `signal` | `name`, `configure()`, `set(level)`, `high()`, `low()`, `activate()`, `deactivate()`, `is_active()`, `read()`, `wait_for(level, *, timeout)`, `pulse(width, *, count=1)`, `state()`, `release()` |
1394
+ | `FlashResult` | `flash` | `ok`, `returncode`, `stdout`, `stderr`, `target_unreachable`, `stalled` |
1395
+ | `SpiSession` (class) | `open_spi` | `flash_id()`, `flash_read(addr, length)`, `flash_erase(addr, length)`, `flash_chip_erase()` (whole chip; seconds taken), `flash_write(addr, data)`, `flash_program(data, addr=0, *, erase=True, verify=True)` (erase, write and verify a whole image), `transfer(data)`, `stream(data, *, head=b"", hold_cs=False)`, `close()` |
1396
+ | `SpiFlashInfo` | `SpiSession.flash_id` | `jedec_id`, `present`, `size`, `status` |
1397
+ | `SpiFlashResult` | `spi_flash`, `SpiSession.flash_program` | `jedec_id`, `addr`, `length`, `erased`, `verified`, `seconds` |
1398
+ | `SpiStreamResult` | `SpiSession.stream` | `sent`, `seconds` |
1399
+ | `UartCapture` | `capture_uart`, `power_cycle_and_capture` | `text`, `lines`, `matched`, `match(pattern)`, `contains(needle)` (also `needle in capture`) |
1400
+ | `UartSession` (class) | `open_uart` (a context manager) | `write(data)`, `read(*, timeout=0.0)`, `read_until(pattern, *, timeout)`, `expect(pattern, *, timeout)`, `text`, `lines`, `closed`, `error`, `close()` |
1401
+ | `Sensor` (enum) | `enable_i2c_sensor` | `Sensor.BMP280` |
1402
+ | `I2CTransaction` | `i2c_sensor_capture`, `decode("i2c")` | `messages`, `complete`, `address`, `to(address)` |
1403
+ | `I2CMessage` | `I2CTransaction.messages` | `address`, `read`, `address_ack`, `data`, `values` |
1404
+ | `I2CByte` | `I2CMessage.data` | `value`, `ack` |
1405
+ | `UartFrame` | `decode("uart")` | `index`, `start_us`, `end_us`, `value`, `hex`, `text`, `ok`, `error` |
1406
+ | `SpiFrame` | `decode("spi")` | `index`, `start_us`, `end_us`, `mosi`, `miso`, `mosi_hex`, `miso_hex` |
1407
+ | `AnalogPathState` | `analog_path` | `path`, `dac_mux_register`, `cal_relay_register` |
1408
+ | `AdcReading` | `adc_read` | `source`, `voltage`, `count`, `span`, `offset`, `current` |
1409
+ | `DacOutput` | `dac_output` | `path`, `voltage`, `code` |
1410
+ | `CurrentOutput` | `current_out`, `current_out_range` | `current`, `code`, `min_current`, `max_current` |
1411
+ | `Calibration` | `calibrate`, `calibration`, `clear_calibration` | `source`, `calibrated`, `offset`, `a`, `b`, `count`, `span`, `samples` |
1412
+ | `Capture` | `capture_adc` | `counts`, `volts`, `currents`, `sample_rate_hz`, `source`, `trigger`, `duration`, `times()`, `mean()`, `min()`, `max()`, `rms()`, `rms_ac()`, `peak_to_peak()`, `first_crossing()`, `crossing_times()`, `fft()`, `dominant_frequency()` |
1413
+ | `LaCapture` | `capture_la` | `words`, `channels`, `sample_rate_hz`, `trigger`, `duration`, `channel(la)`, `level_at()`, `edges()`, `edge_times()`, `first_edge()`, `delay()`, `frequency()`, `duty_cycle()`, `pulse_widths()`, `decode()` |
1414
+ | `CorrelatedCapture` | `capture_correlated` | `adc` (a `Capture`), `la` (a `LaCapture`), sharing one hardware trigger |
1415
+ | `Trigger` | `capture_adc`, `capture_la`, `capture_correlated` | `la`, `edge` |
1416
+ | `DacHandle` (class) | `generate` (a context manager) | `stop()` |
1417
+ | `ReplayHandle` (class) | `replay`, `replay_waveform` (a context manager) | a `DacHandle` that also knows what it replays: `stop()` |
1418
+ | `Fault` | `replay(fault=...)` | `type`, `start`, `width`, `level`, `to_dict()` |
1419
+ | `Segment` | `WaveformLibrary.save_segments` | `shape` (`ramp`, `hold`, `step`), `duration`, `v_start`, `v_end`, `to_dict()` |
1420
+ | `ControlLoopHandle` (class) | `control_loop` (a context manager) | `probe()`, `set_input(...)`, `stop()` |
1421
+ | `IVPoint` | `loop_probe`, `ControlLoopHandle.probe` | `i`, `v`, `input_code`, `source`, `tripped`, `loop_input` |
1422
+ | `LoopInputMap` | `control_loop(input_map=...)` | `mv_per_unit`, `range_min`, `range_max`, `mv_at_zero`, `trip`, `to_request()` |
1423
+ | `LoopState` | `loop_input` | `source`, `input_code`, `step`, `output_code` |
1424
+ | `FpgaImage` (enum) | `fpga_image` | `LOOP`, `DEEP_REPLAY` |
1425
+ | `FpgaImageInfo` | `fpga_image` | `image`, `version`, `features` |
1426
+ | `CanBus` (class) | `open_can` | `write()`, `read()`, `read_until()`, `expect()`, `collect()`, `assert_periodic()`, `add_responder()`, `clear_responders()`, `simulate_ecu()`, `set_term()`, `status()`, `close()` |
1427
+ | `CanFrame` | `CanBus.read`, `CanBus.expect` | `id`, `data`, `ext`, `rtr`, `ts`, `dlc` |
1428
+ | `CanReadResult` | `can_read` | `frames`, `overflow` |
1429
+ | `MotorEmulator` (class) | `open_motor_emulator` | `configure()`, `boards()`, `probe()`, `read()`, `read_signed(reg)`, `write()`, `status()`, `trips()`, `clear_trips()`, `arm()`, `set_control()`, `set_pwm()`, `set_duties(a, b, c)`, `set_shape(shape)`, `set_protection()`, `set_battery()`, `battery_state()`, `set_pv_calibration(offset, gain)`, `sample()`, `time()`, `start_log()`, `stop_log()`, `read_log()`, `eeprom_read(addr, length)`, `eeprom_write(addr, data)`, `close()` |
1430
+ | `BatteryModel` | `MotorEmulator.set_battery` | `capacity_ah`, `ocv`, `r0_ohm`, `r1_ohm`, `tau_s`, `soc`, `v_min`, `v_max`, `invert_current` |
1431
+ | `EmulatorCalibration` | `open_motor_emulator(calibration=...)` | `codes_per_amp`, `codes_per_volt`, `offsets`, `sample_rate_hz`, `amps()`, `volts()`, `current_code()`, `bus_code()`, `resistance_code()` |
1432
+ | `Wiring` | `wiring`, `save_wiring` | see [Wiring profile](#wiring-profile); also `defaults()`, `load(path)`, `coerce(value)` (a `Wiring`, dict or file path), `from_dict(data, *, strict=True)` (`strict=False` keeps unknown keys in `extra`), `to_dict()`, `with_changes(**changes)`, `assignments()`, `pins()`, `la(name)`, `signal(name)`, `describe()`, `warnings()`, `la_voltage`, `i2c_address` |
1433
+ | `Signal` | `Wiring.signals` | `name`, `la`, `direction`, `active_low`, `description`, `to_dict()` |
1434
+ | `Waveform` | `WaveformLibrary` | `id`, `name`, `kind`, `sample_count`, `sample_rate_hz`, `bits`, `full_scale_v`, `recording_size_bytes`, `created_at`, `dac_path`, `segments`, `unit`, `is_recording` |
1435
+ | `WaveformLibrary` (class) | `waveforms` | `list()`, `get()`, `find()`, `save_recording()`, `save_waveform()`, `save_segments()`, `rename()`, `delete()`, `preview()`, `download_recording()`, `samples_b64(waveform_id)` |
1436
+ | `BuildReporter`, `NoopBuildReporter` (classes) | `build_report`, `make_build_reporter` | `active`, `build_id`, `record_wiring()`, `upload_artifact()`, `upload_artifacts()`, `upload_logs()`, `set_result()`, `finalize()` |
1437
+ | `DeviceLease` (class) | a cloud `BenchPod` holds one | `acquire(*, wait_timeout=600.0, poll_interval=5.0)`, `held`, `lease_id`, `release()` |
1438
+ | `ConnSpec` | `parse_connection`, `resolve_connection` | `kind`, `addr`, `device`, `device_name`, `is_wifi()`, `is_serial()`, `is_cloud()` |
1439
+ | `LowLevel` (class) | `lowlevel` | see [Escape hatches](#escape-hatches) |
1440
+ | `Pin` / `Efuse` (enums) | channel and rail arguments | `PIN1` … `PIN14`, `INTERNAL`, `EXTERNAL` |
1441
+
1442
+ ### `Capabilities`
1443
+
1444
+ `bp.capabilities` (and the `benchpod_capabilities` fixture) parses the pod's `status` reply
1445
+ (`Capabilities.from_status`) or the server's `cap.*` map for a cloud device
1446
+ (`Capabilities.from_parameters`); `merge(other)` overlays one on another. A flag that was not
1447
+ reported keeps its default (`False`, `0` or `""`), so a feature check is just
1448
+ `if bp.capabilities.spi_master:`. `@pytest.mark.benchpod_capability("name")` takes the same names.
1449
+
1450
+ | Group | Fields |
1451
+ |---|---|
1452
+ | Identity | `board`, `firmware_version`, `board_rev` (`"v2"`, `"v3"`, `"unknown"`, or `""`), `la_vccio_mv` (the LA bank voltage the pod reports) |
1453
+ | ADC | `adc_bits`, `adc_fullscale_mv`, `adc_channels`, `adc_offset_counts`, `adc_affine` (the front-end fit), `adc_max_count`, `counts_to_volts(count)` |
1454
+ | DAC and replay | `dac`, `dac_dc`, `dac_replay`, `dac_deep_replay`, `dac_control_loop`, `dac_loop_sources` (gateware v29+), `dac_loop_input_map` (v30+), `dac_cotrig` (v27+), `dac_bits`, `dac_replay_bits`, `dac_replay_max_samples`, `dac_fullscale_mv`, `dac_channels`, `dac_limits` (output limits on the DAC paths) |
1455
+ | Features | `scope`, `analyzer`, `serial`, `tunnel`, `command`, `ota`, `la_pins`, `gpio_read` (direct pin reads, v35+), `capture_trigger` (v35+), `power_profile`, `capture_b64` (faster ADC read-back, used automatically), `nrst_pin` (the dedicated reset pin, rev3), `usb_cc` (rev3), `spi_master` (v45+), `spi_stream`, `calibrate`, `current_out`, `can`, `pod_current` (`PowerStatus.pod`), `analog` (`False` on the digital-only board, `None` when not announced) |
1456
+ | Firmware updates and policies | `flash_kb` (MCU flash in KiB, 0 when not reported), `blob_slots`, `ota_sig`, `sig_policy` (`"audit"`, `"required"`, ...), `sig_keys`, `sig_policy_cmd`, `lan_policy` (`"open"`, `"locked"`, `"off"`), `lan_policy_cmd`, `tunnel_max_tier`, `lease_state` (the pod refuses LAN writes while a cloud job holds it), `cloud_ca`, `cloud_proxy`, `ws_auth_v2` (server only) |
1457
+ | Boot health | `safe_mode`, `safe_reason`, `last_crash`, `reset_cause`, `boot_warning()` (a one-line warning after safe mode or a crash, else `None`) |
1458
+ | Source | `raw` (the map it was parsed from) |
1459
+
1460
+ ### `ServerApi`
1461
+
1462
+ A thin client for the embeddedci server's HTTP API (`{api_base}/api/...`), for scripts that need
1463
+ the server rather than a pod. `bp.server_api` returns one bound to the connection's credentials;
1464
+ build one yourself with `ServerApi(*, api_base=None, api_key=None, token_provider=None,
1465
+ timeout=60.0, lease_id=None)`. Errors raise `ServerApiError` (`status`), and an HTTP 403 raises
1466
+ `ServerPermissionDeniedError`.
1467
+
1468
+ | Method | Purpose |
1469
+ |---|---|
1470
+ | `list_devices()` | the pods the credentials can see |
1471
+ | `resolve_device_id(name)` | a device name to its server id (raises when unknown) |
1472
+ | `device_parameters(name_or_id)` | the server's `cap.*` map for a device (`Capabilities.from_parameters` parses it) |
1473
+ | `wiring_profile(device_id)` | the stored wiring profile: `{"profile", "stored", "defaults", "warnings"}` |
1474
+ | `put_wiring(device_id, wiring)` | store a wiring profile (the server validates it; errors name the fields) |
1475
+ | `scope_capture_start(device_id, *, samples=256, sample_rate_mhz=1.0)` | start a server-side ADC capture; returns its id |
1476
+ | `dual_capture_start(device_id, *, adc_samples, adc_rate_mhz, la_samples, la_rate_mhz)` | start a server-side ADC + LA capture; returns its id |
1477
+ | `capture_snapshot(capture_id)` | fetch a server-side capture |
1478
+ | `replay_start(payload)` | arm a server-side DAC replay (`POST /dac/replay/start`) |
1479
+ | `request(method, path, *, query=None, json_body=None, raw_body=None, content_type=None, parse_json=True)` | any other endpoint; returns `(status, body)` |
1480
+
1481
+ ### Functions, constants and modules
1482
+
1483
+ | Name | Purpose |
1484
+ |---|---|
1485
+ | `parse_connection(raw)` | parse a connection string into a `ConnSpec` (raises `ConnectionConfigError`) |
1486
+ | `resolve_connection(connection=None)` | the same, falling back to `BENCHPOD_CONNECTION` |
1487
+ | `make_build_reporter(*, api_base=None, audience=None, target="", name="")` | a `BuildReporter` inside GitHub Actions, else a `NoopBuildReporter` |
1488
+ | `build_panel_curve(voc_code, sharpness=4.0, points=256)`, `build_linear_curve(max_code, rising=True, points=256)`, `build_constant_curve(value_code, points=256)` | control-loop curves (see [In-fabric DAC control loop](#in-fabric-dac-control-loop)) |
1489
+ | `curve_output_at(curve, input_code)`, `input_percent_to_code(percent)` | evaluate a curve the way the gateware does; an input percentage as a code |
1490
+ | `encode_curve_b64url(codes)` | 16-bit DAC codes as little-endian base64url, the wire form of a curve |
1491
+ | `INTERNAL`, `EXTERNAL`, `PIN1` … `PIN14`, `BMP280_ADDR_PRIMARY`, `BMP280_ADDR_SECONDARY` | named constants (see [API conventions](#api-conventions-and-stability)) |
1492
+ | `DacPath`, `DacOutputPath`, `AnalogPath`, `AdcSource`, `CalibrateSource` (`"current_in"`), `LoopSource`, `Waveshape`, `ReplayMapping`, `DecodeProtocol`, `CanMode`, `FaultType`, `GpioMode`, `Edge`, `TriggerEdge` | the `Literal` string types |
1493
+ | `decode`, `i2c`, `can`, `control_loop`, `motor_emulator`, `dsp` | submodules: off-device decoders, I2C helpers, CAN types, curve helpers, the emulator's registers, and `dsp`, a pure-Python mirror of the server's record-to-replay processing |
1494
+
1316
1495
  ## Examples
1317
1496
 
1318
1497
  * [`examples/test_bmp280.py`](https://github.com/embeddedci-com/embeddedci-python/blob/main/packages/embeddedci/examples/test_bmp280.py)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "embeddedci"
7
- version = "2.7.0"
7
+ version = "2.8.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"
@@ -100,6 +100,7 @@ from .errors import (
100
100
  TriggerTimeout,
101
101
  UartLinkError,
102
102
  UartTimeout,
103
+ UnsupportedFeatureError,
103
104
  )
104
105
  from .flash import FlashResult
105
106
  from .gpio import GpioPin, LaPinState
@@ -279,6 +280,7 @@ __all__ = [
279
280
  "PinConflictError",
280
281
  "PullConflictError",
281
282
  "TriggerTimeout",
283
+ "UnsupportedFeatureError",
282
284
  # device lease
283
285
  "DeviceLease",
284
286
  # CI build reporting
@@ -82,7 +82,7 @@ from .constants import (
82
82
  coerce_pin,
83
83
  )
84
84
  from .connection import resolve_connection
85
- from .errors import BenchPodError, FirmwareError, classify_firmware_error
85
+ from .errors import BenchPodError, FirmwareError, UnsupportedFeatureError, classify_firmware_error
86
86
  from .flash import FlashResult
87
87
  from .gpio import GpioPin, LaPinState
88
88
  from .lease import DEFAULT_LEASE_TTL, DEFAULT_LEASE_WAIT, DeviceLease
@@ -355,11 +355,62 @@ class BenchPod:
355
355
  self._caps = None
356
356
  return self.capabilities
357
357
 
358
+ def supports(self, feature: str) -> bool:
359
+ """Whether this pod has ``feature``, a :class:`Capabilities` flag (``"spi_master"``,
360
+ ``"capture_trigger"``, ``"can"``, ...). Reads the capabilities once; ``False`` for a
361
+ flag this SDK does not know. The ``can_*`` properties name the common ones."""
362
+ return bool(getattr(self.capabilities, feature, False))
363
+
364
+ @property
365
+ def can_gpio(self) -> bool:
366
+ """LA pin modes and GPIO (:meth:`gpio`, :meth:`la_pins`, :meth:`set_gpio`): capability ``la_pins``."""
367
+ return self.supports("la_pins")
368
+
369
+ @property
370
+ def can_trigger(self) -> bool:
371
+ """Triggered captures (``trigger=``): capability ``capture_trigger``."""
372
+ return self.supports("capture_trigger")
373
+
374
+ @property
375
+ def can_spi(self) -> bool:
376
+ """The SPI master and SPI NOR flash programming (:meth:`open_spi`, :meth:`spi_flash`):
377
+ capability ``spi_master``."""
378
+ return self.supports("spi_master")
379
+
380
+ @property
381
+ def can_profile_power(self) -> bool:
382
+ """Gap-free power profiles (:meth:`measure_power`): capability ``power_profile``."""
383
+ return self.supports("power_profile")
384
+
385
+ @property
386
+ def can_calibrate(self) -> bool:
387
+ """On-pod ADC calibration (:meth:`calibrate`): capability ``calibrate``."""
388
+ return self.supports("calibrate")
389
+
390
+ @property
391
+ def can_current_out(self) -> bool:
392
+ """The 4-20 mA output (:meth:`current_out`): capability ``current_out``."""
393
+ return self.supports("current_out")
394
+
395
+ @property
396
+ def can_reset_target(self) -> bool:
397
+ """The dedicated target-reset pin (:meth:`reset_target`, ``flash(nreset=True)``):
398
+ capability ``nrst_pin``."""
399
+ return self.supports("nrst_pin")
400
+
401
+ @property
402
+ def can_analog(self) -> bool:
403
+ """The analog front end (ADC, DAC, 4-20 mA). ``False`` only on the digital-only board;
404
+ firmware that does not say is taken as analog (every older board has it)."""
405
+ return self.capabilities.analog is not False
406
+
358
407
  def _require_capability(self, flag: str, what: str) -> None:
359
- if not getattr(self.capabilities, flag, False):
408
+ if not self.supports(flag):
360
409
  fw = self.capabilities.firmware_version or "unknown"
361
- raise BenchPodError(f"{what} need newer pod firmware (the {flag!r} capability is missing; "
362
- f"this pod runs firmware {fw})")
410
+ raise UnsupportedFeatureError(
411
+ f"{what} need newer pod firmware (the {flag!r} capability is missing; "
412
+ f"this pod runs firmware {fw})",
413
+ feature=flag, firmware_version=self.capabilities.firmware_version)
363
414
 
364
415
  # -- wiring profile -------------------------------------------------------
365
416