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.
Files changed (106) hide show
  1. {embeddedci-2.2.0 → embeddedci-2.3.0}/CHANGELOG.md +13 -0
  2. {embeddedci-2.2.0 → embeddedci-2.3.0}/PKG-INFO +28 -1
  3. {embeddedci-2.2.0 → embeddedci-2.3.0}/README.md +27 -0
  4. {embeddedci-2.2.0 → embeddedci-2.3.0}/pyproject.toml +1 -1
  5. {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/__init__.py +4 -0
  6. {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/capabilities.py +5 -0
  7. {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/client.py +67 -0
  8. {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/pytest_plugin.py +35 -0
  9. embeddedci-2.3.0/src/embeddedci/benchpod/spi.py +195 -0
  10. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/api_surface.json +35 -0
  11. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/e2e/conftest.py +2 -0
  12. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/e2e/test_e2e_la13_14.py +7 -2
  13. embeddedci-2.3.0/tests/e2e/test_e2e_spi.py +64 -0
  14. embeddedci-2.3.0/tests/test_spi.py +205 -0
  15. {embeddedci-2.2.0 → embeddedci-2.3.0}/.gitignore +0 -0
  16. {embeddedci-2.2.0 → embeddedci-2.3.0}/LICENSE +0 -0
  17. {embeddedci-2.2.0 → embeddedci-2.3.0}/docs/event-uart-design.md +0 -0
  18. {embeddedci-2.2.0 → embeddedci-2.3.0}/examples/README.md +0 -0
  19. {embeddedci-2.2.0 → embeddedci-2.3.0}/examples/test_bmp280.py +0 -0
  20. {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/__init__.py +0 -0
  21. {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/can.py +0 -0
  22. {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/capture.py +0 -0
  23. {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/ci.py +0 -0
  24. {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/cloud_auth.py +0 -0
  25. {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/connection.py +0 -0
  26. {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/constants.py +0 -0
  27. {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/control_loop.py +0 -0
  28. {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/decode.py +0 -0
  29. {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/discovery.py +0 -0
  30. {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/dsp.py +0 -0
  31. {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/errors.py +0 -0
  32. {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/flash.py +0 -0
  33. {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/gpio.py +0 -0
  34. {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/i2c.py +0 -0
  35. {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/lease.py +0 -0
  36. {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/lowlevel.py +0 -0
  37. {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/power.py +0 -0
  38. {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/protocol.py +0 -0
  39. {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/replay.py +0 -0
  40. {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/results.py +0 -0
  41. {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/sensor.py +0 -0
  42. {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/server_api.py +0 -0
  43. {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/state.py +0 -0
  44. {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/transport/__init__.py +0 -0
  45. {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/transport/base.py +0 -0
  46. {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/transport/cloud.py +0 -0
  47. {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/transport/serial.py +0 -0
  48. {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/transport/tcp.py +0 -0
  49. {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/uart.py +0 -0
  50. {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/waveforms.py +0 -0
  51. {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/benchpod/wiring.py +0 -0
  52. {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/py.typed +0 -0
  53. {embeddedci-2.2.0 → embeddedci-2.3.0}/src/embeddedci/upload_build.py +0 -0
  54. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/conftest.py +0 -0
  55. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/e2e/README.md +0 -0
  56. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/e2e/e2e_helpers.py +0 -0
  57. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/e2e/test_e2e_cloud.py +0 -0
  58. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/e2e/test_e2e_dut.py +0 -0
  59. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/e2e/test_e2e_gpio.py +0 -0
  60. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/e2e/test_e2e_pod.py +0 -0
  61. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/e2e/test_e2e_rev3.py +0 -0
  62. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/e2e/test_e2e_usb.py +0 -0
  63. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/examples/test_bmp280_hil.py +0 -0
  64. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/examples/test_can_ecu_demo.py +0 -0
  65. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/examples/test_can_loopback_hil.py +0 -0
  66. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/examples/test_smoke.py +0 -0
  67. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_adc_fft_hw.py +0 -0
  68. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_api_surface.py +0 -0
  69. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_can.py +0 -0
  70. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_capabilities.py +0 -0
  71. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_capture.py +0 -0
  72. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_capture_replay_hw.py +0 -0
  73. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_ci.py +0 -0
  74. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_client_v2.py +0 -0
  75. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_cloud.py +0 -0
  76. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_connection.py +0 -0
  77. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_control_loop.py +0 -0
  78. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_control_loop_hw.py +0 -0
  79. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_dac_path_ranges.py +0 -0
  80. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_decode.py +0 -0
  81. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_dsp.py +0 -0
  82. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_flash_bridge.py +0 -0
  83. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_gpio.py +0 -0
  84. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_i2c_decode.py +0 -0
  85. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_i2c_parity.py +0 -0
  86. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_image_switching.py +0 -0
  87. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_integration.py +0 -0
  88. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_la_voltage_fixture.py +0 -0
  89. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_lease.py +0 -0
  90. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_pins_bias.py +0 -0
  91. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_protocol.py +0 -0
  92. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_replay.py +0 -0
  93. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_sensor.py +0 -0
  94. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_serial_autodetect.py +0 -0
  95. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_serial_json.py +0 -0
  96. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_serial_text_console.py +0 -0
  97. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_tcp_transport.py +0 -0
  98. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_timed_power.py +0 -0
  99. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_timing.py +0 -0
  100. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_trigger_power.py +0 -0
  101. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_uart_capture.py +0 -0
  102. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_uart_session.py +0 -0
  103. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_upload_build.py +0 -0
  104. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_waveforms.py +0 -0
  105. {embeddedci-2.2.0 → embeddedci-2.3.0}/tests/test_wiring.py +0 -0
  106. {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.2.0
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
@@ -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.3.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"
@@ -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
- Nothing may be wired to LA13/LA14 (override with BENCHPOD_E2E_TOP_LA). Each path that used to stop
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