embeddedci 2.2.0__tar.gz → 2.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {embeddedci-2.2.0 → embeddedci-2.3.0}/CHANGELOG.md +13 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/PKG-INFO +28 -1
- {embeddedci-2.2.0 → embeddedci-2.3.0}/README.md +27 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/pyproject.toml +1 -1
- {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/__init__.py +4 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/capabilities.py +5 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/client.py +67 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/pytest_plugin.py +35 -0
- embeddedci-2.3.0/src/embeddedci/benchpod/spi.py +195 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/api_surface.json +35 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/e2e/conftest.py +2 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/e2e/test_e2e_la13_14.py +7 -2
- embeddedci-2.3.0/tests/e2e/test_e2e_spi.py +64 -0
- embeddedci-2.3.0/tests/test_spi.py +205 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/.gitignore +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/LICENSE +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/docs/event-uart-design.md +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/examples/README.md +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/examples/test_bmp280.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/__init__.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/can.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/capture.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/ci.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/cloud_auth.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/connection.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/constants.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/control_loop.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/decode.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/discovery.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/dsp.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/errors.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/flash.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/gpio.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/i2c.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/lease.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/lowlevel.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/power.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/protocol.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/replay.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/results.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/sensor.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/server_api.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/state.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/transport/__init__.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/transport/base.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/transport/cloud.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/transport/serial.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/transport/tcp.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/uart.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/waveforms.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/wiring.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/py.typed +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/upload_build.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/conftest.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/e2e/README.md +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/e2e/e2e_helpers.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/e2e/test_e2e_cloud.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/e2e/test_e2e_dut.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/e2e/test_e2e_gpio.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/e2e/test_e2e_pod.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/e2e/test_e2e_rev3.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/e2e/test_e2e_usb.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/examples/test_bmp280_hil.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/examples/test_can_ecu_demo.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/examples/test_can_loopback_hil.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/examples/test_smoke.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_adc_fft_hw.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_api_surface.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_can.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_capabilities.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_capture.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_capture_replay_hw.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_ci.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_client_v2.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_cloud.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_connection.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_control_loop.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_control_loop_hw.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_dac_path_ranges.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_decode.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_dsp.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_flash_bridge.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_gpio.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_i2c_decode.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_i2c_parity.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_image_switching.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_integration.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_la_voltage_fixture.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_lease.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_pins_bias.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_protocol.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_replay.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_sensor.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_serial_autodetect.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_serial_json.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_serial_text_console.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_tcp_transport.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_timed_power.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_timing.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_trigger_power.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_uart_capture.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_uart_session.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_upload_build.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_waveforms.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_wiring.py +0 -0
- {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/testdata/i2c_decode_vectors.json +0 -0
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 2.3.0
|
|
6
|
+
|
|
7
|
+
- SPI master on the LA pins: `BenchPod.open_spi()` returns an `SpiSession` for raw full-duplex
|
|
8
|
+
transfers (`transfer`, CS held across calls with `hold_cs`) and SPI NOR flash work
|
|
9
|
+
(`flash_id`, `flash_read`, `flash_erase`, `flash_write`, `flash_program`).
|
|
10
|
+
`BenchPod.spi_flash(image, addr)` erases, writes and verifies a whole image in one call, and can
|
|
11
|
+
hold the DUT in reset while it runs (`hold_reset=True`). Pins come from the wiring profile
|
|
12
|
+
(`spi_sclk`, `spi_mosi`, `spi_miso`, `spi_cs`) or as arguments; up to 6 MHz, modes 0 and 3.
|
|
13
|
+
Needs firmware 3.3+ with gateware v45 (capability `spi_master`, new `Capabilities.spi_master`).
|
|
14
|
+
Works over the LAN, USB and the cloud.
|
|
15
|
+
- pytest plugin: with `BENCHPOD_LIFT_DAC_LIMITS=1` a session fixture clears the pod's DAC output
|
|
16
|
+
limits for the run and writes the same limits back at the end, even when tests fail.
|
|
17
|
+
|
|
5
18
|
## 2.2.0
|
|
6
19
|
|
|
7
20
|
- Cloud: the session token the SDK mints is renewed shortly before it expires, and once more if the
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: embeddedci
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.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
|
|
@@ -541,6 +541,33 @@ 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
|
+
|
|
544
571
|
## UART
|
|
545
572
|
|
|
546
573
|
`rx` is the LA channel the pod **samples** (wire the DUT's TX here); `tx` is the channel the pod
|
|
@@ -503,6 +503,33 @@ 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
|
+
|
|
506
533
|
## UART
|
|
507
534
|
|
|
508
535
|
`rx` is the LA channel the pod **samples** (wire the DUT's TX here); `tx` is the channel the pod
|
|
@@ -118,6 +118,7 @@ from .state import (
|
|
|
118
118
|
UsbCcStatus,
|
|
119
119
|
)
|
|
120
120
|
from .uart import UartCapture, UartSession
|
|
121
|
+
from .spi import SpiFlashInfo, SpiFlashResult, SpiSession
|
|
121
122
|
from .waveforms import Waveform, WaveformLibrary
|
|
122
123
|
from .wiring import Signal, Wiring
|
|
123
124
|
|
|
@@ -150,6 +151,9 @@ __all__ = [
|
|
|
150
151
|
"FlashResult",
|
|
151
152
|
"UartCapture",
|
|
152
153
|
"UartSession",
|
|
154
|
+
"SpiSession",
|
|
155
|
+
"SpiFlashInfo",
|
|
156
|
+
"SpiFlashResult",
|
|
153
157
|
# CAN
|
|
154
158
|
"can",
|
|
155
159
|
"CanBus",
|
|
@@ -158,6 +158,9 @@ 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
|
|
161
164
|
|
|
162
165
|
#: LA I/O-bank voltage the pod currently reports (mV), if known.
|
|
163
166
|
la_vccio_mv: int = 0
|
|
@@ -233,6 +236,7 @@ class Capabilities:
|
|
|
233
236
|
("capture_b64", "capture_b64"),
|
|
234
237
|
("nrst_pin", "nrst_pin"),
|
|
235
238
|
("usb_cc", "usb_cc"),
|
|
239
|
+
("spi_master", "spi_master"),
|
|
236
240
|
):
|
|
237
241
|
if name in names and hasattr(c, attr):
|
|
238
242
|
setattr(c, attr, True)
|
|
@@ -282,6 +286,7 @@ class Capabilities:
|
|
|
282
286
|
("capture_trigger", "cap.capture_trigger"), ("power_profile", "cap.power_profile"),
|
|
283
287
|
("capture_b64", "cap.capture_b64"),
|
|
284
288
|
("nrst_pin", "cap.nrst_pin"), ("usb_cc", "cap.usb_cc"),
|
|
289
|
+
("spi_master", "cap.spi_master"),
|
|
285
290
|
):
|
|
286
291
|
b = _as_bool(params, key)
|
|
287
292
|
if b is not None:
|
|
@@ -39,6 +39,7 @@ from . import dsp as _dsp
|
|
|
39
39
|
from . import flash as _flash
|
|
40
40
|
from . import i2c as _i2c
|
|
41
41
|
from . import sensor as _sensor
|
|
42
|
+
from . import spi as _spi
|
|
42
43
|
from . import uart as _uart
|
|
43
44
|
from .capabilities import Capabilities
|
|
44
45
|
from .constants import (
|
|
@@ -849,6 +850,72 @@ class BenchPod:
|
|
|
849
850
|
link = self._uart_link(rx, tx, baud)
|
|
850
851
|
return _uart.UartSession(link, max_buffer=max_buffer)
|
|
851
852
|
|
|
853
|
+
# -- SPI master / SPI flash ------------------------------------------------
|
|
854
|
+
|
|
855
|
+
def open_spi(
|
|
856
|
+
self,
|
|
857
|
+
*,
|
|
858
|
+
sck: Union[Pin, int, str, None] = None,
|
|
859
|
+
mosi: Union[Pin, int, str, None] = None,
|
|
860
|
+
miso: Union[Pin, int, str, None] = None,
|
|
861
|
+
cs: Union[Pin, int, str, None] = None,
|
|
862
|
+
hz: int = 1_000_000,
|
|
863
|
+
mode: int = 0,
|
|
864
|
+
) -> _spi.SpiSession:
|
|
865
|
+
"""Arm the SPI master on four LA pins and return a :class:`~embeddedci.benchpod.spi.SpiSession`
|
|
866
|
+
(a context manager; closing it releases the pins)::
|
|
867
|
+
|
|
868
|
+
with bp.open_spi(sck=13, mosi=14, miso=6, cs=5, hz=6_000_000) as spi:
|
|
869
|
+
print(spi.flash_id())
|
|
870
|
+
spi.flash_program(open("fw.bin", "rb").read())
|
|
871
|
+
|
|
872
|
+
Omitted pins come from the wiring profile (``spi_sclk``, ``spi_mosi``, ``spi_miso``,
|
|
873
|
+
``spi_cs``). ``hz`` is rounded down to 24 MHz / (2 * n), n = 2..63 (6 MHz .. 190 kHz); the
|
|
874
|
+
session's ``hz`` is the rate used. ``mode`` is 0 or 3. Needs gateware v45+
|
|
875
|
+
(capability ``spi_master``); an SWD flash cannot run while a session is open.
|
|
876
|
+
"""
|
|
877
|
+
self._require_capability("spi_master", "the SPI master")
|
|
878
|
+
if mode not in (0, 3):
|
|
879
|
+
raise ValueError("mode must be 0 or 3")
|
|
880
|
+
req = {"cmd": "spi_start", "sck": self._wired(sck, "spi_sclk"), "mosi": self._wired(mosi, "spi_mosi"),
|
|
881
|
+
"miso": self._wired(miso, "spi_miso"), "cs": self._wired(cs, "spi_cs"),
|
|
882
|
+
"hz": int(hz), "mode": int(mode)}
|
|
883
|
+
return _spi.SpiSession(self.command, _dict(self.command(req)))
|
|
884
|
+
|
|
885
|
+
def spi_flash(
|
|
886
|
+
self,
|
|
887
|
+
image: Union[bytes, str, "os.PathLike[str]"],
|
|
888
|
+
addr: int = 0,
|
|
889
|
+
*,
|
|
890
|
+
erase: bool = True,
|
|
891
|
+
verify: bool = True,
|
|
892
|
+
hold_reset: bool = False,
|
|
893
|
+
sck: Union[Pin, int, str, None] = None,
|
|
894
|
+
mosi: Union[Pin, int, str, None] = None,
|
|
895
|
+
miso: Union[Pin, int, str, None] = None,
|
|
896
|
+
cs: Union[Pin, int, str, None] = None,
|
|
897
|
+
hz: int = 6_000_000,
|
|
898
|
+
mode: int = 0,
|
|
899
|
+
progress: Optional[_spi.Progress] = None,
|
|
900
|
+
) -> _spi.SpiFlashResult:
|
|
901
|
+
"""Program an image (bytes or a file path) into the SPI NOR flash on the SPI pins.
|
|
902
|
+
|
|
903
|
+
Erases the sectors the image covers (unless ``erase=False``), writes it 768 bytes per
|
|
904
|
+
command and verifies every chunk (unless ``verify=False``). ``hold_reset`` holds the DUT
|
|
905
|
+
in reset for the whole job (rev3 pods), so its own controller does not drive the same
|
|
906
|
+
bus; it is released again afterwards, also on failure. Pins come from the wiring
|
|
907
|
+
profile unless given. ``progress(done, total)`` is called as it goes.
|
|
908
|
+
"""
|
|
909
|
+
data = bytes(image) if isinstance(image, (bytes, bytearray, memoryview)) else open(image, "rb").read()
|
|
910
|
+
if hold_reset:
|
|
911
|
+
self.set_reset(True)
|
|
912
|
+
try:
|
|
913
|
+
with self.open_spi(sck=sck, mosi=mosi, miso=miso, cs=cs, hz=hz, mode=mode) as spi:
|
|
914
|
+
return spi.flash_program(data, addr, erase=erase, verify=verify, progress=progress)
|
|
915
|
+
finally:
|
|
916
|
+
if hold_reset:
|
|
917
|
+
self.set_reset(False)
|
|
918
|
+
|
|
852
919
|
# -- ADC / logic-analyzer capture -----------------------------------------
|
|
853
920
|
|
|
854
921
|
def capture_adc(self, samples: int = 4096, *, sample_rate_hz: Optional[float] = None,
|
|
@@ -280,6 +280,41 @@ def benchpod_connection(pytestconfig: "pytest.Config") -> str:
|
|
|
280
280
|
return conn
|
|
281
281
|
|
|
282
282
|
|
|
283
|
+
LIFT_DAC_LIMITS_ENV = "BENCHPOD_LIFT_DAC_LIMITS"
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@pytest.fixture(scope="session", autouse=True)
|
|
287
|
+
def _benchpod_lift_dac_limits(pytestconfig: "pytest.Config") -> Iterator[None]:
|
|
288
|
+
"""With ``BENCHPOD_LIFT_DAC_LIMITS=1``: clear the pod's DAC output limits for the session and
|
|
289
|
+
put the SAME limits back at the end, even when tests fail.
|
|
290
|
+
|
|
291
|
+
The limits protect an external output stage (a solar simulator module on the 5 V path, say).
|
|
292
|
+
With the stage switched off a hardware test run may drive the DAC freely; without the variable
|
|
293
|
+
nothing changes and the pod refuses what the limits forbid. Opens its own short connection, so
|
|
294
|
+
it applies whichever fixture a suite then uses to talk to the pod."""
|
|
295
|
+
conn = _resolve_connection(pytestconfig)
|
|
296
|
+
if os.environ.get(LIFT_DAC_LIMITS_ENV) != "1" or not conn:
|
|
297
|
+
yield
|
|
298
|
+
return
|
|
299
|
+
saved = None
|
|
300
|
+
try:
|
|
301
|
+
with BenchPod(conn) as pod:
|
|
302
|
+
limits = pod.command({"cmd": "dac_limits"})
|
|
303
|
+
if isinstance(limits, dict) and limits.get("enabled"):
|
|
304
|
+
pod.command({"cmd": "dac_limits", "enabled": False})
|
|
305
|
+
saved = limits
|
|
306
|
+
except Exception as exc: # older firmware has no dac_limits: nothing to do
|
|
307
|
+
print(f"[benchpod] {LIFT_DAC_LIMITS_ENV}: could not read/clear DAC limits: {exc}")
|
|
308
|
+
try:
|
|
309
|
+
yield
|
|
310
|
+
finally:
|
|
311
|
+
if saved is not None:
|
|
312
|
+
restore = {"cmd": "dac_limits", "path": saved["path"], "inverted": saved["inverted"],
|
|
313
|
+
"min_mv": saved["min_mv"], "max_mv": saved["max_mv"]}
|
|
314
|
+
with BenchPod(conn) as pod:
|
|
315
|
+
pod.command(restore)
|
|
316
|
+
|
|
317
|
+
|
|
283
318
|
@pytest.fixture(scope="session")
|
|
284
319
|
def benchpod_la_voltage() -> Optional[float]:
|
|
285
320
|
"""The I/O voltage of the board under test, selected on the pod when the session connects.
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""SPI master on the LA pins: raw transfers and SPI NOR flash programming.
|
|
2
|
+
|
|
3
|
+
Firmware 3.x with gateware v45+ (capability ``spi_master``) runs an SPI master on any four LA
|
|
4
|
+
channels (SCK, MOSI, MISO, CS), up to 6 MHz, modes 0 and 3. It shares the SWD engine, so an SPI
|
|
5
|
+
session and an SWD flash exclude each other. The flash operations are the pod's own
|
|
6
|
+
``spi_flash`` command: standard 25-series parts (W25Q, MX25, GD25, IS25, ...), 3-byte addresses
|
|
7
|
+
(the first 16 MB). Every request and reply fits one cloud command frame, so all of this works over
|
|
8
|
+
the LAN, the USB console and the cloud alike.
|
|
9
|
+
|
|
10
|
+
Open a session with :meth:`BenchPod.open_spi`; program a whole image with :meth:`BenchPod.spi_flash`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Any, Callable, Dict, Optional, Tuple
|
|
19
|
+
|
|
20
|
+
#: Bytes per ``spi_xfer`` / ``spi_flash write`` command, and per ``spi_flash read``.
|
|
21
|
+
XFER_MAX = 768
|
|
22
|
+
READ_MAX = 1024
|
|
23
|
+
#: Bytes per ``spi_flash erase`` command: at most 16 block erases, a few seconds.
|
|
24
|
+
ERASE_STEP = 1 << 20
|
|
25
|
+
|
|
26
|
+
Progress = Callable[[int, int], None]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _b64(data: bytes) -> str:
|
|
30
|
+
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _unb64(text: str) -> bytes:
|
|
34
|
+
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class SpiFlashInfo:
|
|
39
|
+
"""What :meth:`SpiSession.flash_id` found on the bus."""
|
|
40
|
+
|
|
41
|
+
#: JEDEC ID as 6 hex digits, e.g. ``"ef4017"`` (Winbond W25Q64).
|
|
42
|
+
jedec_id: str
|
|
43
|
+
#: False when the ID read all 00 or all FF: nothing is answering on those pins.
|
|
44
|
+
present: bool
|
|
45
|
+
#: Capacity in bytes from the ID, or 0 when the ID does not encode it.
|
|
46
|
+
size: int
|
|
47
|
+
#: Status register 1.
|
|
48
|
+
status: int
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class SpiFlashResult:
|
|
53
|
+
"""The outcome of :meth:`SpiSession.flash_program` / :meth:`BenchPod.spi_flash`."""
|
|
54
|
+
|
|
55
|
+
jedec_id: str
|
|
56
|
+
addr: int
|
|
57
|
+
length: int
|
|
58
|
+
#: Bytes erased (whole 4 KB sectors covering the image), 0 with ``erase=False``.
|
|
59
|
+
erased: int
|
|
60
|
+
verified: bool
|
|
61
|
+
seconds: float
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class SpiSession:
|
|
65
|
+
"""An armed SPI master (from :meth:`BenchPod.open_spi`). Use it as a context manager, or call
|
|
66
|
+
:meth:`close` to release the four pins."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, command: Callable[[Dict[str, Any]], Any], info: Dict[str, Any]) -> None:
|
|
69
|
+
self._command = command
|
|
70
|
+
self.sck = int(info.get("sck", 0))
|
|
71
|
+
self.mosi = int(info.get("mosi", 0))
|
|
72
|
+
self.miso = int(info.get("miso", 0))
|
|
73
|
+
self.cs = int(info.get("cs", 0))
|
|
74
|
+
#: The SCK rate the pod picked (at or below the one asked for).
|
|
75
|
+
self.hz = int(info.get("hz", 0))
|
|
76
|
+
self.mode = int(info.get("mode", 0))
|
|
77
|
+
self._closed = False
|
|
78
|
+
|
|
79
|
+
# -- lifetime ----------------------------------------------------------------------------
|
|
80
|
+
def close(self) -> None:
|
|
81
|
+
"""Stop the SPI master and release its pins (they go back to high-Z). Idempotent."""
|
|
82
|
+
if not self._closed:
|
|
83
|
+
self._closed = True
|
|
84
|
+
self._command({"cmd": "spi_stop"})
|
|
85
|
+
|
|
86
|
+
def __enter__(self) -> "SpiSession":
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
def __exit__(self, *exc: object) -> None:
|
|
90
|
+
self.close()
|
|
91
|
+
|
|
92
|
+
# -- raw transfers -----------------------------------------------------------------------
|
|
93
|
+
def transfer(self, data: bytes, *, hold_cs: bool = False) -> bytes:
|
|
94
|
+
"""Full duplex: clock ``data`` out on MOSI and return the bytes clocked in on MISO.
|
|
95
|
+
|
|
96
|
+
CS is asserted before the first byte and released after the last, unless ``hold_cs``
|
|
97
|
+
keeps it asserted for the next :meth:`transfer` (one transaction over several calls).
|
|
98
|
+
Longer data is split into 768-byte commands with CS held between them."""
|
|
99
|
+
data = bytes(data)
|
|
100
|
+
if not data:
|
|
101
|
+
raise ValueError("transfer needs at least one byte")
|
|
102
|
+
out = bytearray()
|
|
103
|
+
for off in range(0, len(data), XFER_MAX):
|
|
104
|
+
last = off + XFER_MAX >= len(data)
|
|
105
|
+
reply = self._command({"cmd": "spi_xfer", "tx": _b64(data[off:off + XFER_MAX]),
|
|
106
|
+
"cs": "release" if last and not hold_cs else "hold"})
|
|
107
|
+
out += _unb64(str(reply["rx"]))
|
|
108
|
+
return bytes(out)
|
|
109
|
+
|
|
110
|
+
# -- SPI NOR flash -----------------------------------------------------------------------
|
|
111
|
+
def flash_id(self) -> SpiFlashInfo:
|
|
112
|
+
"""Read the JEDEC ID and status register."""
|
|
113
|
+
r = self._command({"cmd": "spi_flash", "op": "id"})
|
|
114
|
+
return SpiFlashInfo(jedec_id=str(r["id"]), present=bool(r["present"]),
|
|
115
|
+
size=int(r.get("size", 0)), status=int(r.get("status", 0)))
|
|
116
|
+
|
|
117
|
+
def flash_read(self, addr: int, length: int, *, progress: Optional[Progress] = None) -> bytes:
|
|
118
|
+
"""Read ``length`` bytes from ``addr`` (1 KB per command)."""
|
|
119
|
+
if length <= 0:
|
|
120
|
+
raise ValueError("length must be positive")
|
|
121
|
+
out = bytearray()
|
|
122
|
+
while len(out) < length:
|
|
123
|
+
n = min(READ_MAX, length - len(out))
|
|
124
|
+
r = self._command({"cmd": "spi_flash", "op": "read", "addr": addr + len(out), "len": n})
|
|
125
|
+
out += _unb64(str(r["data"]))
|
|
126
|
+
if progress:
|
|
127
|
+
progress(len(out), length)
|
|
128
|
+
return bytes(out)
|
|
129
|
+
|
|
130
|
+
def flash_erase(self, addr: int, length: int, *, progress: Optional[Progress] = None) -> Tuple[int, int]:
|
|
131
|
+
"""Erase every 4 KB sector ``[addr, addr+length)`` touches (64 KB blocks where whole).
|
|
132
|
+
Returns the range actually erased as ``(start, length)``; it is sector-aligned."""
|
|
133
|
+
if length <= 0:
|
|
134
|
+
raise ValueError("length must be positive")
|
|
135
|
+
start = end = None
|
|
136
|
+
pos, stop = addr, addr + length
|
|
137
|
+
while pos < stop:
|
|
138
|
+
n = min(ERASE_STEP, stop - pos)
|
|
139
|
+
r = self._command({"cmd": "spi_flash", "op": "erase", "addr": pos, "len": n})
|
|
140
|
+
s, l = int(r["addr"]), int(r["len"])
|
|
141
|
+
start = s if start is None else start
|
|
142
|
+
end = s + l
|
|
143
|
+
pos = max(pos + n, end)
|
|
144
|
+
if progress:
|
|
145
|
+
progress(min(pos, stop) - addr, length)
|
|
146
|
+
assert start is not None and end is not None
|
|
147
|
+
return start, end - start
|
|
148
|
+
|
|
149
|
+
def flash_chip_erase(self) -> float:
|
|
150
|
+
"""Erase the whole chip; returns the seconds the pod reported. A large part can take
|
|
151
|
+
minutes, longer than a cloud command may wait: prefer :meth:`flash_erase` there."""
|
|
152
|
+
r = self._command({"cmd": "spi_flash", "op": "chip_erase"})
|
|
153
|
+
return int(r.get("ms", 0)) / 1000.0
|
|
154
|
+
|
|
155
|
+
def flash_write(self, addr: int, data: bytes, *, verify: bool = True,
|
|
156
|
+
progress: Optional[Progress] = None) -> None:
|
|
157
|
+
"""Program ``data`` at ``addr`` (768 bytes per command; the range must be erased).
|
|
158
|
+
With ``verify`` the pod reads every chunk back and fails on the first difference."""
|
|
159
|
+
data = bytes(data)
|
|
160
|
+
for off in range(0, len(data), XFER_MAX):
|
|
161
|
+
req: Dict[str, Any] = {"cmd": "spi_flash", "op": "write", "addr": addr + off,
|
|
162
|
+
"data": _b64(data[off:off + XFER_MAX])}
|
|
163
|
+
if not verify:
|
|
164
|
+
req["verify"] = False
|
|
165
|
+
self._command(req)
|
|
166
|
+
if progress:
|
|
167
|
+
progress(min(off + XFER_MAX, len(data)), len(data))
|
|
168
|
+
|
|
169
|
+
def flash_program(self, data: bytes, addr: int = 0, *, erase: bool = True, verify: bool = True,
|
|
170
|
+
progress: Optional[Progress] = None) -> SpiFlashResult:
|
|
171
|
+
"""Erase (unless ``erase=False``) and write a whole image, verifying every chunk.
|
|
172
|
+
|
|
173
|
+
``progress(done, total)`` counts erase bytes first, then written bytes.
|
|
174
|
+
Raises :class:`~embeddedci.benchpod.errors.FirmwareError` when no flash answers."""
|
|
175
|
+
data = bytes(data)
|
|
176
|
+
if not data:
|
|
177
|
+
raise ValueError("the image is empty")
|
|
178
|
+
t0 = time.monotonic()
|
|
179
|
+
info = self.flash_id()
|
|
180
|
+
if not info.present:
|
|
181
|
+
from .errors import FirmwareError
|
|
182
|
+
raise FirmwareError(f"no SPI flash answers on SCK LA{self.sck} / MISO LA{self.miso} "
|
|
183
|
+
f"(JEDEC ID {info.jedec_id})", cmd="spi_flash")
|
|
184
|
+
if info.size and addr + len(data) > info.size:
|
|
185
|
+
raise ValueError(f"{len(data)} bytes at 0x{addr:x} do not fit the {info.size}-byte flash")
|
|
186
|
+
total = (len(data) if erase else 0) + len(data)
|
|
187
|
+
erased = 0
|
|
188
|
+
if erase:
|
|
189
|
+
_, erased = self.flash_erase(addr, len(data),
|
|
190
|
+
progress=(lambda d, t: progress(d, total)) if progress else None)
|
|
191
|
+
base = len(data) if erase else 0
|
|
192
|
+
self.flash_write(addr, data, verify=verify,
|
|
193
|
+
progress=(lambda d, t: progress(base + d, total)) if progress else None)
|
|
194
|
+
return SpiFlashResult(jedec_id=info.jedec_id, addr=addr, length=len(data), erased=erased,
|
|
195
|
+
verified=verify, seconds=time.monotonic() - t0)
|
|
@@ -69,6 +69,7 @@
|
|
|
69
69
|
"loop_probe": "(self) -> '_control_loop.IVPoint'",
|
|
70
70
|
"measure_power": "(self, duration: 'float', *, efuse: 'Optional[Union[Efuse, int]]' = None, rate_hz: 'float' = 500.0, keep_samples: 'int' = 0) -> 'PowerProfile'",
|
|
71
71
|
"open_can": "(self, *, bitrate: 'int' = 500000, mode: 'CanMode' = 'normal', term: 'bool' = False, fd: 'bool' = False) -> '_can.CanBus'",
|
|
72
|
+
"open_spi": "(self, *, sck: 'Union[Pin, int, str, None]' = None, mosi: 'Union[Pin, int, str, None]' = None, miso: 'Union[Pin, int, str, None]' = None, cs: 'Union[Pin, int, str, None]' = None, hz: 'int' = 1000000, mode: 'int' = 0) -> '_spi.SpiSession'",
|
|
72
73
|
"open_uart": "(self, *, rx: 'Union[Pin, int, str, None]' = None, tx: 'Union[Pin, int, str, None]' = None, baud: 'Optional[int]' = None, max_buffer: 'int' = 1048576) -> '_uart.UartSession'",
|
|
73
74
|
"pin_levels": "(self) -> 'Dict[int, int]'",
|
|
74
75
|
"ping": "(self) -> 'Any'",
|
|
@@ -94,6 +95,7 @@
|
|
|
94
95
|
"set_pull": "(self, la: 'Union[Pin, int]', enabled: 'bool') -> 'PullState'",
|
|
95
96
|
"set_reset": "(self, asserted: 'bool') -> 'ResetState'",
|
|
96
97
|
"signal": "(self, name: 'str') -> 'GpioPin'",
|
|
98
|
+
"spi_flash": "(self, image: \"Union[bytes, str, 'os.PathLike[str]']\", addr: 'int' = 0, *, erase: 'bool' = True, verify: 'bool' = True, hold_reset: 'bool' = False, sck: 'Union[Pin, int, str, None]' = None, mosi: 'Union[Pin, int, str, None]' = None, miso: 'Union[Pin, int, str, None]' = None, cs: 'Union[Pin, int, str, None]' = None, hz: 'int' = 6000000, mode: 'int' = 0, progress: 'Optional[_spi.Progress]' = None) -> '_spi.SpiFlashResult'",
|
|
97
99
|
"status": "(self) -> 'Dict[str, Any]'",
|
|
98
100
|
"target_power": "(self, efuse: 'Optional[Union[Efuse, int]]' = None, *, on: 'bool', delay: 'Optional[float]' = None) -> 'None'",
|
|
99
101
|
"target_status": "(self) -> 'TargetStatus'",
|
|
@@ -188,6 +190,7 @@
|
|
|
188
190
|
"capture_b64",
|
|
189
191
|
"nrst_pin",
|
|
190
192
|
"usb_cc",
|
|
193
|
+
"spi_master",
|
|
191
194
|
"la_vccio_mv",
|
|
192
195
|
"safe_mode",
|
|
193
196
|
"safe_reason",
|
|
@@ -603,6 +606,24 @@
|
|
|
603
606
|
],
|
|
604
607
|
"to_dict": "(self) -> 'Dict[str, Any]'"
|
|
605
608
|
},
|
|
609
|
+
"SpiFlashInfo": {
|
|
610
|
+
"__fields__": [
|
|
611
|
+
"jedec_id",
|
|
612
|
+
"present",
|
|
613
|
+
"size",
|
|
614
|
+
"status"
|
|
615
|
+
]
|
|
616
|
+
},
|
|
617
|
+
"SpiFlashResult": {
|
|
618
|
+
"__fields__": [
|
|
619
|
+
"jedec_id",
|
|
620
|
+
"addr",
|
|
621
|
+
"length",
|
|
622
|
+
"erased",
|
|
623
|
+
"verified",
|
|
624
|
+
"seconds"
|
|
625
|
+
]
|
|
626
|
+
},
|
|
606
627
|
"SpiFrame": {
|
|
607
628
|
"__fields__": [
|
|
608
629
|
"index",
|
|
@@ -614,6 +635,17 @@
|
|
|
614
635
|
"miso_hex"
|
|
615
636
|
]
|
|
616
637
|
},
|
|
638
|
+
"SpiSession": {
|
|
639
|
+
"__init__": "(self, command: 'Callable[[Dict[str, Any]], Any]', info: 'Dict[str, Any]') -> 'None'",
|
|
640
|
+
"close": "(self) -> 'None'",
|
|
641
|
+
"flash_chip_erase": "(self) -> 'float'",
|
|
642
|
+
"flash_erase": "(self, addr: 'int', length: 'int', *, progress: 'Optional[Progress]' = None) -> 'Tuple[int, int]'",
|
|
643
|
+
"flash_id": "(self) -> 'SpiFlashInfo'",
|
|
644
|
+
"flash_program": "(self, data: 'bytes', addr: 'int' = 0, *, erase: 'bool' = True, verify: 'bool' = True, progress: 'Optional[Progress]' = None) -> 'SpiFlashResult'",
|
|
645
|
+
"flash_read": "(self, addr: 'int', length: 'int', *, progress: 'Optional[Progress]' = None) -> 'bytes'",
|
|
646
|
+
"flash_write": "(self, addr: 'int', data: 'bytes', *, verify: 'bool' = True, progress: 'Optional[Progress]' = None) -> 'None'",
|
|
647
|
+
"transfer": "(self, data: 'bytes', *, hold_cs: 'bool' = False) -> 'bytes'"
|
|
648
|
+
},
|
|
617
649
|
"TargetStatus": {
|
|
618
650
|
"__fields__": [
|
|
619
651
|
"internal",
|
|
@@ -838,7 +870,10 @@
|
|
|
838
870
|
"ServerApi",
|
|
839
871
|
"ServerApiError",
|
|
840
872
|
"Signal",
|
|
873
|
+
"SpiFlashInfo",
|
|
874
|
+
"SpiFlashResult",
|
|
841
875
|
"SpiFrame",
|
|
876
|
+
"SpiSession",
|
|
842
877
|
"TargetStatus",
|
|
843
878
|
"TargetUnreachableError",
|
|
844
879
|
"TransportError",
|
|
@@ -51,6 +51,7 @@ class Bench:
|
|
|
51
51
|
board_rev: str # the PCB revision this bench's pod must report ("" = accept any)
|
|
52
52
|
nrst_la: Optional[int] # LA channel jumpered to the pod's reset pin (J1 pin 22), if any
|
|
53
53
|
ext_loop: bool = False # the DAC 0-5 V output is wired to the ADC's front SMA
|
|
54
|
+
spi: Optional[Tuple[int, int, int, int]] = None # SPI flash on (sck, mosi, miso, cs), or none
|
|
54
55
|
|
|
55
56
|
|
|
56
57
|
@pytest.fixture(scope="session")
|
|
@@ -74,6 +75,7 @@ def bench() -> Bench:
|
|
|
74
75
|
board_rev=_env("BOARD_REV", ""),
|
|
75
76
|
nrst_la=int(_env("NRST_LA", "0")) or None,
|
|
76
77
|
ext_loop=_env("EXT_LOOP", "0") == "1",
|
|
78
|
+
spi=tuple(int(x) for x in _env("SPI", "").split(",")) if _env("SPI", "") else None, # type: ignore[arg-type]
|
|
77
79
|
)
|
|
78
80
|
|
|
79
81
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
pytest packages/embeddedci/tests/e2e/test_e2e_la13_14.py --benchpod-connection=<pod host>
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
LA13/LA14 must be free of drivers (device INPUTS are fine; override with BENCHPOD_E2E_TOP_LA). Each path that used to stop
|
|
6
6
|
at 12 gets one check: the pin table, GPIO drive + read-back, a capture, a trigger, a UART session.
|
|
7
7
|
Firmware older than 3.1 only has 12 channels, so these skip there; on 3.1+ a missing channel fails.
|
|
8
8
|
"""
|
|
@@ -104,8 +104,13 @@ def test_level_trigger_on_la13(pod, top):
|
|
|
104
104
|
|
|
105
105
|
|
|
106
106
|
def test_step_train_on_la14_is_captured(pod, top):
|
|
107
|
-
"""An exact count: the 40 ms train starts 0.5 s into a 2 s capture, so none of it is missed.
|
|
107
|
+
"""An exact count: the 40 ms train starts 0.5 s into a 2 s capture, so none of it is missed.
|
|
108
|
+
|
|
109
|
+
LA14 is held low as a GPIO output first (the train then pulses the GPIO pin), so an undriven
|
|
110
|
+
line cannot add an edge: with something wired to LA14 (a device input) it otherwise floats
|
|
111
|
+
and picked up a stray edge now and then."""
|
|
108
112
|
_, la14 = top
|
|
113
|
+
pod.gpio(la14, "output", level=0)
|
|
109
114
|
result = {}
|
|
110
115
|
|
|
111
116
|
def run():
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Tier "spi": the SPI master and SPI NOR flash programming (gateware v45+, capability spi_master).
|
|
2
|
+
|
|
3
|
+
BENCHPOD_E2E_SPI=13,14,6,5 pytest packages/embeddedci/tests/e2e/test_e2e_spi.py --benchpod-connection=<pod host>
|
|
4
|
+
|
|
5
|
+
Needs a 25-series SPI NOR flash (W25Q, MX25, GD25, XT25, ...) wired to the pod: BENCHPOD_E2E_SPI
|
|
6
|
+
gives its SCK, MOSI (the chip's DI), MISO (DO) and CS as LA channels, powered at the LA voltage,
|
|
7
|
+
/WP and /HOLD tied high. Skips without it. The tests erase and write the 64 KB block at 0x10000.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from embeddedci.benchpod import FirmwareError, PinConflictError
|
|
17
|
+
|
|
18
|
+
pytestmark = pytest.mark.hardware
|
|
19
|
+
|
|
20
|
+
BASE = 0x10000
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture
|
|
24
|
+
def spi_pins(pod, bench):
|
|
25
|
+
if bench.spi is None:
|
|
26
|
+
pytest.skip("set BENCHPOD_E2E_SPI=sck,mosi,miso,cs to run the SPI flash tier")
|
|
27
|
+
if not pod.capabilities.spi_master:
|
|
28
|
+
pytest.skip("the pod has no SPI master (gateware v45+, capability spi_master)")
|
|
29
|
+
sck, mosi, miso, cs = bench.spi
|
|
30
|
+
return dict(sck=sck, mosi=mosi, miso=miso, cs=cs)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.mark.parametrize("hz,mode", [(1_000_000, 0), (6_000_000, 0), (6_000_000, 3)])
|
|
34
|
+
def test_program_and_read_back(pod, spi_pins, hz, mode):
|
|
35
|
+
image = os.urandom(20_000)
|
|
36
|
+
res = pod.spi_flash(image, BASE, hz=hz, mode=mode, **spi_pins)
|
|
37
|
+
assert res.verified and res.length == len(image) and res.erased >= len(image)
|
|
38
|
+
with pod.open_spi(hz=hz, mode=mode, **spi_pins) as spi:
|
|
39
|
+
assert spi.hz <= hz
|
|
40
|
+
assert spi.flash_read(BASE, len(image)) == image
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_raw_transfer_reads_the_jedec_id(pod, spi_pins):
|
|
44
|
+
with pod.open_spi(**spi_pins) as spi:
|
|
45
|
+
info = spi.flash_id()
|
|
46
|
+
assert info.present, f"no flash answering: {info}"
|
|
47
|
+
rx = spi.transfer(b"\x9f\x00\x00\x00")
|
|
48
|
+
assert rx[1:].hex() == info.jedec_id
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_writing_over_data_fails_verify(pod, spi_pins):
|
|
52
|
+
with pod.open_spi(**spi_pins) as spi:
|
|
53
|
+
spi.flash_erase(BASE, 4096)
|
|
54
|
+
spi.flash_write(BASE, b"\x00\x00")
|
|
55
|
+
with pytest.raises(FirmwareError, match="verify failed"):
|
|
56
|
+
spi.flash_write(BASE, b"\xff\xff")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_pins_are_owned_while_open_and_released_after(pod, spi_pins):
|
|
60
|
+
with pod.open_spi(**spi_pins):
|
|
61
|
+
with pytest.raises(PinConflictError):
|
|
62
|
+
pod.gpio(spi_pins["cs"], "output", level=1)
|
|
63
|
+
pod.gpio(spi_pins["cs"], "output", level=1) # free again once the session closed
|
|
64
|
+
pod.release_gpio()
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""SPI master + SPI flash, against a fake pod that speaks the firmware's spi_* contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
from typing import Any, Dict, List
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from embeddedci.benchpod import BenchPod, BenchPodError, FirmwareError, SpiFlashInfo, Wiring
|
|
11
|
+
from embeddedci.benchpod.spi import ERASE_STEP, READ_MAX, XFER_MAX
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def b64(d: bytes) -> str:
|
|
15
|
+
return base64.urlsafe_b64encode(d).decode().rstrip("=")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def unb64(t: str) -> bytes:
|
|
19
|
+
return base64.urlsafe_b64decode(t + "=" * (-len(t) % 4))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SpiPod:
|
|
23
|
+
"""The firmware's SPI commands over a 1 MB flash model (limits as in command_handler_spi.c)."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, *, caps=None, present=True) -> None:
|
|
26
|
+
self.caps = ["la", "la_pins", "spi_master", "nrst_pin"] if caps is None else caps
|
|
27
|
+
self.mem = bytearray(b"\xff" * (1 << 20))
|
|
28
|
+
self.present = present
|
|
29
|
+
self.armed = None
|
|
30
|
+
self.cs_held = False
|
|
31
|
+
self.commands: List[dict] = []
|
|
32
|
+
self.reset_log: List[bool] = []
|
|
33
|
+
self.fail_write_at = None
|
|
34
|
+
|
|
35
|
+
def status(self) -> Dict[str, Any]:
|
|
36
|
+
return {"board": "stm32h563", "adc_bits": 16, "version": "3.3.0", "caps": self.caps,
|
|
37
|
+
"nrst_pin": True}
|
|
38
|
+
|
|
39
|
+
def ping(self) -> Any:
|
|
40
|
+
return "pong"
|
|
41
|
+
|
|
42
|
+
def close(self) -> None:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
def command(self, req: dict) -> Any:
|
|
46
|
+
self.commands.append(req)
|
|
47
|
+
cmd = req["cmd"]
|
|
48
|
+
if cmd == "nrst":
|
|
49
|
+
self.reset_log.append(bool(req["assert"]))
|
|
50
|
+
return {"supported": True, "asserted": bool(req["assert"])}
|
|
51
|
+
if cmd == "spi_start":
|
|
52
|
+
pins = [req[k] for k in ("sck", "mosi", "miso", "cs")]
|
|
53
|
+
assert len(set(pins)) == 4 and all(1 <= p <= 14 for p in pins)
|
|
54
|
+
assert self.armed is None, "spi busy"
|
|
55
|
+
self.armed = req
|
|
56
|
+
half = max(2, min(63, -(-24_000_000 // (2 * req["hz"]))))
|
|
57
|
+
return {**{k: req[k] for k in ("sck", "mosi", "miso", "cs", "mode")}, "hz": 24_000_000 // (2 * half)}
|
|
58
|
+
if cmd == "spi_stop":
|
|
59
|
+
self.armed = None
|
|
60
|
+
return "spi stopped"
|
|
61
|
+
if self.armed is None:
|
|
62
|
+
raise FirmwareError("no SPI session: send spi_start first", cmd=cmd)
|
|
63
|
+
if cmd == "spi_xfer":
|
|
64
|
+
tx = unb64(req["tx"])
|
|
65
|
+
assert 1 <= len(tx) <= 768
|
|
66
|
+
self.cs_held = req.get("cs", "release") == "hold"
|
|
67
|
+
return {"rx": b64(bytes((b ^ 0xFF) for b in tx)), "cs": "held" if self.cs_held else "released"}
|
|
68
|
+
assert cmd == "spi_flash"
|
|
69
|
+
op = req["op"]
|
|
70
|
+
if op == "id":
|
|
71
|
+
return {"id": "ef4014" if self.present else "ffffff", "present": self.present,
|
|
72
|
+
"size": (1 << 20) if self.present else 0, "status": 0}
|
|
73
|
+
if not self.present:
|
|
74
|
+
raise FirmwareError("write enable did not stick: no flash answering, or it is write-protected", cmd=cmd)
|
|
75
|
+
if op == "read":
|
|
76
|
+
assert 1 <= req["len"] <= 1024
|
|
77
|
+
return {"addr": req["addr"], "len": req["len"],
|
|
78
|
+
"data": b64(bytes(self.mem[req["addr"]:req["addr"] + req["len"]]))}
|
|
79
|
+
if op == "erase":
|
|
80
|
+
assert 1 <= req["len"] <= 1 << 20
|
|
81
|
+
a = req["addr"] & ~0xFFF
|
|
82
|
+
end = (req["addr"] + req["len"] + 0xFFF) & ~0xFFF
|
|
83
|
+
self.mem[a:end] = b"\xff" * (end - a)
|
|
84
|
+
return {"addr": a, "len": end - a, "ms": 10}
|
|
85
|
+
if op == "write":
|
|
86
|
+
data = unb64(req["data"])
|
|
87
|
+
assert 1 <= len(data) <= 768
|
|
88
|
+
if self.fail_write_at is not None and req["addr"] >= self.fail_write_at:
|
|
89
|
+
raise FirmwareError(f"verify failed at 0x{req['addr']:06x}: not erased, write-protected, or a bad wire", cmd=cmd)
|
|
90
|
+
for i, b in enumerate(data):
|
|
91
|
+
self.mem[req["addr"] + i] &= b
|
|
92
|
+
return {"addr": req["addr"], "len": len(data), "verified": req.get("verify", True), "ms": 1}
|
|
93
|
+
raise AssertionError(op)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@pytest.fixture
|
|
97
|
+
def pod():
|
|
98
|
+
fake = SpiPod()
|
|
99
|
+
bp = BenchPod(transport=fake, wiring=Wiring(uart_rx=3, uart_tx=4, spi_sclk=13, spi_mosi=14, spi_miso=6, spi_cs=5))
|
|
100
|
+
return bp, fake
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_open_spi_takes_pins_from_wiring_and_rounds_hz(pod):
|
|
104
|
+
bp, fake = pod
|
|
105
|
+
with bp.open_spi(hz=5_000_000) as spi:
|
|
106
|
+
assert (spi.sck, spi.mosi, spi.miso, spi.cs) == (13, 14, 6, 5)
|
|
107
|
+
assert spi.hz == 4_000_000 # 24 MHz / (2 * 3): never faster than asked
|
|
108
|
+
assert fake.armed is None # closed: spi_stop sent
|
|
109
|
+
assert fake.commands[-1] == {"cmd": "spi_stop"}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_open_spi_needs_the_capability_and_a_valid_mode():
|
|
113
|
+
bp = BenchPod(transport=SpiPod(caps=["la"]), wiring=Wiring(spi_sclk=7, spi_mosi=8, spi_miso=9, spi_cs=10))
|
|
114
|
+
with pytest.raises(BenchPodError):
|
|
115
|
+
bp.open_spi()
|
|
116
|
+
bp = BenchPod(transport=SpiPod(), wiring=Wiring(spi_sclk=7, spi_mosi=8, spi_miso=9, spi_cs=10))
|
|
117
|
+
with pytest.raises(ValueError):
|
|
118
|
+
bp.open_spi(mode=1)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_open_spi_without_wiring_pins_says_which_is_missing():
|
|
122
|
+
bp = BenchPod(transport=SpiPod())
|
|
123
|
+
with pytest.raises(ValueError, match="spi_sclk"):
|
|
124
|
+
bp.open_spi()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_transfer_splits_long_data_and_holds_cs_between_chunks(pod):
|
|
128
|
+
bp, fake = pod
|
|
129
|
+
data = bytes(range(256)) * 7 # 1792 bytes: 768 + 768 + 256
|
|
130
|
+
with bp.open_spi() as spi:
|
|
131
|
+
rx = spi.transfer(data)
|
|
132
|
+
xf = [c for c in fake.commands if c["cmd"] == "spi_xfer"]
|
|
133
|
+
assert [len(unb64(c["tx"])) for c in xf] == [768, 768, 256]
|
|
134
|
+
assert [c["cs"] for c in xf] == ["hold", "hold", "release"]
|
|
135
|
+
assert rx == bytes(b ^ 0xFF for b in data)
|
|
136
|
+
spi.transfer(b"\x9f", hold_cs=True)
|
|
137
|
+
assert fake.cs_held
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_flash_id_read_erase_write_chunking(pod):
|
|
141
|
+
bp, fake = pod
|
|
142
|
+
with bp.open_spi() as spi:
|
|
143
|
+
assert spi.flash_id() == SpiFlashInfo(jedec_id="ef4014", present=True, size=1 << 20, status=0)
|
|
144
|
+
img = bytes((i * 7) & 0xFF for i in range(3000))
|
|
145
|
+
start, length = spi.flash_erase(0x1100, len(img))
|
|
146
|
+
assert (start, length) == (0x1000, 0x1000) # 0x1100..0x1cb8: one sector
|
|
147
|
+
spi.flash_write(0x1100, img)
|
|
148
|
+
writes = [c for c in fake.commands if c.get("op") == "write"]
|
|
149
|
+
assert [len(unb64(c["data"])) for c in writes] == [768, 768, 768, 696]
|
|
150
|
+
assert all("verify" not in c for c in writes) # verify is the default
|
|
151
|
+
assert spi.flash_read(0x1100, len(img)) == img
|
|
152
|
+
reads = [c for c in fake.commands if c.get("op") == "read"]
|
|
153
|
+
assert [c["len"] for c in reads] == [READ_MAX, READ_MAX, 3000 - 2 * READ_MAX]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_erase_is_sent_in_one_megabyte_steps(pod):
|
|
157
|
+
bp, fake = pod
|
|
158
|
+
fake.mem = bytearray(b"\xff" * (4 << 20))
|
|
159
|
+
with bp.open_spi() as spi:
|
|
160
|
+
spi.flash_erase(0, 2 * ERASE_STEP + 10)
|
|
161
|
+
erases = [c for c in fake.commands if c.get("op") == "erase"]
|
|
162
|
+
assert [c["len"] for c in erases] == [ERASE_STEP, ERASE_STEP, 10]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_spi_flash_programs_verifies_and_reports_progress(pod, tmp_path):
|
|
166
|
+
bp, fake = pod
|
|
167
|
+
img = bytes((i * 13 + 5) & 0xFF for i in range(10_000))
|
|
168
|
+
path = tmp_path / "fw.bin"
|
|
169
|
+
path.write_bytes(img)
|
|
170
|
+
seen = []
|
|
171
|
+
res = bp.spi_flash(str(path), 0x2000, progress=lambda d, t: seen.append((d, t)))
|
|
172
|
+
assert bytes(fake.mem[0x2000:0x2000 + len(img)]) == img
|
|
173
|
+
assert res.length == len(img) and res.verified and res.erased == 12288 and res.jedec_id == "ef4014"
|
|
174
|
+
assert seen[-1] == (2 * len(img), 2 * len(img)) and all(d <= t for d, t in seen)
|
|
175
|
+
assert fake.armed is None
|
|
176
|
+
assert fake.commands[0]["cmd"] == "spi_start" and fake.commands[0]["hz"] == 6_000_000
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_spi_flash_holds_reset_and_releases_it_on_failure(pod):
|
|
180
|
+
bp, fake = pod
|
|
181
|
+
fake.fail_write_at = 0x800
|
|
182
|
+
with pytest.raises(FirmwareError, match="verify failed"):
|
|
183
|
+
bp.spi_flash(bytes(4096), hold_reset=True)
|
|
184
|
+
assert fake.reset_log == [True, False] # held for the job, released after the failure
|
|
185
|
+
assert fake.armed is None # and the pins were released
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_spi_flash_refuses_an_empty_bus_before_erasing():
|
|
189
|
+
fake = SpiPod(present=False)
|
|
190
|
+
bp = BenchPod(transport=fake, wiring=Wiring(spi_sclk=7, spi_mosi=8, spi_miso=9, spi_cs=10))
|
|
191
|
+
with pytest.raises(FirmwareError, match="no SPI flash answers"):
|
|
192
|
+
bp.spi_flash(b"\x01\x02")
|
|
193
|
+
assert not [c for c in fake.commands if c.get("op") in ("erase", "write")]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_spi_flash_refuses_an_image_larger_than_the_part(pod):
|
|
197
|
+
bp, fake = pod
|
|
198
|
+
with pytest.raises(ValueError, match="do not fit"):
|
|
199
|
+
bp.spi_flash(bytes(4096), (1 << 20) - 100)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_capability_flag_parses_from_status_caps():
|
|
203
|
+
bp = BenchPod(transport=SpiPod())
|
|
204
|
+
assert bp.capabilities.spi_master
|
|
205
|
+
assert not BenchPod(transport=SpiPod(caps=["la"])).capabilities.spi_master
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|