embeddedci 2.2.0__tar.gz → 2.4.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 (109) hide show
  1. {embeddedci-2.2.0 → embeddedci-2.4.0}/CHANGELOG.md +50 -1
  2. {embeddedci-2.2.0 → embeddedci-2.4.0}/PKG-INFO +151 -6
  3. {embeddedci-2.2.0 → embeddedci-2.4.0}/README.md +150 -5
  4. {embeddedci-2.2.0 → embeddedci-2.4.0}/pyproject.toml +1 -1
  5. {embeddedci-2.2.0 → embeddedci-2.4.0}/src/embeddedci/benchpod/__init__.py +18 -0
  6. {embeddedci-2.2.0 → embeddedci-2.4.0}/src/embeddedci/benchpod/capabilities.py +16 -0
  7. {embeddedci-2.2.0 → embeddedci-2.4.0}/src/embeddedci/benchpod/client.py +353 -32
  8. {embeddedci-2.2.0 → embeddedci-2.4.0}/src/embeddedci/benchpod/constants.py +12 -6
  9. {embeddedci-2.2.0 → embeddedci-2.4.0}/src/embeddedci/benchpod/dsp.py +4 -1
  10. {embeddedci-2.2.0 → embeddedci-2.4.0}/src/embeddedci/benchpod/lowlevel.py +3 -3
  11. embeddedci-2.4.0/src/embeddedci/benchpod/motor_emulator.py +657 -0
  12. {embeddedci-2.2.0 → embeddedci-2.4.0}/src/embeddedci/benchpod/pytest_plugin.py +35 -0
  13. {embeddedci-2.2.0 → embeddedci-2.4.0}/src/embeddedci/benchpod/replay.py +1 -1
  14. {embeddedci-2.2.0 → embeddedci-2.4.0}/src/embeddedci/benchpod/results.py +3 -0
  15. embeddedci-2.4.0/src/embeddedci/benchpod/spi.py +240 -0
  16. {embeddedci-2.2.0 → embeddedci-2.4.0}/src/embeddedci/benchpod/state.py +76 -2
  17. {embeddedci-2.2.0 → embeddedci-2.4.0}/src/embeddedci/benchpod/transport/tcp.py +20 -0
  18. {embeddedci-2.2.0 → embeddedci-2.4.0}/src/embeddedci/benchpod/waveforms.py +23 -4
  19. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/api_surface.json +150 -6
  20. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/e2e/README.md +1 -0
  21. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/e2e/conftest.py +2 -0
  22. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/e2e/test_e2e_gpio.py +32 -0
  23. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/e2e/test_e2e_la13_14.py +7 -2
  24. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/e2e/test_e2e_pod.py +229 -0
  25. embeddedci-2.4.0/tests/e2e/test_e2e_spi.py +83 -0
  26. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_client_v2.py +115 -1
  27. embeddedci-2.4.0/tests/test_current_out_waveforms.py +198 -0
  28. embeddedci-2.4.0/tests/test_motor_emulator.py +549 -0
  29. embeddedci-2.4.0/tests/test_spi.py +205 -0
  30. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_tcp_transport.py +40 -0
  31. {embeddedci-2.2.0 → embeddedci-2.4.0}/.gitignore +0 -0
  32. {embeddedci-2.2.0 → embeddedci-2.4.0}/LICENSE +0 -0
  33. {embeddedci-2.2.0 → embeddedci-2.4.0}/docs/event-uart-design.md +0 -0
  34. {embeddedci-2.2.0 → embeddedci-2.4.0}/examples/README.md +0 -0
  35. {embeddedci-2.2.0 → embeddedci-2.4.0}/examples/test_bmp280.py +0 -0
  36. {embeddedci-2.2.0 → embeddedci-2.4.0}/src/embeddedci/__init__.py +0 -0
  37. {embeddedci-2.2.0 → embeddedci-2.4.0}/src/embeddedci/benchpod/can.py +0 -0
  38. {embeddedci-2.2.0 → embeddedci-2.4.0}/src/embeddedci/benchpod/capture.py +0 -0
  39. {embeddedci-2.2.0 → embeddedci-2.4.0}/src/embeddedci/benchpod/ci.py +0 -0
  40. {embeddedci-2.2.0 → embeddedci-2.4.0}/src/embeddedci/benchpod/cloud_auth.py +0 -0
  41. {embeddedci-2.2.0 → embeddedci-2.4.0}/src/embeddedci/benchpod/connection.py +0 -0
  42. {embeddedci-2.2.0 → embeddedci-2.4.0}/src/embeddedci/benchpod/control_loop.py +0 -0
  43. {embeddedci-2.2.0 → embeddedci-2.4.0}/src/embeddedci/benchpod/decode.py +0 -0
  44. {embeddedci-2.2.0 → embeddedci-2.4.0}/src/embeddedci/benchpod/discovery.py +0 -0
  45. {embeddedci-2.2.0 → embeddedci-2.4.0}/src/embeddedci/benchpod/errors.py +0 -0
  46. {embeddedci-2.2.0 → embeddedci-2.4.0}/src/embeddedci/benchpod/flash.py +0 -0
  47. {embeddedci-2.2.0 → embeddedci-2.4.0}/src/embeddedci/benchpod/gpio.py +0 -0
  48. {embeddedci-2.2.0 → embeddedci-2.4.0}/src/embeddedci/benchpod/i2c.py +0 -0
  49. {embeddedci-2.2.0 → embeddedci-2.4.0}/src/embeddedci/benchpod/lease.py +0 -0
  50. {embeddedci-2.2.0 → embeddedci-2.4.0}/src/embeddedci/benchpod/power.py +0 -0
  51. {embeddedci-2.2.0 → embeddedci-2.4.0}/src/embeddedci/benchpod/protocol.py +0 -0
  52. {embeddedci-2.2.0 → embeddedci-2.4.0}/src/embeddedci/benchpod/sensor.py +0 -0
  53. {embeddedci-2.2.0 → embeddedci-2.4.0}/src/embeddedci/benchpod/server_api.py +0 -0
  54. {embeddedci-2.2.0 → embeddedci-2.4.0}/src/embeddedci/benchpod/transport/__init__.py +0 -0
  55. {embeddedci-2.2.0 → embeddedci-2.4.0}/src/embeddedci/benchpod/transport/base.py +0 -0
  56. {embeddedci-2.2.0 → embeddedci-2.4.0}/src/embeddedci/benchpod/transport/cloud.py +0 -0
  57. {embeddedci-2.2.0 → embeddedci-2.4.0}/src/embeddedci/benchpod/transport/serial.py +0 -0
  58. {embeddedci-2.2.0 → embeddedci-2.4.0}/src/embeddedci/benchpod/uart.py +0 -0
  59. {embeddedci-2.2.0 → embeddedci-2.4.0}/src/embeddedci/benchpod/wiring.py +0 -0
  60. {embeddedci-2.2.0 → embeddedci-2.4.0}/src/embeddedci/py.typed +0 -0
  61. {embeddedci-2.2.0 → embeddedci-2.4.0}/src/embeddedci/upload_build.py +0 -0
  62. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/conftest.py +0 -0
  63. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/e2e/e2e_helpers.py +0 -0
  64. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/e2e/test_e2e_cloud.py +0 -0
  65. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/e2e/test_e2e_dut.py +0 -0
  66. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/e2e/test_e2e_rev3.py +0 -0
  67. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/e2e/test_e2e_usb.py +0 -0
  68. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/examples/test_bmp280_hil.py +0 -0
  69. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/examples/test_can_ecu_demo.py +0 -0
  70. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/examples/test_can_loopback_hil.py +0 -0
  71. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/examples/test_smoke.py +0 -0
  72. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_adc_fft_hw.py +0 -0
  73. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_api_surface.py +0 -0
  74. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_can.py +0 -0
  75. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_capabilities.py +0 -0
  76. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_capture.py +0 -0
  77. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_capture_replay_hw.py +0 -0
  78. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_ci.py +0 -0
  79. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_cloud.py +0 -0
  80. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_connection.py +0 -0
  81. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_control_loop.py +0 -0
  82. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_control_loop_hw.py +0 -0
  83. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_dac_path_ranges.py +0 -0
  84. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_decode.py +0 -0
  85. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_dsp.py +0 -0
  86. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_flash_bridge.py +0 -0
  87. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_gpio.py +0 -0
  88. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_i2c_decode.py +0 -0
  89. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_i2c_parity.py +0 -0
  90. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_image_switching.py +0 -0
  91. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_integration.py +0 -0
  92. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_la_voltage_fixture.py +0 -0
  93. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_lease.py +0 -0
  94. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_pins_bias.py +0 -0
  95. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_protocol.py +0 -0
  96. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_replay.py +0 -0
  97. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_sensor.py +0 -0
  98. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_serial_autodetect.py +0 -0
  99. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_serial_json.py +0 -0
  100. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_serial_text_console.py +0 -0
  101. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_timed_power.py +0 -0
  102. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_timing.py +0 -0
  103. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_trigger_power.py +0 -0
  104. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_uart_capture.py +0 -0
  105. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_uart_session.py +0 -0
  106. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_upload_build.py +0 -0
  107. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_waveforms.py +0 -0
  108. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/test_wiring.py +0 -0
  109. {embeddedci-2.2.0 → embeddedci-2.4.0}/tests/testdata/i2c_decode_vectors.json +0 -0
@@ -1,6 +1,55 @@
1
1
  # Changelog — `embeddedci`
2
2
 
3
- ## Unreleased
3
+ ## 2.4.0
4
+
5
+ - **Breaking:** the 4-20 mA measurement terminal (J8) is now `"current_in"` as an ADC source
6
+ and analog path, and `lowlevel.cal_switch(current_in=...)` for its relay. The old name read as
7
+ "amplifier". Firmware 3.4.0 no longer accepts the old name, so `AdcSource` and
8
+ `AnalogPath` drop it without a major version. `adc_read("current_in")` also returns the loop
9
+ current in amps as `AdcReading.current`.
10
+ - Calibration: `BenchPod.calibrate()` makes the pod measure the offset of its 4-20 mA input
11
+ (`current_in`, J8 disconnected) and store it in flash; `calibration()` reads it back and
12
+ `clear_calibration()` removes it. All three return a `Calibration` (`calibrated`, `offset` in
13
+ volts, the fit `a` / `b`). `adc_read("current_in")` has the offset taken out and reports it as
14
+ `AdcReading.offset`. New `CalibrateSource` and `Capabilities.calibrate`. Needs firmware 3.4.0.
15
+ - 4-20 mA output: `BenchPod.current_out(amps)` holds a current on terminal J9 and returns a
16
+ `CurrentOutput` with the current actually held, the 16-bit DAC code and the range the output
17
+ can do; `current_out_range()` reads the range only. The pod converts and refuses a current
18
+ outside its range. The output is loop powered (it needs an external floating supply) and shares the DAC with the voltage outputs,
19
+ which `current_out` switches off. New analog path `"current_out"` and
20
+ `Capabilities.current_out`. Needs firmware 3.4.0.
21
+ - Waveforms on the 4-20 mA output: `generate`, `replay` and `replay_waveform` take
22
+ `dac_path="current_out"` (`DacPath` gains it), with levels in amps and the range the pod
23
+ reports. Stopping the returned handle returns the loop to 4 mA.
24
+ - Captures of the 4-20 mA input in amps: `capture_adc(source="current_in")` scales with the pod's
25
+ own fit and fills the new `Capture.currents`. `capture_correlated` takes `source` too, so the
26
+ loop current is captured next to the logic channels off one trigger. Such a capture replays as the same current on
27
+ `current_out`, and `save_capture_as_recording` stores it as a current. `Waveform.unit` says
28
+ whether a library entry is in `"V"` or `"mA"`; `WaveformLibrary.save_recording` takes `unit`,
29
+ and `save_segments` takes amps on the `current_out` path. Needs server support for the unit.
30
+ - Motor & battery emulator: `BenchPod.open_motor_emulator()` returns a `MotorEmulator` for the
31
+ ECP5 emulator boards on the SPI link. It configures every board at once (`configure`, slave
32
+ SPI with PROGRAMN and DONE), reads and writes the link registers by name (`read`, `write`,
33
+ `broadcast=True`), and converts the common ones to units with an `EmulatorCalibration`: bridge
34
+ PWM and duties, sinc samples, protection and trips, the battery model (`BatteryModel`, OCV curve,
35
+ R0 / RC branch, state of charge), the back-EMF shape, the logging stream (`read_log` returns
36
+ whole sample sets) and the board EEPROM. Needs emulator gateware 0x0007+.
37
+ - `SpiSession.stream(data, head=...)` sends data of any size in one CS frame through the pod's
38
+ PSRAM (`load_bin` staging, then the `spi_stream` command). New `SpiStreamResult` and
39
+ `Capabilities.spi_stream`.
40
+
41
+ ## 2.3.0
42
+
43
+ - SPI master on the LA pins: `BenchPod.open_spi()` returns an `SpiSession` for raw full-duplex
44
+ transfers (`transfer`, CS held across calls with `hold_cs`) and SPI NOR flash work
45
+ (`flash_id`, `flash_read`, `flash_erase`, `flash_write`, `flash_program`).
46
+ `BenchPod.spi_flash(image, addr)` erases, writes and verifies a whole image in one call, and can
47
+ hold the DUT in reset while it runs (`hold_reset=True`). Pins come from the wiring profile
48
+ (`spi_sclk`, `spi_mosi`, `spi_miso`, `spi_cs`) or as arguments; up to 6 MHz, modes 0 and 3.
49
+ Needs firmware 3.3+ with gateware v45 (capability `spi_master`, new `Capabilities.spi_master`).
50
+ Works over the LAN, USB and the cloud.
51
+ - pytest plugin: with `BENCHPOD_LIFT_DAC_LIMITS=1` a session fixture clears the pod's DAC output
52
+ limits for the run and writes the same limits back at the end, even when tests fail.
4
53
 
5
54
  ## 2.2.0
6
55
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: embeddedci
3
- Version: 2.2.0
3
+ Version: 2.4.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
@@ -243,7 +243,7 @@ automatically.)
243
243
  * **Invalid arguments raise `ValueError`**. A `BenchPodError` subclass always means a device,
244
244
  transport or server failure — see [Errors](#errors).
245
245
  * **Device state is typed.** Methods return frozen dataclasses (`LaVoltage`, `TargetStatus`,
246
- `PowerStatus`, `PullState`, `DacOutput`, `AdcReading`, …) from `embeddedci.benchpod.state`; each
246
+ `PowerStatus`, `PullState`, `DacOutput`, `CurrentOutput`, `AdcReading`, …) from `embeddedci.benchpod.state`; each
247
247
  keeps the untouched firmware reply in `.raw`.
248
248
  * **String options are `Literal` types** and are validated: `DacPath`, `DacOutputPath`,
249
249
  `AnalogPath`, `AdcSource`, `LoopSource`, `Waveshape`, `ReplayMapping`, `DecodeProtocol`,
@@ -504,7 +504,7 @@ how finely a transient was resolved. Measured on a v2 pod:
504
504
  | eFuse 2 (external) | 5-20 V on the external terminal | ≈ 3.0 A | ≈ 21.8 V | 167 µA |
505
505
 
506
506
  An overload is held at the limit for about 2.8 ms, then the eFuse trips (`TargetStatus` `fault`) and
507
- retries after ≈ 110 ms. A trip shorter than one sample does not show in a profile. The ADC's `amp`
507
+ retries after ≈ 110 ms. A trip shorter than one sample does not show in a profile. The ADC's `current_in`
508
508
  input is a 4-20 mA loop terminal (249 Ω), not a way to measure the target's supply.
509
509
 
510
510
  ## Flashing
@@ -541,6 +541,65 @@ assert result.ok, result.stderr # also: returncode, stdout, target_unreachab
541
541
  empties the target config's clock-boost reset events, which otherwise race the SWD link.
542
542
  * OpenOCD runs on the machine running the test, on every transport including the cloud.
543
543
 
544
+ ## SPI flash and SPI devices
545
+
546
+ Pods with gateware v45+ (`bp.capabilities.spi_master`) run an SPI master on any four LA pins, up
547
+ to 6 MHz, modes 0 and 3. Wire a 25-series SPI NOR flash (W25Q, MX25, GD25, ...) with its SCK, DI,
548
+ DO and CS on LA channels (power it at the LA voltage, tie /WP and /HOLD high) and program it:
549
+
550
+ ```python
551
+ res = bp.spi_flash("fw.bin", 0x0, sck=13, mosi=14, miso=6, cs=5, hold_reset=True)
552
+ print(res.jedec_id, res.length, res.seconds) # erased, written and verified
553
+ ```
554
+
555
+ Or work with a session (pins from the wiring profile's `spi_sclk`, `spi_mosi`, `spi_miso`,
556
+ `spi_cs` when omitted):
557
+
558
+ ```python
559
+ with bp.open_spi(hz=6_000_000) as spi:
560
+ print(spi.flash_id()) # SpiFlashInfo(jedec_id='ef4017', present=True, ...)
561
+ data = spi.flash_read(0x0, 4096)
562
+ spi.flash_erase(0x10000, 65536)
563
+ spi.flash_write(0x10000, payload) # 768 B per command, each verified
564
+ rx = spi.transfer(b"\x9f\x00\x00\x00") # raw full-duplex, for any SPI device
565
+ ```
566
+
567
+ `hold_reset=True` holds the DUT in reset for the job (rev3 pods), so its own controller does not
568
+ drive the same bus. The SPI master shares the SWD engine: an SWD flash cannot run while a session
569
+ is open. Expect about 30 KB/s written (verified) over the LAN, less over the cloud.
570
+
571
+ `spi.stream(data, head=b"...")` sends data of any size in one CS frame, write-only: it is uploaded
572
+ into the pod's PSRAM first, then clocked out 512 bytes at a time (an FPGA bitstream into a
573
+ slave-SPI configuration port). It needs a LAN or cloud connection and firmware with the
574
+ `spi_stream` command (`bp.capabilities.spi_stream`).
575
+
576
+ ## Motor & battery emulator
577
+
578
+ The BenchPod motor emulator boards (ECP5, up to four on one SPI link) are driven with
579
+ `bp.open_motor_emulator()`. The SPI pins come from the wiring profile, as do `PROGRAMN` (open
580
+ drain) and `DONE` (input) when it names them:
581
+
582
+ ```python
583
+ from embeddedci.benchpod import BatteryModel
584
+
585
+ with bp.open_motor_emulator() as emu:
586
+ emu.configure("emu.bit") # every board at once; waits for DONE
587
+ print(emu.probe(0)) # BoardInfo(version=7, ...)
588
+ emu.set_protection(0, overcurrent_a=8, overvoltage_on_v=30, overvoltage_off_v=29)
589
+ emu.set_battery(BatteryModel(capacity_ah=2.0, ocv=[25.2, 23.4, 22.2, 18.0], r0_ohm=0.02))
590
+ emu.set_pwm(200_000)
591
+ emu.arm(0)
592
+ emu.set_control(0, pwm=True)
593
+ print(emu.sample(0).phase_currents, emu.battery_state(0).soc)
594
+ emu.start_log(["a", "bus"])
595
+ log = emu.read_log() # whole sample sets, sequence-checked
596
+ ```
597
+
598
+ Every register of the link protocol is reachable by name with `emu.read("SPEED")` and
599
+ `emu.write("KE", 1200, board=1)` (`motor_emulator.REGISTERS`); `broadcast=True` writes every
600
+ board. Units use `EmulatorCalibration` (the design values unless you pass a bench calibration).
601
+ Needs emulator gateware 0x0007 or later.
602
+
544
603
  ## UART
545
604
 
546
605
  `rx` is the LA channel the pod **samples** (wire the DUT's TX here); `tx` is the channel the pod
@@ -713,7 +772,8 @@ one step:
713
772
  | `"dac_3v3"`, `"dac_5v"`, `"dac_12v"` | route the DAC to that output (`dac_12v` is the bipolar ±12 V output) |
714
773
  | `"adc_ext"` | connect the ADC to the front SMA |
715
774
  | `"cal1"`, `"cal2"` | loop the 5 V / 12 V DAC output back into the ADC |
716
- | `"amp"` | read the amps terminal |
775
+ | `"current_in"` | read the 4-20 mA measurement terminal |
776
+ | `"current_out"` | the 4-20 mA output: switch the DAC voltage outputs off |
717
777
  | `"off"` | park everything |
718
778
 
719
779
  ```python
@@ -727,9 +787,94 @@ print(bp.adc_read("ext").voltage) # the true front-SMA voltage (the ÷1
727
787
  ```
728
788
 
729
789
  `dac_output` takes calibrated volts (`"3v3"`, `"5v"`, `"12v"`, or `"off"`); without `volts` it only
730
- routes. `adc_read` routes the source (`"ext"`, `"cal1"`, `"cal2"`, `"amp"`) and returns one averaged,
790
+ routes. `adc_read` routes the source (`"ext"`, `"cal1"`, `"cal2"`, `"current_in"`) and returns one averaged,
731
791
  calibrated reading; the pod refuses it while the input is still moving (e.g. a DAC left running).
732
792
 
793
+ ### Calibrating the 4-20 mA input
794
+
795
+ `"current_in"` reads the 4-20 mA terminal (J8) across a 249 Ω resistor, so 4 mA is 0.996 V and 20 mA is
796
+ 4.98 V. Each pod is off by a few mV there. The pod can measure that offset itself and keep it in
797
+ its flash:
798
+
799
+ ```python
800
+ cal = bp.calibrate() # disconnect J8 first. Calibration(source, calibrated, offset, a, b, ...)
801
+ print(cal.offset) # 0.004356: what the open input read, now taken out
802
+ print(bp.adc_read("current_in").voltage) # 0.0 with J8 still open
803
+ print(bp.adc_read("current_in").current) # loop current in amps: 0.004 to 0.020 for a live loop
804
+
805
+ bp.calibration() # what the pod has stored
806
+ bp.clear_calibration() # back to the built-in fit
807
+ ```
808
+
809
+ Calibrate once per pod: it survives a reboot and a firmware update. The pod refuses
810
+ (`BenchPodError`) when something is driving J8 (more than 50 mV) and keeps what it had. `a` and `b`
811
+ are the fit the pod now uses for `current_in` (`volts = a + b * count`). Only the offset of `current_in` is
812
+ calibrated; the other sources use the built-in fits. Needs firmware 3.4.0 (capability
813
+ `calibrate`, `Capabilities.calibrate`).
814
+
815
+ ### The 4-20 mA output
816
+
817
+ Terminal J9 is a two-wire 4-20 mA transmitter. `current_out` holds a current on it, in amps:
818
+
819
+ ```python
820
+ out = bp.current_out(0.012) # CurrentOutput(current, code, min_current, max_current)
821
+ print(out.current) # 0.012: the current actually held (16-bit, 0.25 µA steps)
822
+ print(out.min_current, out.max_current) # 0.004056 0.020094 on a rev3 pod: what the output can do
823
+
824
+ bp.current_out(0.004) # back to the live zero
825
+ print(bp.current_out_range().max_current) # the range only, nothing moves
826
+ ```
827
+
828
+ - **It is loop powered.** J9 pin 1 (plus) goes to the plus of an external floating loop supply, pin 2
829
+ (minus) through your receiver to the supply minus. Use 8 V plus 20 mA times the loop
830
+ resistance, 30 V at most (24 V drives up to 800 Ω).
831
+ - **The loop must float.** Pin 2 is not pod ground, and nothing in the loop may touch pod
832
+ ground: not the supply minus, and not a receiver that shares a ground with the pod (through
833
+ SWD or UART wiring to the same target). If it does, the current is no longer regulated.
834
+ - **Do not wire J9 straight into J8.** The output cannot regulate that way.
835
+ - The pod cannot see the loop. `current_out` succeeds with the supply off or the loop open.
836
+ - The output cannot go below its live zero (a little above 4 mA) or above about 20.1 mA: there is no
837
+ 0 mA and no 21 mA level. `0.004` gives the live zero. Anything else outside the range raises
838
+ `BenchPodError` with the pod's message. A value of 1 or more raises `ValueError` (amps, not mA).
839
+ - **The DAC is shared** with the 3.3 V / 5 V / ±12 V outputs. `current_out` switches those off
840
+ first. `dac_output`, `generate` and `replay` also move the loop current. `dac_stop` leaves the
841
+ loop where it was: call `current_out(0.004)` to go back to 4 mA.
842
+ - While DAC limits are set (an output stage), the pod refuses `current_out`.
843
+
844
+ Waveforms work on it too. `generate`, `replay` and `replay_waveform` take
845
+ `dac_path="current_out"`, and their levels are then amps:
846
+
847
+ ```python
848
+ with bp.generate("sine", freq_hz=2, amplitude=0.006, offset=0.012, dac_path="current_out"):
849
+ ... # 6 to 18 mA; leaving the block returns the loop to 4 mA
850
+
851
+ ramp = [0.004 + 0.016 * i / 999 for i in range(1000)]
852
+ bp.replay(ramp, dac_path="current_out", sample_rate_hz=1000) # 4 to 20 mA in 1 s, looping
853
+
854
+ loop = bp.capture_adc(4096, sample_rate_hz=100_000, source="current_in")
855
+ print(max(loop.currents)) # a capture of J8 carries the loop current in amps
856
+ bp.replay(loop, dac_path="current_out") # play the captured loop back on J9
857
+ ```
858
+
859
+ - `generate` builds 8-bit levels: steps of about 63 µA. `replay` is 16-bit.
860
+ - Stopping the returned handle returns the loop to 4 mA. A bare `dac_stop()` does not.
861
+ - A capture or recording of a voltage has no current to reproduce: on `current_out` it needs
862
+ `mapping="fit"`, which stretches its shape over the output's range. The same holds for a
863
+ current recording (`Waveform.unit == "mA"`) on a voltage output.
864
+ - In the cloud library, `save_capture_as_recording` stores a capture of `current_in` as a current
865
+ (`unit="mA"`), and `waveforms.save_segments(..., dac_path="current_out")` takes amps.
866
+ - Below 400 kS/s a capture of `current_in` reads about 10 to 20 µA higher than `adc_read`.
867
+ - The FPGA timing features work on the 4-20 mA terminals exactly as on the voltage ones, because
868
+ they are the same DAC and ADC: `capture_correlated(..., source="current_in")` captures the loop
869
+ current and the logic channels off one trigger, `on_capture=True` starts a current waveform at
870
+ the capture's t0, `stop_dac_after` freezes it (the loop then holds that current), and
871
+ `trigger=` starts a capture on an LA edge. There is one ADC and one DAC: a capture is the SMA
872
+ voltage or the J8 current, and the output is a voltage or a current, never both at once.
873
+
874
+ The pod converts with a fit for its board revision (measured on one rev3 pod; the nominal part
875
+ values on v2), the same on every pod of that revision: the output has no per-pod calibration.
876
+ Expect a few tens of µA between pods. Needs firmware 3.4.0 or later (capability `current_out`, `Capabilities.current_out`).
877
+
733
878
  ## Captures
734
879
 
735
880
  Captures return data with the scaling applied: an ADC `Capture` holds **calibrated volts** (the
@@ -1192,7 +1337,7 @@ These sit below the stable API and are **not** covered by the stability promise:
1192
1337
  (raises `FirmwareError` when the pod refuses). Works over TCP, USB and the cloud.
1193
1338
  * `bp.transport` — the underlying transport object.
1194
1339
  * `bp.lowlevel` — individual analog switches and raw DAC codes for bring-up and diagnostics:
1195
- `dac_mux(ctrl1=, ctrl2=)`, `dac_mux_status()`, `cal_switch(cal1=, cal2=, amp_measure=, cal_path=)`,
1340
+ `dac_mux(ctrl1=, ctrl2=)`, `dac_mux_status()`, `cal_switch(cal1=, cal2=, current_in=, cal_path=)`,
1196
1341
  `cal_switch_status()`, `dac_set(code, divider=)`. Prefer the named paths — these can leave the
1197
1342
  front end in a state no named path describes.
1198
1343
 
@@ -205,7 +205,7 @@ automatically.)
205
205
  * **Invalid arguments raise `ValueError`**. A `BenchPodError` subclass always means a device,
206
206
  transport or server failure — see [Errors](#errors).
207
207
  * **Device state is typed.** Methods return frozen dataclasses (`LaVoltage`, `TargetStatus`,
208
- `PowerStatus`, `PullState`, `DacOutput`, `AdcReading`, …) from `embeddedci.benchpod.state`; each
208
+ `PowerStatus`, `PullState`, `DacOutput`, `CurrentOutput`, `AdcReading`, …) from `embeddedci.benchpod.state`; each
209
209
  keeps the untouched firmware reply in `.raw`.
210
210
  * **String options are `Literal` types** and are validated: `DacPath`, `DacOutputPath`,
211
211
  `AnalogPath`, `AdcSource`, `LoopSource`, `Waveshape`, `ReplayMapping`, `DecodeProtocol`,
@@ -466,7 +466,7 @@ how finely a transient was resolved. Measured on a v2 pod:
466
466
  | eFuse 2 (external) | 5-20 V on the external terminal | ≈ 3.0 A | ≈ 21.8 V | 167 µA |
467
467
 
468
468
  An overload is held at the limit for about 2.8 ms, then the eFuse trips (`TargetStatus` `fault`) and
469
- retries after ≈ 110 ms. A trip shorter than one sample does not show in a profile. The ADC's `amp`
469
+ retries after ≈ 110 ms. A trip shorter than one sample does not show in a profile. The ADC's `current_in`
470
470
  input is a 4-20 mA loop terminal (249 Ω), not a way to measure the target's supply.
471
471
 
472
472
  ## Flashing
@@ -503,6 +503,65 @@ assert result.ok, result.stderr # also: returncode, stdout, target_unreachab
503
503
  empties the target config's clock-boost reset events, which otherwise race the SWD link.
504
504
  * OpenOCD runs on the machine running the test, on every transport including the cloud.
505
505
 
506
+ ## SPI flash and SPI devices
507
+
508
+ Pods with gateware v45+ (`bp.capabilities.spi_master`) run an SPI master on any four LA pins, up
509
+ to 6 MHz, modes 0 and 3. Wire a 25-series SPI NOR flash (W25Q, MX25, GD25, ...) with its SCK, DI,
510
+ DO and CS on LA channels (power it at the LA voltage, tie /WP and /HOLD high) and program it:
511
+
512
+ ```python
513
+ res = bp.spi_flash("fw.bin", 0x0, sck=13, mosi=14, miso=6, cs=5, hold_reset=True)
514
+ print(res.jedec_id, res.length, res.seconds) # erased, written and verified
515
+ ```
516
+
517
+ Or work with a session (pins from the wiring profile's `spi_sclk`, `spi_mosi`, `spi_miso`,
518
+ `spi_cs` when omitted):
519
+
520
+ ```python
521
+ with bp.open_spi(hz=6_000_000) as spi:
522
+ print(spi.flash_id()) # SpiFlashInfo(jedec_id='ef4017', present=True, ...)
523
+ data = spi.flash_read(0x0, 4096)
524
+ spi.flash_erase(0x10000, 65536)
525
+ spi.flash_write(0x10000, payload) # 768 B per command, each verified
526
+ rx = spi.transfer(b"\x9f\x00\x00\x00") # raw full-duplex, for any SPI device
527
+ ```
528
+
529
+ `hold_reset=True` holds the DUT in reset for the job (rev3 pods), so its own controller does not
530
+ drive the same bus. The SPI master shares the SWD engine: an SWD flash cannot run while a session
531
+ is open. Expect about 30 KB/s written (verified) over the LAN, less over the cloud.
532
+
533
+ `spi.stream(data, head=b"...")` sends data of any size in one CS frame, write-only: it is uploaded
534
+ into the pod's PSRAM first, then clocked out 512 bytes at a time (an FPGA bitstream into a
535
+ slave-SPI configuration port). It needs a LAN or cloud connection and firmware with the
536
+ `spi_stream` command (`bp.capabilities.spi_stream`).
537
+
538
+ ## Motor & battery emulator
539
+
540
+ The BenchPod motor emulator boards (ECP5, up to four on one SPI link) are driven with
541
+ `bp.open_motor_emulator()`. The SPI pins come from the wiring profile, as do `PROGRAMN` (open
542
+ drain) and `DONE` (input) when it names them:
543
+
544
+ ```python
545
+ from embeddedci.benchpod import BatteryModel
546
+
547
+ with bp.open_motor_emulator() as emu:
548
+ emu.configure("emu.bit") # every board at once; waits for DONE
549
+ print(emu.probe(0)) # BoardInfo(version=7, ...)
550
+ emu.set_protection(0, overcurrent_a=8, overvoltage_on_v=30, overvoltage_off_v=29)
551
+ emu.set_battery(BatteryModel(capacity_ah=2.0, ocv=[25.2, 23.4, 22.2, 18.0], r0_ohm=0.02))
552
+ emu.set_pwm(200_000)
553
+ emu.arm(0)
554
+ emu.set_control(0, pwm=True)
555
+ print(emu.sample(0).phase_currents, emu.battery_state(0).soc)
556
+ emu.start_log(["a", "bus"])
557
+ log = emu.read_log() # whole sample sets, sequence-checked
558
+ ```
559
+
560
+ Every register of the link protocol is reachable by name with `emu.read("SPEED")` and
561
+ `emu.write("KE", 1200, board=1)` (`motor_emulator.REGISTERS`); `broadcast=True` writes every
562
+ board. Units use `EmulatorCalibration` (the design values unless you pass a bench calibration).
563
+ Needs emulator gateware 0x0007 or later.
564
+
506
565
  ## UART
507
566
 
508
567
  `rx` is the LA channel the pod **samples** (wire the DUT's TX here); `tx` is the channel the pod
@@ -675,7 +734,8 @@ one step:
675
734
  | `"dac_3v3"`, `"dac_5v"`, `"dac_12v"` | route the DAC to that output (`dac_12v` is the bipolar ±12 V output) |
676
735
  | `"adc_ext"` | connect the ADC to the front SMA |
677
736
  | `"cal1"`, `"cal2"` | loop the 5 V / 12 V DAC output back into the ADC |
678
- | `"amp"` | read the amps terminal |
737
+ | `"current_in"` | read the 4-20 mA measurement terminal |
738
+ | `"current_out"` | the 4-20 mA output: switch the DAC voltage outputs off |
679
739
  | `"off"` | park everything |
680
740
 
681
741
  ```python
@@ -689,9 +749,94 @@ print(bp.adc_read("ext").voltage) # the true front-SMA voltage (the ÷1
689
749
  ```
690
750
 
691
751
  `dac_output` takes calibrated volts (`"3v3"`, `"5v"`, `"12v"`, or `"off"`); without `volts` it only
692
- routes. `adc_read` routes the source (`"ext"`, `"cal1"`, `"cal2"`, `"amp"`) and returns one averaged,
752
+ routes. `adc_read` routes the source (`"ext"`, `"cal1"`, `"cal2"`, `"current_in"`) and returns one averaged,
693
753
  calibrated reading; the pod refuses it while the input is still moving (e.g. a DAC left running).
694
754
 
755
+ ### Calibrating the 4-20 mA input
756
+
757
+ `"current_in"` reads the 4-20 mA terminal (J8) across a 249 Ω resistor, so 4 mA is 0.996 V and 20 mA is
758
+ 4.98 V. Each pod is off by a few mV there. The pod can measure that offset itself and keep it in
759
+ its flash:
760
+
761
+ ```python
762
+ cal = bp.calibrate() # disconnect J8 first. Calibration(source, calibrated, offset, a, b, ...)
763
+ print(cal.offset) # 0.004356: what the open input read, now taken out
764
+ print(bp.adc_read("current_in").voltage) # 0.0 with J8 still open
765
+ print(bp.adc_read("current_in").current) # loop current in amps: 0.004 to 0.020 for a live loop
766
+
767
+ bp.calibration() # what the pod has stored
768
+ bp.clear_calibration() # back to the built-in fit
769
+ ```
770
+
771
+ Calibrate once per pod: it survives a reboot and a firmware update. The pod refuses
772
+ (`BenchPodError`) when something is driving J8 (more than 50 mV) and keeps what it had. `a` and `b`
773
+ are the fit the pod now uses for `current_in` (`volts = a + b * count`). Only the offset of `current_in` is
774
+ calibrated; the other sources use the built-in fits. Needs firmware 3.4.0 (capability
775
+ `calibrate`, `Capabilities.calibrate`).
776
+
777
+ ### The 4-20 mA output
778
+
779
+ Terminal J9 is a two-wire 4-20 mA transmitter. `current_out` holds a current on it, in amps:
780
+
781
+ ```python
782
+ out = bp.current_out(0.012) # CurrentOutput(current, code, min_current, max_current)
783
+ print(out.current) # 0.012: the current actually held (16-bit, 0.25 µA steps)
784
+ print(out.min_current, out.max_current) # 0.004056 0.020094 on a rev3 pod: what the output can do
785
+
786
+ bp.current_out(0.004) # back to the live zero
787
+ print(bp.current_out_range().max_current) # the range only, nothing moves
788
+ ```
789
+
790
+ - **It is loop powered.** J9 pin 1 (plus) goes to the plus of an external floating loop supply, pin 2
791
+ (minus) through your receiver to the supply minus. Use 8 V plus 20 mA times the loop
792
+ resistance, 30 V at most (24 V drives up to 800 Ω).
793
+ - **The loop must float.** Pin 2 is not pod ground, and nothing in the loop may touch pod
794
+ ground: not the supply minus, and not a receiver that shares a ground with the pod (through
795
+ SWD or UART wiring to the same target). If it does, the current is no longer regulated.
796
+ - **Do not wire J9 straight into J8.** The output cannot regulate that way.
797
+ - The pod cannot see the loop. `current_out` succeeds with the supply off or the loop open.
798
+ - The output cannot go below its live zero (a little above 4 mA) or above about 20.1 mA: there is no
799
+ 0 mA and no 21 mA level. `0.004` gives the live zero. Anything else outside the range raises
800
+ `BenchPodError` with the pod's message. A value of 1 or more raises `ValueError` (amps, not mA).
801
+ - **The DAC is shared** with the 3.3 V / 5 V / ±12 V outputs. `current_out` switches those off
802
+ first. `dac_output`, `generate` and `replay` also move the loop current. `dac_stop` leaves the
803
+ loop where it was: call `current_out(0.004)` to go back to 4 mA.
804
+ - While DAC limits are set (an output stage), the pod refuses `current_out`.
805
+
806
+ Waveforms work on it too. `generate`, `replay` and `replay_waveform` take
807
+ `dac_path="current_out"`, and their levels are then amps:
808
+
809
+ ```python
810
+ with bp.generate("sine", freq_hz=2, amplitude=0.006, offset=0.012, dac_path="current_out"):
811
+ ... # 6 to 18 mA; leaving the block returns the loop to 4 mA
812
+
813
+ ramp = [0.004 + 0.016 * i / 999 for i in range(1000)]
814
+ bp.replay(ramp, dac_path="current_out", sample_rate_hz=1000) # 4 to 20 mA in 1 s, looping
815
+
816
+ loop = bp.capture_adc(4096, sample_rate_hz=100_000, source="current_in")
817
+ print(max(loop.currents)) # a capture of J8 carries the loop current in amps
818
+ bp.replay(loop, dac_path="current_out") # play the captured loop back on J9
819
+ ```
820
+
821
+ - `generate` builds 8-bit levels: steps of about 63 µA. `replay` is 16-bit.
822
+ - Stopping the returned handle returns the loop to 4 mA. A bare `dac_stop()` does not.
823
+ - A capture or recording of a voltage has no current to reproduce: on `current_out` it needs
824
+ `mapping="fit"`, which stretches its shape over the output's range. The same holds for a
825
+ current recording (`Waveform.unit == "mA"`) on a voltage output.
826
+ - In the cloud library, `save_capture_as_recording` stores a capture of `current_in` as a current
827
+ (`unit="mA"`), and `waveforms.save_segments(..., dac_path="current_out")` takes amps.
828
+ - Below 400 kS/s a capture of `current_in` reads about 10 to 20 µA higher than `adc_read`.
829
+ - The FPGA timing features work on the 4-20 mA terminals exactly as on the voltage ones, because
830
+ they are the same DAC and ADC: `capture_correlated(..., source="current_in")` captures the loop
831
+ current and the logic channels off one trigger, `on_capture=True` starts a current waveform at
832
+ the capture's t0, `stop_dac_after` freezes it (the loop then holds that current), and
833
+ `trigger=` starts a capture on an LA edge. There is one ADC and one DAC: a capture is the SMA
834
+ voltage or the J8 current, and the output is a voltage or a current, never both at once.
835
+
836
+ The pod converts with a fit for its board revision (measured on one rev3 pod; the nominal part
837
+ values on v2), the same on every pod of that revision: the output has no per-pod calibration.
838
+ Expect a few tens of µA between pods. Needs firmware 3.4.0 or later (capability `current_out`, `Capabilities.current_out`).
839
+
695
840
  ## Captures
696
841
 
697
842
  Captures return data with the scaling applied: an ADC `Capture` holds **calibrated volts** (the
@@ -1154,7 +1299,7 @@ These sit below the stable API and are **not** covered by the stability promise:
1154
1299
  (raises `FirmwareError` when the pod refuses). Works over TCP, USB and the cloud.
1155
1300
  * `bp.transport` — the underlying transport object.
1156
1301
  * `bp.lowlevel` — individual analog switches and raw DAC codes for bring-up and diagnostics:
1157
- `dac_mux(ctrl1=, ctrl2=)`, `dac_mux_status()`, `cal_switch(cal1=, cal2=, amp_measure=, cal_path=)`,
1302
+ `dac_mux(ctrl1=, ctrl2=)`, `dac_mux_status()`, `cal_switch(cal1=, cal2=, current_in=, cal_path=)`,
1158
1303
  `cal_switch_status()`, `dac_set(code, divider=)`. Prefer the named paths — these can leave the
1159
1304
  front end in a state no named path describes.
1160
1305
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "embeddedci"
7
- version = "2.2.0"
7
+ version = "2.4.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"
@@ -49,6 +49,7 @@ from .constants import (
49
49
  PIN13,
50
50
  PIN14,
51
51
  AdcSource,
52
+ CalibrateSource,
52
53
  AnalogPath,
53
54
  CanMode,
54
55
  DacOutputPath,
@@ -105,6 +106,8 @@ from .server_api import ServerApi, ServerApiError
105
106
  from .state import (
106
107
  AdcReading,
107
108
  AnalogPathState,
109
+ Calibration,
110
+ CurrentOutput,
108
111
  DacOutput,
109
112
  EfuseState,
110
113
  FpgaImageInfo,
@@ -118,6 +121,9 @@ from .state import (
118
121
  UsbCcStatus,
119
122
  )
120
123
  from .uart import UartCapture, UartSession
124
+ from .spi import SpiFlashInfo, SpiFlashResult, SpiSession, SpiStreamResult
125
+ from . import motor_emulator
126
+ from .motor_emulator import BatteryModel, EmulatorCalibration, MotorEmulator
121
127
  from .waveforms import Waveform, WaveformLibrary
122
128
  from .wiring import Signal, Wiring
123
129
 
@@ -144,12 +150,23 @@ __all__ = [
144
150
  "AnalogPathState",
145
151
  "DacOutput",
146
152
  "AdcReading",
153
+ "Calibration",
154
+ "CurrentOutput",
147
155
  "FpgaImageInfo",
148
156
  "LoopState",
149
157
  # flash + UART
150
158
  "FlashResult",
151
159
  "UartCapture",
152
160
  "UartSession",
161
+ "SpiSession",
162
+ "SpiFlashInfo",
163
+ "SpiFlashResult",
164
+ "SpiStreamResult",
165
+ # motor & battery emulator
166
+ "motor_emulator",
167
+ "MotorEmulator",
168
+ "EmulatorCalibration",
169
+ "BatteryModel",
153
170
  # CAN
154
171
  "can",
155
172
  "CanBus",
@@ -222,6 +239,7 @@ __all__ = [
222
239
  "DacOutputPath",
223
240
  "AnalogPath",
224
241
  "AdcSource",
242
+ "CalibrateSource",
225
243
  "Waveshape",
226
244
  "ReplayMapping",
227
245
  "LoopSource",
@@ -158,6 +158,16 @@ class Capabilities:
158
158
  nrst_pin: bool = False
159
159
  #: USB-C CC-line monitoring (rev3 pods): :meth:`BenchPod.usb_cc`.
160
160
  usb_cc: bool = False
161
+ #: SPI master on four LA pins and SPI NOR flash programming (gateware >= v45):
162
+ #: :meth:`BenchPod.open_spi`, :meth:`BenchPod.spi_flash`.
163
+ spi_master: bool = False
164
+ #: ``spi_stream``: send an upload staged in PSRAM through the SPI master in one CS frame
165
+ #: (an FPGA bitstream): :meth:`SpiSession.stream <embeddedci.benchpod.spi.SpiSession.stream>`.
166
+ spi_stream: bool = False
167
+ #: The pod can calibrate its own ADC and keeps the result: :meth:`BenchPod.calibrate`.
168
+ calibrate: bool = False
169
+ #: The pod can hold a current on its 4-20 mA output (J9): :meth:`BenchPod.current_out`.
170
+ current_out: bool = False
161
171
 
162
172
  #: LA I/O-bank voltage the pod currently reports (mV), if known.
163
173
  la_vccio_mv: int = 0
@@ -233,6 +243,10 @@ class Capabilities:
233
243
  ("capture_b64", "capture_b64"),
234
244
  ("nrst_pin", "nrst_pin"),
235
245
  ("usb_cc", "usb_cc"),
246
+ ("spi_master", "spi_master"),
247
+ ("spi_stream", "spi_stream"),
248
+ ("calibrate", "calibrate"),
249
+ ("current_out", "current_out"),
236
250
  ):
237
251
  if name in names and hasattr(c, attr):
238
252
  setattr(c, attr, True)
@@ -282,6 +296,8 @@ class Capabilities:
282
296
  ("capture_trigger", "cap.capture_trigger"), ("power_profile", "cap.power_profile"),
283
297
  ("capture_b64", "cap.capture_b64"),
284
298
  ("nrst_pin", "cap.nrst_pin"), ("usb_cc", "cap.usb_cc"),
299
+ ("spi_master", "cap.spi_master"), ("spi_stream", "cap.spi_stream"),
300
+ ("calibrate", "cap.calibrate"), ("current_out", "cap.current_out"),
285
301
  ):
286
302
  b = _as_bool(params, key)
287
303
  if b is not None: