embeddedci 2.4.0__tar.gz → 2.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. {embeddedci-2.4.0 → embeddedci-2.6.0}/CHANGELOG.md +28 -0
  2. {embeddedci-2.4.0 → embeddedci-2.6.0}/PKG-INFO +1 -1
  3. {embeddedci-2.4.0 → embeddedci-2.6.0}/pyproject.toml +1 -1
  4. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/can.py +2 -1
  5. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/capabilities.py +8 -0
  6. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/flash.py +3 -1
  7. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/state.py +10 -1
  8. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/transport/cloud.py +60 -13
  9. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/transport/tcp.py +16 -2
  10. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/api_surface.json +4 -0
  11. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/e2e/test_e2e_rev3.py +8 -2
  12. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_can.py +9 -0
  13. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_client_v2.py +22 -0
  14. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_cloud.py +122 -6
  15. {embeddedci-2.4.0 → embeddedci-2.6.0}/.gitignore +0 -0
  16. {embeddedci-2.4.0 → embeddedci-2.6.0}/LICENSE +0 -0
  17. {embeddedci-2.4.0 → embeddedci-2.6.0}/README.md +0 -0
  18. {embeddedci-2.4.0 → embeddedci-2.6.0}/docs/event-uart-design.md +0 -0
  19. {embeddedci-2.4.0 → embeddedci-2.6.0}/examples/README.md +0 -0
  20. {embeddedci-2.4.0 → embeddedci-2.6.0}/examples/test_bmp280.py +0 -0
  21. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/__init__.py +0 -0
  22. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/__init__.py +0 -0
  23. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/capture.py +0 -0
  24. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/ci.py +0 -0
  25. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/client.py +0 -0
  26. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/cloud_auth.py +0 -0
  27. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/connection.py +0 -0
  28. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/constants.py +0 -0
  29. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/control_loop.py +0 -0
  30. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/decode.py +0 -0
  31. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/discovery.py +0 -0
  32. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/dsp.py +0 -0
  33. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/errors.py +0 -0
  34. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/gpio.py +0 -0
  35. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/i2c.py +0 -0
  36. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/lease.py +0 -0
  37. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/lowlevel.py +0 -0
  38. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/motor_emulator.py +0 -0
  39. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/power.py +0 -0
  40. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/protocol.py +0 -0
  41. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/pytest_plugin.py +0 -0
  42. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/replay.py +0 -0
  43. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/results.py +0 -0
  44. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/sensor.py +0 -0
  45. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/server_api.py +0 -0
  46. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/spi.py +0 -0
  47. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/transport/__init__.py +0 -0
  48. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/transport/base.py +0 -0
  49. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/transport/serial.py +0 -0
  50. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/uart.py +0 -0
  51. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/waveforms.py +0 -0
  52. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/wiring.py +0 -0
  53. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/py.typed +0 -0
  54. {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/upload_build.py +0 -0
  55. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/conftest.py +0 -0
  56. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/e2e/README.md +0 -0
  57. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/e2e/conftest.py +0 -0
  58. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/e2e/e2e_helpers.py +0 -0
  59. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/e2e/test_e2e_cloud.py +0 -0
  60. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/e2e/test_e2e_dut.py +0 -0
  61. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/e2e/test_e2e_gpio.py +0 -0
  62. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/e2e/test_e2e_la13_14.py +0 -0
  63. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/e2e/test_e2e_pod.py +0 -0
  64. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/e2e/test_e2e_spi.py +0 -0
  65. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/e2e/test_e2e_usb.py +0 -0
  66. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/examples/test_bmp280_hil.py +0 -0
  67. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/examples/test_can_ecu_demo.py +0 -0
  68. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/examples/test_can_loopback_hil.py +0 -0
  69. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/examples/test_smoke.py +0 -0
  70. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_adc_fft_hw.py +0 -0
  71. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_api_surface.py +0 -0
  72. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_capabilities.py +0 -0
  73. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_capture.py +0 -0
  74. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_capture_replay_hw.py +0 -0
  75. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_ci.py +0 -0
  76. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_connection.py +0 -0
  77. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_control_loop.py +0 -0
  78. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_control_loop_hw.py +0 -0
  79. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_current_out_waveforms.py +0 -0
  80. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_dac_path_ranges.py +0 -0
  81. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_decode.py +0 -0
  82. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_dsp.py +0 -0
  83. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_flash_bridge.py +0 -0
  84. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_gpio.py +0 -0
  85. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_i2c_decode.py +0 -0
  86. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_i2c_parity.py +0 -0
  87. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_image_switching.py +0 -0
  88. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_integration.py +0 -0
  89. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_la_voltage_fixture.py +0 -0
  90. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_lease.py +0 -0
  91. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_motor_emulator.py +0 -0
  92. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_pins_bias.py +0 -0
  93. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_protocol.py +0 -0
  94. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_replay.py +0 -0
  95. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_sensor.py +0 -0
  96. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_serial_autodetect.py +0 -0
  97. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_serial_json.py +0 -0
  98. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_serial_text_console.py +0 -0
  99. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_spi.py +0 -0
  100. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_tcp_transport.py +0 -0
  101. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_timed_power.py +0 -0
  102. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_timing.py +0 -0
  103. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_trigger_power.py +0 -0
  104. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_uart_capture.py +0 -0
  105. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_uart_session.py +0 -0
  106. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_upload_build.py +0 -0
  107. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_waveforms.py +0 -0
  108. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_wiring.py +0 -0
  109. {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/testdata/i2c_decode_vectors.json +0 -0
@@ -1,5 +1,33 @@
1
1
  # Changelog — `embeddedci`
2
2
 
3
+ ## 2.6.0
4
+
5
+ - Cloud: the tunnel WebSocket sends the session token as an `Authorization: Bearer` header (and
6
+ the lease as `X-Benchpod-Lease`) instead of `?token=` in the URL, so the token stays out of
7
+ access logs. A tunnel error message never shows the token.
8
+ - Cloud: a client-side upload over `embeddedci:<device>` (`replay()` of a waveform, SPI PSRAM
9
+ staging) goes out in frames of at most 1 KiB, paced to 128 KiB/s. It used to be one WebSocket
10
+ frame, which the pod drops without an error above about 1.5 KB. The upload also skips the
11
+ `{"ack":N}` progress lines the pod sends on a cloud tunnel instead of taking the first one as
12
+ the completion.
13
+ - `PowerStatus.pod`: the pod's own 5 V draw, on boards that measure it (INA226 at 0x41, the
14
+ v3r1 analog and v3r2 digital boards). `PowerStatus.total_current` adds the DUT's internal rail
15
+ to it, which is what the USB input delivers. Both are `None` on other boards and on firmware
16
+ up to 3.5.1.
17
+ - `Capabilities.pod_current`: the board has that monitor. Parsed from the pod's `status` and the
18
+ server's `cap.pod_current`.
19
+
20
+ ## 2.5.0
21
+
22
+ - `Capabilities.can`: the pod has classic CAN on CAN+/CAN- (`open_can`). Parsed from the pod's
23
+ `status` and the server's `cap.can`. False on firmware before 3.5.1, which has CAN but does not
24
+ announce it. Firmware 3.5.1 announces it.
25
+ - `flash()` with `target_power` waits for the target to answer on every connect attempt, not only
26
+ the first, so a retry after a target that answered too early (a NUCLEO's ST-LINK still holding
27
+ it) waits for it to settle as well.
28
+ - `CanReadResult.overflow` is documented as what the pod reports: frames dropped since the last
29
+ `can_config` (a running total), not since the last read.
30
+
3
31
  ## 2.4.0
4
32
 
5
33
  - **Breaking:** the 4-20 mA measurement terminal (J8) is now `"current_in"` as an ADC source
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: embeddedci
3
- Version: 2.4.0
3
+ Version: 2.6.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "embeddedci"
7
- version = "2.4.0"
7
+ version = "2.6.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"
@@ -77,7 +77,8 @@ class CanReadResult:
77
77
  """Frames drained from the pod's RX ring by one ``BenchPod.can_read``."""
78
78
 
79
79
  frames: List[CanFrame] = field(default_factory=list)
80
- #: Frames the pod dropped because its RX ring was full since the last read.
80
+ #: Frames the pod dropped because its RX ring was full, counted since the last
81
+ #: ``can_config`` (a running total, not reset by reads).
81
82
  overflow: int = 0
82
83
 
83
84
  @classmethod
@@ -168,6 +168,11 @@ class Capabilities:
168
168
  calibrate: bool = False
169
169
  #: The pod can hold a current on its 4-20 mA output (J9): :meth:`BenchPod.current_out`.
170
170
  current_out: bool = False
171
+ #: Classic CAN on the pod's CAN+/CAN- terminals: :meth:`BenchPod.open_can`. Firmware older
172
+ #: than the flag still has CAN on every v2/v3 pod; it just doesn't say so.
173
+ can: bool = False
174
+ #: The board measures the pod's own 5 V current: :attr:`PowerStatus.pod`.
175
+ pod_current: bool = False
171
176
 
172
177
  #: LA I/O-bank voltage the pod currently reports (mV), if known.
173
178
  la_vccio_mv: int = 0
@@ -247,6 +252,8 @@ class Capabilities:
247
252
  ("spi_stream", "spi_stream"),
248
253
  ("calibrate", "calibrate"),
249
254
  ("current_out", "current_out"),
255
+ ("can", "can"),
256
+ ("pod_current", "pod_current"),
250
257
  ):
251
258
  if name in names and hasattr(c, attr):
252
259
  setattr(c, attr, True)
@@ -298,6 +305,7 @@ class Capabilities:
298
305
  ("nrst_pin", "cap.nrst_pin"), ("usb_cc", "cap.usb_cc"),
299
306
  ("spi_master", "cap.spi_master"), ("spi_stream", "cap.spi_stream"),
300
307
  ("calibrate", "cap.calibrate"), ("current_out", "cap.current_out"),
308
+ ("can", "cap.can"), ("pod_current", "cap.pod_current"),
301
309
  ):
302
310
  b = _as_bool(params, key)
303
311
  if b is not None:
@@ -217,7 +217,9 @@ def flash(
217
217
  for attempt in range(attempts):
218
218
  pod_link = transport.dap_start(
219
219
  swclk, swdio, packet_size=_DAP_MAX_PACKET, packet_count=_DAP_PACKET_COUNT,
220
- wait_ms=TARGET_ANSWER_WAIT_MS if (target_power is not None and attempt == 0) else None)
220
+ # Every attempt, not just the first: a retry after a target that answered too early
221
+ # (a NUCLEO's ST-LINK still holding it) must wait for it to settle as well.
222
+ wait_ms=TARGET_ANSWER_WAIT_MS if target_power is not None else None)
221
223
  try:
222
224
  result = _run_bridge(bin_path, args, pod_link, timeout=timeout)
223
225
  finally:
@@ -111,6 +111,12 @@ class PowerStatus:
111
111
 
112
112
  internal: RailPower
113
113
  external: RailPower
114
+ #: The pod's own 5 V draw, on boards that measure it (``Capabilities.pod_current``); ``None``
115
+ #: otherwise. The DUT's internal rail bypasses this monitor.
116
+ pod: Optional[RailPower] = None
117
+ #: Pod + DUT internal rail in amps: what the USB input delivers. ``None`` unless the pod
118
+ #: reported it (both monitors read).
119
+ total_current: Optional[float] = None
114
120
  raw: Dict[str, Any] = _raw()
115
121
 
116
122
  def rail(self, efuse: "Efuse | int") -> RailPower:
@@ -127,7 +133,10 @@ class PowerStatus:
127
133
  bus_voltage=(_opt_int(r, "bus_mv") or 0) / 1000.0,
128
134
  current=(_opt_int(r, "current_ua") or 0) / 1e6)
129
135
 
130
- return cls(internal=one("internal"), external=one("external"), raw=d)
136
+ pod = one("pod") if isinstance(d.get("pod"), Mapping) else None
137
+ total_ua = _opt_int(_d(d.get("pod")), "total_ua")
138
+ return cls(internal=one("internal"), external=one("external"), pod=pod,
139
+ total_current=None if total_ua is None else total_ua / 1e6, raw=d)
131
140
 
132
141
 
133
142
  @dataclass(frozen=True)
@@ -30,6 +30,17 @@ _EDGE_ERRORS = {502, 503, 504, 520, 521, 522, 523, 524}
30
30
  #: Renew a minted session token this many seconds before it expires.
31
31
  _RENEW_MARGIN = 120.0
32
32
 
33
+ #: Largest raw payload of one client->pod WebSocket frame. The server forwards each client frame
34
+ #: as one ``tunnel.data`` message (base64url plus a ~70-byte JSON envelope) and the pod copies that
35
+ #: message into a 2048-byte buffer (firmware BP_CLOUD_RX_MAX): a frame over about 1.5 KB raw is
36
+ #: dropped without an error. 1024 raw bytes is 1366 base64 characters, well inside the limit.
37
+ TUNNEL_FRAME_MAX = 1024
38
+ #: Rate cap for one large client upload (``load_bin`` for replay/SPI staging), the same backstop the
39
+ #: server's own DAC upload uses (benchpodUploadBytesPerSec): the pod acknowledges TCP eagerly, so
40
+ #: without pacing a fast burst overruns its 16 KB receive buffer or the server's per-pod inbox
41
+ #: and the tunnel ends.
42
+ TUNNEL_UPLOAD_BYTES_PER_SEC = 128 * 1024
43
+
33
44
 
34
45
  def _server_error(detail: str) -> str:
35
46
  """The ``error`` field of a server JSON error body, else the raw text."""
@@ -67,6 +78,14 @@ def _transient(code: int, detail: str) -> bool:
67
78
  return False
68
79
 
69
80
 
81
+ def _redact(text: str, secrets: "tuple[str, ...]") -> str:
82
+ """``text`` with every secret (a session token, say) replaced, for error messages."""
83
+ for secret in secrets:
84
+ if secret:
85
+ text = text.replace(secret, "[redacted]").replace(quote(secret, safe=""), "[redacted]")
86
+ return text
87
+
88
+
70
89
  def _repeatable(req: dict) -> bool:
71
90
  # An edge error does not prove the pod never ran the command, so never repeat one that
72
91
  # acts on the target: a CAN frame, a reset pulse or a step-pulse train would happen twice.
@@ -88,7 +107,12 @@ class _WsTunnelSocket:
88
107
  treat a dropped tunnel the same as a closed socket.
89
108
  """
90
109
 
91
- def __init__(self, url: str, timeout: float) -> None:
110
+ #: Clock and sleep used for upload pacing (tests swap them).
111
+ _clock = staticmethod(time.monotonic)
112
+ _sleep = staticmethod(time.sleep)
113
+
114
+ def __init__(self, url: str, timeout: float, headers: "list[str] | None" = None,
115
+ secrets: "tuple[str, ...]" = ()) -> None:
92
116
  try:
93
117
  import websocket # websocket-client (optional extra: embeddedci[cloud])
94
118
  except ImportError as exc: # pragma: no cover - exercised only without the extra
@@ -100,13 +124,17 @@ class _WsTunnelSocket:
100
124
  # default Python/websocket-client signature (HTTP 403, error 1010).
101
125
  self._ws = websocket.create_connection(
102
126
  url, timeout=timeout, enable_multithread=True,
103
- header=[f"User-Agent: {USER_AGENT}"],
127
+ header=[f"User-Agent: {USER_AGENT}", *(headers or [])],
104
128
  )
105
129
  except Exception as exc:
106
- err = TransportError(f"could not open cloud tunnel: {exc}")
130
+ text = str(exc)
131
+ safe = _redact(text, secrets)
132
+ err = TransportError(f"could not open cloud tunnel: {safe}")
107
133
  # websocket-client's WebSocketBadStatusException carries the HTTP status of the upgrade.
108
134
  err.status = getattr(exc, "status_code", None) # type: ignore[attr-defined]
109
135
  err.body = getattr(exc, "resp_body", None) # type: ignore[attr-defined]
136
+ if safe != text: # the original would show the token in a traceback
137
+ raise err from None
110
138
  raise err from exc
111
139
  self._buf = bytearray()
112
140
  self._closed = False
@@ -137,7 +165,17 @@ class _WsTunnelSocket:
137
165
  return take
138
166
 
139
167
  def sendall(self, data: bytes) -> None:
140
- self._ws.send_binary(bytes(data))
168
+ """Send ``data`` as WebSocket binary frames of at most :data:`TUNNEL_FRAME_MAX` bytes, in
169
+ order. A write larger than one frame (a ``load_bin`` upload) is paced to
170
+ :data:`TUNNEL_UPLOAD_BYTES_PER_SEC`; small writes (commands, DAP packets) go out at once."""
171
+ view = memoryview(bytes(data))
172
+ start = self._clock()
173
+ for off in range(0, len(view), TUNNEL_FRAME_MAX):
174
+ if off:
175
+ behind = start + off / TUNNEL_UPLOAD_BYTES_PER_SEC - self._clock()
176
+ if behind > 0:
177
+ self._sleep(behind)
178
+ self._ws.send_binary(bytes(view[off:off + TUNNEL_FRAME_MAX]))
141
179
 
142
180
  def shutdown(self, _how) -> None:
143
181
  return None
@@ -212,6 +250,8 @@ class CloudTransport(TcpTransport):
212
250
  return True
213
251
 
214
252
  def _ws_url(self) -> str:
253
+ """The tunnel URL. It carries no credentials: those go in :meth:`_ws_headers`, so the
254
+ token never reaches an access log or an error message."""
215
255
  base = self.api_base
216
256
  if base.startswith("https://"):
217
257
  ws_base = "wss://" + base[len("https://"):]
@@ -219,22 +259,29 @@ class CloudTransport(TcpTransport):
219
259
  ws_base = "ws://" + base[len("http://"):]
220
260
  else:
221
261
  ws_base = base
222
- token = self._session_token()
223
- url = (
224
- f"{ws_base}/api/cloud/devices/ws"
225
- f"?device={quote(self.device_name, safe='')}&token={quote(token, safe='')}"
226
- )
262
+ return f"{ws_base}/api/cloud/devices/ws?device={quote(self.device_name, safe='')}"
263
+
264
+ def _ws_headers(self, token: str) -> "list[str]":
265
+ """Headers for the tunnel's WebSocket upgrade: the session token as a Bearer header
266
+ (the server's requireGithubActionToken reads it before the ``?token=`` fallback) and the
267
+ lease, if any."""
268
+ headers = [f"Authorization: Bearer {token}"]
227
269
  if self.lease_id:
228
- url += f"&lease_id={quote(self.lease_id, safe='')}"
229
- return url
270
+ headers.append(f"X-Benchpod-Lease: {self.lease_id}")
271
+ return headers
272
+
273
+ def _open_tunnel(self) -> _WsTunnelSocket:
274
+ token = self._session_token()
275
+ return _WsTunnelSocket(self._ws_url(), self.timeout, headers=self._ws_headers(token),
276
+ secrets=(token,))
230
277
 
231
278
  def _dial(self) -> _WsTunnelSocket: # type: ignore[override]
232
279
  try:
233
- sock = _WsTunnelSocket(self._ws_url(), self.timeout)
280
+ sock = self._open_tunnel()
234
281
  except TransportError as exc:
235
282
  status = getattr(exc, "status", None)
236
283
  if status == 401 and self._invalidate_token():
237
- sock = _WsTunnelSocket(self._ws_url(), self.timeout)
284
+ sock = self._open_tunnel()
238
285
  else:
239
286
  body = getattr(exc, "body", None) or b""
240
287
  if isinstance(body, bytes):
@@ -108,6 +108,20 @@ class TcpTransport(Transport):
108
108
  raise TransportError("connection closed before a full reply line")
109
109
  buf.extend(chunk)
110
110
 
111
+ @classmethod
112
+ def _recv_load_bin_done(cls, sock: socket.socket, buf: bytearray) -> bytes:
113
+ """Read the ``load_bin`` completion line, skipping the ``{"ack":N}`` progress lines the
114
+ pod interleaves on a cloud tunnel (firmware sends them only there, for flow control)."""
115
+ while True:
116
+ line = cls._recv_line(sock, buf)
117
+ try:
118
+ obj = json.loads(line)
119
+ except ValueError:
120
+ return line
121
+ if isinstance(obj, dict) and "ack" in obj and "status" not in obj:
122
+ continue
123
+ return line
124
+
111
125
  @staticmethod
112
126
  def _recv_line_exact(sock: socket.socket) -> bytes:
113
127
  """Read one line a byte at a time, leaving everything after ``\\n``.
@@ -215,7 +229,7 @@ class TcpTransport(Transport):
215
229
  sock.sendall(encode_request(begin))
216
230
  raise_for_status(parse_reply(self._recv_line(sock, buf)), cmd="load_bin")
217
231
  sock.sendall(bytes(data))
218
- raise_for_status(parse_reply(self._recv_line(sock, buf)), cmd="load_bin")
232
+ raise_for_status(parse_reply(self._recv_load_bin_done(sock, buf)), cmd="load_bin")
219
233
  sock.sendall(encode_request(replay))
220
234
  reply = parse_reply(self._recv_line(sock, buf))
221
235
  raise_for_status(reply, cmd=replay.get("cmd", "replay"))
@@ -236,7 +250,7 @@ class TcpTransport(Transport):
236
250
  sock.sendall(encode_request({"cmd": "load_bin", "total": len(data), "psram": True}))
237
251
  raise_for_status(parse_reply(self._recv_line(sock, buf)), cmd="load_bin")
238
252
  sock.sendall(bytes(data))
239
- reply = parse_reply(self._recv_line(sock, buf))
253
+ reply = parse_reply(self._recv_load_bin_done(sock, buf))
240
254
  raise_for_status(reply, cmd="load_bin")
241
255
  total = reply.data.get("total") if isinstance(reply.data, dict) else None
242
256
  return int(total) if total is not None else len(data)
@@ -230,6 +230,8 @@
230
230
  "spi_stream",
231
231
  "calibrate",
232
232
  "current_out",
233
+ "can",
234
+ "pod_current",
233
235
  "la_vccio_mv",
234
236
  "safe_mode",
235
237
  "safe_reason",
@@ -620,6 +622,8 @@
620
622
  "__fields__": [
621
623
  "internal",
622
624
  "external",
625
+ "pod",
626
+ "total_current",
623
627
  "raw"
624
628
  ],
625
629
  "from_reply": "(cls, reply: 'Any') -> \"'PowerStatus'\"",
@@ -183,8 +183,14 @@ def test_usb_cc_report_is_self_consistent(v3):
183
183
  assert cc.orientation == ("cc1" if cc.cc1_voltage >= cc.cc2_voltage else "cc2"), cc
184
184
  expected = "default" if live < 0.66 else "1.5A" if live < 1.23 else "3.0A"
185
185
  assert cc.advertised == expected, cc
186
- # the other line sits at our own Rd (~0 V) or a cable's Ra, well under the live one
187
- assert min(cc.cc1_voltage, cc.cc2_voltage) < live / 2, cc
186
+ # Two separate channels were read (a mix-up that sampled one channel twice would make them
187
+ # equal). With a compliant cable the other line sits near 0 V (our own Rd) or at a cable's
188
+ # Ra; some docks and cables put Rp on both lines (pod .220's bench reads 0.30 V / 0.41 V),
189
+ # which is the setup, not the firmware: the report must still pick the higher line, above.
190
+ other = min(cc.cc1_voltage, cc.cc2_voltage)
191
+ assert live - other > 0.03, cc
192
+ if other >= 0.2:
193
+ print(f"note: this cable/host drives both CC lines ({cc.cc1_voltage} V / {cc.cc2_voltage} V)")
188
194
 
189
195
 
190
196
  def test_usb_cc_sees_the_attached_cable(v3):
@@ -298,3 +298,12 @@ def test_assert_periodic_flags_too_few(bp, pod):
298
298
  can.write(0x100, [0])
299
299
  with pytest.raises(AssertionError):
300
300
  can.assert_periodic(0x100, period=0.1, min_count=3, duration=0.05)
301
+
302
+
303
+ def test_can_capability_flag_from_status_and_cloud_parameters():
304
+ from embeddedci.benchpod.capabilities import Capabilities
305
+
306
+ assert Capabilities.from_status({"caps": ["la", "can"]}).can
307
+ assert not Capabilities.from_status({"caps": ["la"]}).can # older firmware: not announced
308
+ assert Capabilities.from_parameters({"cap.can": "true"}).can
309
+ assert not Capabilities.from_parameters({}).can
@@ -8,6 +8,7 @@ import pytest
8
8
 
9
9
  from embeddedci import benchpod
10
10
  from embeddedci.benchpod import BenchPod, BenchPodError
11
+ from embeddedci.benchpod.capabilities import Capabilities
11
12
  from embeddedci.benchpod.state import (
12
13
  AdcReading,
13
14
  AnalogPathState,
@@ -152,6 +153,27 @@ def test_target_status_and_power_status_are_typed():
152
153
  assert ps.rail(1).bus_voltage == pytest.approx(5.012)
153
154
  assert ps.internal.current == pytest.approx(0.1234)
154
155
  assert not ps.external.ok
156
+ assert ps.pod is None and ps.total_current is None
157
+
158
+
159
+ def test_power_status_pod_draw():
160
+ bp, _ = _bp({
161
+ "power_status": {"internal": {"ok": True, "bus_mv": 5012, "current_ua": 123400},
162
+ "external": {"ok": False, "bus_mv": 0, "current_ua": 0},
163
+ "pod": {"ok": True, "bus_mv": 4950, "current_ua": 312000,
164
+ "total_ua": 435400}},
165
+ })
166
+ ps = bp.power_status()
167
+ assert ps.pod is not None and ps.pod.ok
168
+ assert ps.pod.current == pytest.approx(0.312) and ps.pod.bus_voltage == pytest.approx(4.95)
169
+ assert ps.total_current == pytest.approx(0.4354)
170
+
171
+
172
+ def test_pod_current_capability():
173
+ assert Capabilities.from_status({"caps": ["la", "pod_current"]}).pod_current
174
+ assert not Capabilities.from_status({"caps": ["la"]}).pod_current
175
+ assert Capabilities.from_parameters({"cap.pod_current": "true"}).pod_current
176
+ assert not Capabilities.from_parameters({"cap.pod_current": "false"}).pod_current
155
177
 
156
178
 
157
179
  def test_reset_line():
@@ -38,9 +38,52 @@ def test_resolve_and_open_cloud_transport():
38
38
  def test_cloud_ws_url():
39
39
  t = CloudTransport("dev-a", api_base="https://example.test", token="abc")
40
40
  url = t._ws_url()
41
- assert url.startswith("wss://example.test/api/cloud/devices/ws?")
42
- assert "device=dev-a" in url
43
- assert "token=abc" in url
41
+ assert url == "wss://example.test/api/cloud/devices/ws?device=dev-a"
42
+
43
+
44
+ def _fake_websocket_module(monkeypatch, fail=None):
45
+ """Install a fake ``websocket`` module; returns the list of create_connection calls."""
46
+ import sys
47
+ import types
48
+
49
+ calls = []
50
+
51
+ def create_connection(url, **kwargs):
52
+ calls.append((url, kwargs))
53
+ if fail is not None:
54
+ raise fail
55
+ return _FakeWS([])
56
+
57
+ monkeypatch.setitem(sys.modules, "websocket",
58
+ types.SimpleNamespace(create_connection=create_connection))
59
+ return calls
60
+
61
+
62
+ def test_tunnel_sends_the_token_as_a_bearer_header_not_in_the_url(monkeypatch):
63
+ calls = _fake_websocket_module(monkeypatch)
64
+ t = CloudTransport("dev-a", api_base="https://example.test", token="sekrit-tok")
65
+ t.lease_id = "lease-1"
66
+ t._dial()
67
+ (url, kwargs), = calls
68
+ assert url == "wss://example.test/api/cloud/devices/ws?device=dev-a"
69
+ assert "sekrit-tok" not in url and "lease" not in url
70
+ assert "Authorization: Bearer sekrit-tok" in kwargs["header"]
71
+ assert "X-Benchpod-Lease: lease-1" in kwargs["header"]
72
+ assert any(h.startswith("User-Agent: ") for h in kwargs["header"])
73
+
74
+
75
+ def test_tunnel_error_never_shows_the_token(monkeypatch):
76
+ from embeddedci.benchpod.errors import TransportError
77
+
78
+ # An exception text that echoes the request (as a proxy or a debug build might).
79
+ _fake_websocket_module(monkeypatch, fail=OSError(
80
+ "handshake failed: Authorization: Bearer sekrit/tok+1 url=?token=sekrit%2Ftok%2B1"))
81
+ t = CloudTransport("dev-a", api_base="https://example.test", token="sekrit/tok+1")
82
+ with pytest.raises(TransportError) as info:
83
+ t._dial()
84
+ assert "sekrit" not in str(info.value)
85
+ assert "[redacted]" in str(info.value)
86
+ assert info.value.__cause__ is None # the unredacted original is not chained
44
87
 
45
88
 
46
89
  # -- OIDC minting: the three distinct error reasons -------------------------
@@ -187,6 +230,8 @@ class _FakeWS:
187
230
 
188
231
  def send_binary(self, data):
189
232
  self.last = data
233
+ self.frames = getattr(self, "frames", [])
234
+ self.frames.append(bytes(data))
190
235
 
191
236
  def settimeout(self, _t):
192
237
  pass
@@ -207,6 +252,77 @@ def test_ws_tunnel_socket_buffers_and_eofs():
207
252
  assert sock.recv(5) == b"" # EOF
208
253
 
209
254
 
255
+ def _sending_socket(monkeypatch):
256
+ """A tunnel socket over a fake WS with a fake clock: sleeps advance it."""
257
+ from embeddedci.benchpod.transport import cloud
258
+
259
+ clock = [100.0]
260
+ sleeps = []
261
+
262
+ def sleep(s):
263
+ sleeps.append(s)
264
+ clock[0] += s
265
+
266
+ monkeypatch.setattr(cloud._WsTunnelSocket, "_clock", staticmethod(lambda: clock[0]))
267
+ monkeypatch.setattr(cloud._WsTunnelSocket, "_sleep", staticmethod(sleep))
268
+ sock = _WsTunnelSocket.__new__(_WsTunnelSocket)
269
+ sock._ws = _FakeWS([])
270
+ sock._buf = bytearray()
271
+ sock._closed = False
272
+ return sock, sleeps
273
+
274
+
275
+ def test_ws_tunnel_socket_splits_a_large_upload_into_small_frames_in_order(monkeypatch):
276
+ from embeddedci.benchpod.transport import cloud
277
+
278
+ sock, sleeps = _sending_socket(monkeypatch)
279
+ payload = bytes(i * 7 % 251 for i in range(10 * 1024))
280
+ sock.sendall(payload)
281
+ frames = sock._ws.frames
282
+ assert [len(f) for f in frames] == [1024] * 10
283
+ assert all(len(f) <= cloud.TUNNEL_FRAME_MAX for f in frames)
284
+ assert b"".join(frames) == payload
285
+ # base64url of one frame plus the server's tunnel.data envelope fits the pod's 2048-byte buffer.
286
+ import base64
287
+ import json
288
+ env = json.dumps({"type": "tunnel.data", "tunnel_id": "x" * 36,
289
+ "data_b64": base64.urlsafe_b64encode(frames[0]).decode().rstrip("=")})
290
+ assert len(env) < 1600
291
+ # Paced to the upload rate: 10 KiB at 128 KiB/s takes about 70 ms after the first frame.
292
+ assert len(sleeps) == 9
293
+ assert sum(sleeps) == pytest.approx(9 * 1024 / cloud.TUNNEL_UPLOAD_BYTES_PER_SEC)
294
+
295
+
296
+ def test_ws_tunnel_socket_sends_a_small_write_as_one_frame_without_waiting(monkeypatch):
297
+ sock, sleeps = _sending_socket(monkeypatch)
298
+ sock.sendall(b'{"cmd":"ping"}\n')
299
+ sock.sendall(bytearray(b"x" * 1024))
300
+ sock.sendall(b"y" * 1025)
301
+ assert [len(f) for f in sock._ws.frames] == [15, 1024, 1024, 1]
302
+ assert sleeps == [pytest.approx(1024 / (128 * 1024))]
303
+
304
+
305
+ def test_cloud_load_replay_skips_the_tunnel_ack_lines(monkeypatch):
306
+ """Over a cloud tunnel the pod interleaves {"ack":N} progress lines during load_bin; the
307
+ completion line, not the first ack, ends the upload."""
308
+ sock, _ = _sending_socket(monkeypatch)
309
+ sock._ws = _FakeWS([
310
+ b'{"status":"ok","data":{"ready":20000}}\n',
311
+ b'{"ack":8192}\n{"ack":16384}\n',
312
+ b'{"status":"ok","data":{"total":20000}}\n',
313
+ b'{"status":"ok","data":{"replaying":true}}\n',
314
+ ])
315
+ t = CloudTransport("dev-a", api_base="https://example.test", token="abc")
316
+ monkeypatch.setattr(t, "_dial", lambda: sock)
317
+ data = t.load_replay(data=b"\x00" * 20000, replay={"cmd": "replay"}, psram=True)
318
+ assert data == {"replaying": True}
319
+ frames = sock._ws.frames
320
+ import json
321
+ assert json.loads(frames[0])["cmd"] == "load_bin"
322
+ assert json.loads(frames[-1]) == {"cmd": "replay"}
323
+ assert b"".join(frames[1:-1]) == b"\x00" * 20000
324
+
325
+
210
326
  def _edge_error(code, body):
211
327
  import io
212
328
  import urllib.error
@@ -397,8 +513,8 @@ def test_tunnel_renews_a_rejected_token(monkeypatch):
397
513
  def settimeout(self, t):
398
514
  pass
399
515
 
400
- def fake_socket(url, timeout):
401
- urls.append(url)
516
+ def fake_socket(url, timeout, headers=None, secrets=()):
517
+ urls.append(headers[0])
402
518
  if len(urls) == 1:
403
519
  err = TransportError("could not open cloud tunnel: Handshake status 401")
404
520
  err.status = 401
@@ -408,7 +524,7 @@ def test_tunnel_renews_a_rejected_token(monkeypatch):
408
524
  monkeypatch.setattr(cloud, "_WsTunnelSocket", fake_socket)
409
525
  t = CloudTransport("dev-a", api_base="https://example.test", user_token=lambda: "u")
410
526
  t._dial()
411
- assert "token=tok1" in urls[0] and "token=tok2" in urls[1]
527
+ assert urls == ["Authorization: Bearer tok1", "Authorization: Bearer tok2"]
412
528
 
413
529
 
414
530
  @pytest.mark.parametrize("user_token, expected", [(lambda: "user-jwt", "Bearer user-jwt"), (None, "Bearer sess")])
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes