embeddedci 2.6.0__tar.gz → 2.7.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 (115) hide show
  1. {embeddedci-2.6.0 → embeddedci-2.7.0}/CHANGELOG.md +49 -0
  2. {embeddedci-2.6.0 → embeddedci-2.7.0}/PKG-INFO +12 -2
  3. {embeddedci-2.6.0 → embeddedci-2.7.0}/README.md +11 -1
  4. {embeddedci-2.6.0 → embeddedci-2.7.0}/pyproject.toml +1 -1
  5. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/__init__.py +16 -1
  6. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/capabilities.py +69 -0
  7. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/client.py +26 -11
  8. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/constants.py +6 -0
  9. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/errors.py +92 -0
  10. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/gpio.py +2 -2
  11. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/protocol.py +4 -3
  12. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/server_api.py +12 -2
  13. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/transport/base.py +22 -2
  14. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/transport/cloud.py +137 -26
  15. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/transport/serial.py +90 -45
  16. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/transport/tcp.py +99 -39
  17. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/uart.py +47 -5
  18. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/api_surface.json +40 -0
  19. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_capabilities.py +54 -0
  20. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_client_v2.py +48 -0
  21. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_cloud.py +120 -3
  22. embeddedci-2.7.0/tests/test_dac_limits_fixture.py +112 -0
  23. embeddedci-2.7.0/tests/test_decode_parity.py +84 -0
  24. embeddedci-2.7.0/tests/test_refusal_errors.py +118 -0
  25. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_serial_json.py +32 -0
  26. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_tcp_transport.py +101 -0
  27. embeddedci-2.7.0/tests/test_transport_failures.py +468 -0
  28. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_uart_session.py +66 -0
  29. embeddedci-2.7.0/tests/testdata/spi_decode_vectors.json +784 -0
  30. embeddedci-2.7.0/tests/testdata/uart_decode_vectors.json +4017 -0
  31. {embeddedci-2.6.0 → embeddedci-2.7.0}/.gitignore +0 -0
  32. {embeddedci-2.6.0 → embeddedci-2.7.0}/LICENSE +0 -0
  33. {embeddedci-2.6.0 → embeddedci-2.7.0}/docs/event-uart-design.md +0 -0
  34. {embeddedci-2.6.0 → embeddedci-2.7.0}/examples/README.md +0 -0
  35. {embeddedci-2.6.0 → embeddedci-2.7.0}/examples/test_bmp280.py +0 -0
  36. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/__init__.py +0 -0
  37. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/can.py +0 -0
  38. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/capture.py +0 -0
  39. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/ci.py +0 -0
  40. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/cloud_auth.py +0 -0
  41. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/connection.py +0 -0
  42. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/control_loop.py +0 -0
  43. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/decode.py +0 -0
  44. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/discovery.py +0 -0
  45. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/dsp.py +0 -0
  46. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/flash.py +0 -0
  47. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/i2c.py +0 -0
  48. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/lease.py +0 -0
  49. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/lowlevel.py +0 -0
  50. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/motor_emulator.py +0 -0
  51. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/power.py +0 -0
  52. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/pytest_plugin.py +0 -0
  53. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/replay.py +0 -0
  54. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/results.py +0 -0
  55. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/sensor.py +0 -0
  56. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/spi.py +0 -0
  57. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/state.py +0 -0
  58. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/transport/__init__.py +0 -0
  59. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/waveforms.py +0 -0
  60. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/wiring.py +0 -0
  61. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/py.typed +0 -0
  62. {embeddedci-2.6.0 → embeddedci-2.7.0}/src/embeddedci/upload_build.py +0 -0
  63. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/conftest.py +0 -0
  64. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/e2e/README.md +0 -0
  65. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/e2e/conftest.py +0 -0
  66. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/e2e/e2e_helpers.py +0 -0
  67. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/e2e/test_e2e_cloud.py +0 -0
  68. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/e2e/test_e2e_dut.py +0 -0
  69. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/e2e/test_e2e_gpio.py +0 -0
  70. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/e2e/test_e2e_la13_14.py +0 -0
  71. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/e2e/test_e2e_pod.py +0 -0
  72. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/e2e/test_e2e_rev3.py +0 -0
  73. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/e2e/test_e2e_spi.py +0 -0
  74. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/e2e/test_e2e_usb.py +0 -0
  75. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/examples/test_bmp280_hil.py +0 -0
  76. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/examples/test_can_ecu_demo.py +0 -0
  77. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/examples/test_can_loopback_hil.py +0 -0
  78. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/examples/test_smoke.py +0 -0
  79. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_adc_fft_hw.py +0 -0
  80. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_api_surface.py +0 -0
  81. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_can.py +0 -0
  82. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_capture.py +0 -0
  83. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_capture_replay_hw.py +0 -0
  84. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_ci.py +0 -0
  85. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_connection.py +0 -0
  86. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_control_loop.py +0 -0
  87. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_control_loop_hw.py +0 -0
  88. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_current_out_waveforms.py +0 -0
  89. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_dac_path_ranges.py +0 -0
  90. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_decode.py +0 -0
  91. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_dsp.py +0 -0
  92. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_flash_bridge.py +0 -0
  93. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_gpio.py +0 -0
  94. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_i2c_decode.py +0 -0
  95. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_i2c_parity.py +0 -0
  96. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_image_switching.py +0 -0
  97. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_integration.py +0 -0
  98. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_la_voltage_fixture.py +0 -0
  99. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_lease.py +0 -0
  100. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_motor_emulator.py +0 -0
  101. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_pins_bias.py +0 -0
  102. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_protocol.py +0 -0
  103. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_replay.py +0 -0
  104. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_sensor.py +0 -0
  105. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_serial_autodetect.py +0 -0
  106. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_serial_text_console.py +0 -0
  107. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_spi.py +0 -0
  108. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_timed_power.py +0 -0
  109. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_timing.py +0 -0
  110. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_trigger_power.py +0 -0
  111. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_uart_capture.py +0 -0
  112. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_upload_build.py +0 -0
  113. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_waveforms.py +0 -0
  114. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/test_wiring.py +0 -0
  115. {embeddedci-2.6.0 → embeddedci-2.7.0}/tests/testdata/i2c_decode_vectors.json +0 -0
@@ -1,5 +1,54 @@
1
1
  # Changelog — `embeddedci`
2
2
 
3
+ ## 2.7.0
4
+
5
+ - `UartSession.write` on a link that is gone (a reset connection, an unplugged pod, a closed
6
+ cloud tunnel) raises `UartLinkError` instead of the raw `BrokenPipeError`/`OSError`.
7
+ - Typed refusals. `PodLockedError`: the pod's LAN policy keeps this command for the cloud or the
8
+ USB console (`locked: …`, and a policy change sent over the LAN). `PodLeasedError` (with
9
+ `holder`, `left_s` and, from the server, `expires_at`): a cloud job holds the pod (`busy: a
10
+ cloud job holds this pod (…)` on the LAN, HTTP 409 from the server). `PodBusyError`: any other
11
+ `busy: …`. `PermissionDeniedError` (with `status`): the pod's `forbidden: …` on a cloud tunnel
12
+ and a server HTTP 403 (`ServerPermissionDeniedError` from `ServerApi`). An HTML 403 from the
13
+ Cloudflare edge stays a `TransportError`. Every new class also subclasses what the refusal used
14
+ to raise (`FirmwareError`, `TransportError`, `DeviceBusyError`, `ServerApiError`), so existing
15
+ handlers still catch them. Refusals are classified wherever they arrive, not only in
16
+ `BenchPod.command`.
17
+ - `Capabilities` parses every flag firmware 3.6 and the server announce: `analog` (`None` when not
18
+ announced), `dac_limits`, `flash_kb`, `blob_slots`, `ota_sig`, `sig_policy`, `sig_keys`,
19
+ `sig_policy_cmd`, `lan_policy`, `lan_policy_cmd`, `tunnel_max_tier`, `lease_state`, `cloud_ca`,
20
+ `cloud_proxy` and (server only) `ws_auth_v2`.
21
+ - Errors: a socket or serial failure no longer escapes as a raw `OSError`. A pod that does not
22
+ answer in time raises `TransportTimeout` (a `TransportError`, and still a `TimeoutError`); a
23
+ connection that ends or resets before the reply raises `ConnectionClosedError` (a
24
+ `TransportError`). Over the cloud a slow pod used to look like a closed connection; it is now
25
+ a timeout too, and a lost connection to the server during a command is a
26
+ `ConnectionClosedError` instead of a raw exception.
27
+ - Timeouts follow the request: a blocking `measure_power(duration)` waits for its duration on top
28
+ of the connection timeout (it used to fail after the fixed 30 s), and so do a triggered
29
+ capture's `trigger_timeout` and a `dap_start` wait. The cloud command channel asks the server
30
+ to wait that long too (the server caps it at 120 s).
31
+ - The handshake that switches a connection to raw mode (`dap_start`, `uart_proxy_start`) is
32
+ bounded by the timeout. A pod that never acknowledged it used to hang the caller.
33
+ - `BenchPod.capabilities` is not cached when the status read fails, so a passing glitch no longer
34
+ leaves the whole session without capabilities. The failure is logged.
35
+ - `UartSession`: when the link dies with an error (the connection drops, the pod is unplugged),
36
+ `expect()` and `read_until()` raise `UartLinkError` at once (a `UartTimeout` with the text so
37
+ far and the `cause`), `read()` raises it once nothing is unread, and `UartSession.error` says
38
+ why. They used to wait out the full timeout as if the DUT were quiet. A link closed with
39
+ `close()` is not an error.
40
+ - Serial: stale bytes (a reply to a request that timed out) are dropped before each JSON request,
41
+ so they can no longer be taken as the next command's reply.
42
+ - Cloud: after a Cloudflare edge error (502/503/504/52x) the command channel repeats only commands
43
+ that read (`status`, `ping`, `target_status`, `power_status`, the read form of `la_voltage`,
44
+ `dac_limits`, `calibrate`, ...). It used to repeat everything except a few target actions, so
45
+ an `spi_xfer`, a `generate` or a delayed `target_power` could run twice. A command the server
46
+ refused before forwarding ("retry shortly") is still repeated, whatever it is.
47
+ - `la_step`: the docs said `delay` is the time between pulses; it is half of it (each pulse is
48
+ high for `delay`, then low for `delay`). `steps` above 65535 and a `delay` outside 4 µs..65.535 ms
49
+ now raise `ValueError` before anything is sent: the pod's counters are 16-bit and it refused
50
+ them with a bare "invalid args". `GpioPin.pulse` documents the same bounds.
51
+
3
52
  ## 2.6.0
4
53
 
5
54
  - Cloud: the tunnel WebSocket sends the session token as an `Authorization: Bearer` header (and
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: embeddedci
3
- Version: 2.6.0
3
+ Version: 2.7.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
@@ -1177,7 +1177,10 @@ with bp.open_can(bitrate=500_000, term=True) as can: # on a real bus, 120
1177
1177
  ### Step/direction pulse trains
1178
1178
 
1179
1179
  `bp.la_step(PIN3, steps=200, delay=0.001, dir_la=PIN4, direction=1)` emits step pulses on an LA
1180
- channel, `delay` seconds apart, generated by the FPGA; it returns immediately.
1180
+ channel, generated by the FPGA; it returns immediately. `delay` is half the step period: each pulse
1181
+ is high for `delay`, then low for `delay`, so the example steps every 2 ms (500 steps/s). `steps`
1182
+ is 1..65535 and `delay` 4 µs..65.535 ms (16-bit counters in the gateware); split a longer move into
1183
+ several trains.
1181
1184
 
1182
1185
  ## Cloud (`embeddedci:<device-name>`)
1183
1186
 
@@ -1317,13 +1320,20 @@ Invalid arguments raise `ValueError` before anything is sent. Everything else ra
1317
1320
  | `BenchPodError` | base class of all of the below | |
1318
1321
  | `ConnectionConfigError` | no connection configured, unparsable string, discovery found zero or several pods | |
1319
1322
  | `TransportError` | the pod or tunnel could not be reached / talked to | |
1323
+ | `TransportTimeout` (a `TransportError` and a `TimeoutError`) | the pod or tunnel did not answer in time | |
1324
+ | `ConnectionClosedError` (a `TransportError`) | the connection ended or reset before the reply | |
1320
1325
  | `FirmwareError` | the pod replied with an error (e.g. "la voltage not set") | `firmware_message`, `cmd` |
1321
1326
  | `FlashError` | OpenOCD failed, is missing, or lacks the TCP backend | |
1322
1327
  | `TargetUnreachableError` (a `FlashError`) | the probe worked but no target answered on SWD | |
1323
1328
  | `DeviceBusyError` | a cloud device lease was not granted within `lease_wait` | |
1324
1329
  | `CloudAuthError` | no session token: API key rejected, OIDC unavailable, exchange failed | |
1325
1330
  | `ServerApiError` | an embeddedci server API call failed | `status` (HTTP) |
1331
+ | `PodLockedError` (a `FirmwareError`) | the pod's LAN policy keeps the command for the cloud or the USB console | `firmware_message`, `cmd` |
1332
+ | `PodBusyError` (a `FirmwareError`) | the pod is busy (`busy: …`), e.g. a capture or upload is running | `firmware_message`, `cmd` |
1333
+ | `PodLeasedError` (a `PodBusyError`, `DeviceBusyError` and `TransportError`) | a cloud job holds the pod (its refusal on the LAN, or the server's HTTP 409) | `holder`, `left_s`, `expires_at`, `status` |
1334
+ | `PermissionDeniedError` (a `FirmwareError` and `TransportError`) | the pod's `forbidden: …` on a cloud tunnel, or a server HTTP 403 (`ServerPermissionDeniedError`, also a `ServerApiError`, from `ServerApi`) | `status` |
1326
1335
  | `UartTimeout` | `UartSession.expect` timed out | `text` |
1336
+ | `UartLinkError` (a `UartTimeout` and `ConnectionClosedError`) | the UART link died with an error while `UartSession` waited | `text`, `cause` |
1327
1337
  | `PinConflictError` | an LA channel is already used by another function (a `FirmwareError`) | `la`, `function` |
1328
1338
  | `PullConflictError` | an engaged bias resistor can't work with the channel's function (a `FirmwareError`) | `la` |
1329
1339
  | `TriggerTimeout` | a triggered capture's condition never happened (a `FirmwareError`) | `la`, `edge` |
@@ -1139,7 +1139,10 @@ with bp.open_can(bitrate=500_000, term=True) as can: # on a real bus, 120
1139
1139
  ### Step/direction pulse trains
1140
1140
 
1141
1141
  `bp.la_step(PIN3, steps=200, delay=0.001, dir_la=PIN4, direction=1)` emits step pulses on an LA
1142
- channel, `delay` seconds apart, generated by the FPGA; it returns immediately.
1142
+ channel, generated by the FPGA; it returns immediately. `delay` is half the step period: each pulse
1143
+ is high for `delay`, then low for `delay`, so the example steps every 2 ms (500 steps/s). `steps`
1144
+ is 1..65535 and `delay` 4 µs..65.535 ms (16-bit counters in the gateware); split a longer move into
1145
+ several trains.
1143
1146
 
1144
1147
  ## Cloud (`embeddedci:<device-name>`)
1145
1148
 
@@ -1279,13 +1282,20 @@ Invalid arguments raise `ValueError` before anything is sent. Everything else ra
1279
1282
  | `BenchPodError` | base class of all of the below | |
1280
1283
  | `ConnectionConfigError` | no connection configured, unparsable string, discovery found zero or several pods | |
1281
1284
  | `TransportError` | the pod or tunnel could not be reached / talked to | |
1285
+ | `TransportTimeout` (a `TransportError` and a `TimeoutError`) | the pod or tunnel did not answer in time | |
1286
+ | `ConnectionClosedError` (a `TransportError`) | the connection ended or reset before the reply | |
1282
1287
  | `FirmwareError` | the pod replied with an error (e.g. "la voltage not set") | `firmware_message`, `cmd` |
1283
1288
  | `FlashError` | OpenOCD failed, is missing, or lacks the TCP backend | |
1284
1289
  | `TargetUnreachableError` (a `FlashError`) | the probe worked but no target answered on SWD | |
1285
1290
  | `DeviceBusyError` | a cloud device lease was not granted within `lease_wait` | |
1286
1291
  | `CloudAuthError` | no session token: API key rejected, OIDC unavailable, exchange failed | |
1287
1292
  | `ServerApiError` | an embeddedci server API call failed | `status` (HTTP) |
1293
+ | `PodLockedError` (a `FirmwareError`) | the pod's LAN policy keeps the command for the cloud or the USB console | `firmware_message`, `cmd` |
1294
+ | `PodBusyError` (a `FirmwareError`) | the pod is busy (`busy: …`), e.g. a capture or upload is running | `firmware_message`, `cmd` |
1295
+ | `PodLeasedError` (a `PodBusyError`, `DeviceBusyError` and `TransportError`) | a cloud job holds the pod (its refusal on the LAN, or the server's HTTP 409) | `holder`, `left_s`, `expires_at`, `status` |
1296
+ | `PermissionDeniedError` (a `FirmwareError` and `TransportError`) | the pod's `forbidden: …` on a cloud tunnel, or a server HTTP 403 (`ServerPermissionDeniedError`, also a `ServerApiError`, from `ServerApi`) | `status` |
1288
1297
  | `UartTimeout` | `UartSession.expect` timed out | `text` |
1298
+ | `UartLinkError` (a `UartTimeout` and `ConnectionClosedError`) | the UART link died with an error while `UartSession` waited | `text`, `cause` |
1289
1299
  | `PinConflictError` | an LA channel is already used by another function (a `FirmwareError`) | `la`, `function` |
1290
1300
  | `PullConflictError` | an engaged bias resistor can't work with the channel's function (a `FirmwareError`) | `la` |
1291
1301
  | `TriggerTimeout` | a triggered capture's condition never happened (a `FirmwareError`) | `la`, `edge` |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "embeddedci"
7
- version = "2.6.0"
7
+ version = "2.7.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"
@@ -83,15 +83,22 @@ from .errors import (
83
83
  BenchPodError,
84
84
  CanTimeout,
85
85
  CloudAuthError,
86
+ ConnectionClosedError,
86
87
  ConnectionConfigError,
87
88
  DeviceBusyError,
88
89
  FirmwareError,
89
90
  FlashError,
91
+ PermissionDeniedError,
90
92
  PinConflictError,
93
+ PodBusyError,
94
+ PodLeasedError,
95
+ PodLockedError,
91
96
  PullConflictError,
92
97
  TargetUnreachableError,
93
98
  TransportError,
99
+ TransportTimeout,
94
100
  TriggerTimeout,
101
+ UartLinkError,
95
102
  UartTimeout,
96
103
  )
97
104
  from .flash import FlashResult
@@ -102,7 +109,7 @@ from .lowlevel import LowLevel
102
109
  from .power import PowerProfile, PowerProfileSession
103
110
  from .replay import DacHandle, Fault, ReplayHandle, Segment
104
111
  from .results import Capture, CorrelatedCapture, LaCapture, Trigger
105
- from .server_api import ServerApi, ServerApiError
112
+ from .server_api import ServerApi, ServerApiError, ServerPermissionDeniedError
106
113
  from .state import (
107
114
  AdcReading,
108
115
  AnalogPathState,
@@ -253,13 +260,21 @@ __all__ = [
253
260
  "BenchPodError",
254
261
  "ConnectionConfigError",
255
262
  "TransportError",
263
+ "TransportTimeout",
264
+ "ConnectionClosedError",
256
265
  "FirmwareError",
257
266
  "FlashError",
258
267
  "TargetUnreachableError",
259
268
  "DeviceBusyError",
260
269
  "CloudAuthError",
261
270
  "ServerApiError",
271
+ "ServerPermissionDeniedError",
272
+ "PodLockedError",
273
+ "PodBusyError",
274
+ "PodLeasedError",
275
+ "PermissionDeniedError",
262
276
  "UartTimeout",
277
+ "UartLinkError",
263
278
  "CanTimeout",
264
279
  "PinConflictError",
265
280
  "PullConflictError",
@@ -81,6 +81,12 @@ def _as_bool(m: Mapping[str, Any], *keys: str) -> Optional[bool]:
81
81
  return None
82
82
 
83
83
 
84
+ #: Boolean capabilities the firmware's ``status`` reports as top-level ``true`` fields (and the
85
+ #: server as ``cap.<name>``), command_handler.c handle_status / cloud_client.c.
86
+ _STATUS_FLAGS = ("ota_sig", "sig_policy_cmd", "lan_policy_cmd", "tunnel_max_tier", "lease_state",
87
+ "cloud_ca", "cloud_proxy", "blob_slots")
88
+
89
+
84
90
  def _crash_text(v: Any) -> str:
85
91
  """The firmware's crash summary, with its "none" (no crash) mapped to ``""``."""
86
92
  s = str(v or "").strip()
@@ -173,6 +179,42 @@ class Capabilities:
173
179
  can: bool = False
174
180
  #: The board measures the pod's own 5 V current: :attr:`PowerStatus.pod`.
175
181
  pod_current: bool = False
182
+ #: The board has the analog front end (ADC, DAC, 4-20 mA). ``False`` on the digital-only
183
+ #: board; ``None`` when the firmware does not say (older firmware, analog boards only).
184
+ analog: Optional[bool] = None
185
+ #: Output limits on the DAC paths (``{"cmd":"dac_limits"}``).
186
+ dac_limits: bool = False
187
+
188
+ # firmware updates and policies
189
+ #: Internal flash of the pod's MCU in KiB (2048 or 1024); 0 when not reported.
190
+ flash_kb: int = 0
191
+ #: Spare flash slots for the W25Q blobs (``flash-self`` installs them).
192
+ blob_slots: bool = False
193
+ #: Firmware updates carry a signature the pod checks.
194
+ ota_sig: bool = False
195
+ #: The signature policy: ``"audit"`` (report only), ``"required"``, ...; ``""`` when unknown.
196
+ sig_policy: str = ""
197
+ #: Number of signing keys the pod trusts; 0 when not reported.
198
+ sig_keys: int = 0
199
+ #: The pod takes the ``sig_policy`` command.
200
+ sig_policy_cmd: bool = False
201
+ #: The LAN policy: ``"open"``, ``"locked"`` (LAN keeps T0/T1, the rest needs the cloud or USB)
202
+ #: or ``"off"``; ``""`` when unknown.
203
+ lan_policy: str = ""
204
+ #: The pod takes the ``lan_policy`` command.
205
+ lan_policy_cmd: bool = False
206
+ #: The pod limits a cloud tunnel to the tier the server allows its user (a non-admin gets
207
+ #: :class:`~embeddedci.benchpod.errors.PermissionDeniedError` for config and firmware commands).
208
+ tunnel_max_tier: bool = False
209
+ #: The pod knows when a cloud job holds it and refuses LAN writes meanwhile
210
+ #: (:class:`~embeddedci.benchpod.errors.PodLeasedError`).
211
+ lease_state: bool = False
212
+ #: The pod accepts a company CA for its cloud connection (``cloud_ca``).
213
+ cloud_ca: bool = False
214
+ #: The pod reaches the cloud through an HTTP proxy (``cloud_proxy``).
215
+ cloud_proxy: bool = False
216
+ #: The pod signs its cloud login with the v2 scheme (server-side flag only).
217
+ ws_auth_v2: bool = False
176
218
 
177
219
  #: LA I/O-bank voltage the pod currently reports (mV), if known.
178
220
  la_vccio_mv: int = 0
@@ -219,6 +261,18 @@ class Capabilities:
219
261
  c.adc_channels = v
220
262
  if (v := _as_int(status, "la_vccio_mv")) is not None:
221
263
  c.la_vccio_mv = v
264
+ # Top-level fields of newer firmware (not in caps[]).
265
+ for attr in _STATUS_FLAGS:
266
+ if status.get(attr) is True:
267
+ setattr(c, attr, True)
268
+ if (v := _as_bool(status, "analog")) is not None:
269
+ c.analog = v
270
+ if (v := _as_int(status, "flash_kb")) is not None and v > 0:
271
+ c.flash_kb = v
272
+ if (v := _as_int(status, "sig_keys")) is not None and v > 0:
273
+ c.sig_keys = v
274
+ c.sig_policy = str(status.get("sig_policy", "") or "")
275
+ c.lan_policy = str(status.get("lan_policy", "") or "")
222
276
  caps = status.get("caps")
223
277
  if isinstance(caps, (list, tuple)):
224
278
  names = {str(x).lower() for x in caps}
@@ -254,9 +308,13 @@ class Capabilities:
254
308
  ("current_out", "current_out"),
255
309
  ("can", "can"),
256
310
  ("pod_current", "pod_current"),
311
+ ("dac_limits", "dac_limits"),
312
+ *((f, f) for f in _STATUS_FLAGS),
257
313
  ):
258
314
  if name in names and hasattr(c, attr):
259
315
  setattr(c, attr, True)
316
+ if "analog" in names:
317
+ c.analog = True
260
318
  c.safe_mode = _as_bool(status, "safe_mode") is True
261
319
  c.safe_reason = str(status.get("safe_reason", "") or "") if c.safe_mode else ""
262
320
  c.last_crash = _crash_text(status.get("last_crash", status.get("crash")))
@@ -306,10 +364,21 @@ class Capabilities:
306
364
  ("spi_master", "cap.spi_master"), ("spi_stream", "cap.spi_stream"),
307
365
  ("calibrate", "cap.calibrate"), ("current_out", "cap.current_out"),
308
366
  ("can", "cap.can"), ("pod_current", "cap.pod_current"),
367
+ ("dac_limits", "cap.dac_limits"),
368
+ ("ws_auth_v2", "cap.ws_auth_v2"),
369
+ *((f, f"cap.{f}") for f in _STATUS_FLAGS),
309
370
  ):
310
371
  b = _as_bool(params, key)
311
372
  if b is not None:
312
373
  setattr(c, attr, b)
374
+ # Tri-state: "" (not announced) stays None.
375
+ c.analog = _as_bool(params, "cap.analog")
376
+ if (v := _as_int(params, "cap.flash_kb")) is not None and v > 0:
377
+ c.flash_kb = v
378
+ if (v := _as_int(params, "cap.sig_keys")) is not None and v > 0:
379
+ c.sig_keys = v
380
+ c.sig_policy = str(params.get("cap.sig_policy", "") or "")
381
+ c.lan_policy = str(params.get("cap.lan_policy", "") or "")
313
382
  c.safe_mode = _as_bool(params, "cap.safe_mode") is True
314
383
  c.safe_reason = str(params.get("cap.safe_reason", "") or "") if c.safe_mode else ""
315
384
  c.last_crash = _crash_text(params.get("cap.last_crash"))
@@ -54,6 +54,9 @@ from .constants import (
54
54
  DAC_PATHS,
55
55
  DECODE_PROTOCOLS,
56
56
  GPIO_MODES,
57
+ LA_STEP_MAX_DELAY_US,
58
+ LA_STEP_MAX_STEPS,
59
+ LA_STEP_MIN_DELAY_US,
57
60
  LA_VOLTAGES,
58
61
  PULLDOWN_CHANNELS,
59
62
  PULLUP_CHANNELS,
@@ -322,13 +325,17 @@ class BenchPod:
322
325
 
323
326
  Parsed from :meth:`status` and — when the SDK has server access — enriched with the
324
327
  fuller ``cap.*`` set the server holds (explicit calibration + replay depth). Cached after
325
- the first read; :meth:`refresh_capabilities` re-reads it.
328
+ the first successful read; :meth:`refresh_capabilities` re-reads it. When the status read
329
+ fails the result is not cached, so a passing glitch does not leave the session without
330
+ any capability for good.
326
331
  """
327
332
  if self._caps is None:
333
+ status_ok = True
328
334
  try:
329
335
  status = self.status()
330
- except BenchPodError:
331
- status = {}
336
+ except BenchPodError as exc:
337
+ _log.warning("could not read the pod's status for its capabilities: %s", exc)
338
+ status, status_ok = {}, False
332
339
  caps = Capabilities.from_status(status)
333
340
  api = self._try_server_api()
334
341
  if api is not None and self._device_name:
@@ -338,6 +345,8 @@ class BenchPod:
338
345
  caps = caps.merge(Capabilities.from_parameters(params))
339
346
  except BenchPodError:
340
347
  pass
348
+ if not status_ok:
349
+ return caps
341
350
  self._caps = caps
342
351
  return self._caps
343
352
 
@@ -1945,16 +1954,22 @@ class BenchPod:
1945
1954
 
1946
1955
  def la_step(self, la: Union[Pin, int], *, steps: int, delay: float,
1947
1956
  dir_la: Optional[Union[Pin, int]] = None, direction: int = 0) -> Dict[str, Any]:
1948
- """Emit ``steps`` step pulses on an LA channel, ``delay`` seconds apart (step/dir drivers).
1949
-
1950
- The FPGA runs the train autonomously and this returns immediately. With ``dir_la`` set,
1951
- that channel is driven to ``direction`` (0/1) first.
1957
+ """Emit ``steps`` step pulses on an LA channel (step/dir drivers).
1958
+
1959
+ ``delay`` is HALF the step period: each pulse is high for ``delay`` seconds, then low for
1960
+ ``delay``, so pulses are ``2 * delay`` apart (``1 / (2 * delay)`` steps per second).
1961
+ ``steps`` is 1..65535 and ``delay`` 4 µs..65.535 ms (the gateware counters are 16-bit);
1962
+ split a longer move into several trains. The FPGA runs the train autonomously and this
1963
+ returns immediately. With ``dir_la`` set, that channel is driven to ``direction`` (0/1)
1964
+ first.
1952
1965
  """
1953
- if steps <= 0:
1954
- raise ValueError(f"steps must be > 0, got {steps!r}")
1966
+ if not 1 <= steps <= LA_STEP_MAX_STEPS:
1967
+ raise ValueError(f"steps must be 1..{LA_STEP_MAX_STEPS} (the pod's step counter is "
1968
+ f"16-bit; split a longer move), got {steps!r}")
1955
1969
  delay_us = int(round(delay * 1e6))
1956
- if delay_us < 1:
1957
- raise ValueError(f"delay must be at least 1 µs, got {delay!r}")
1970
+ if not LA_STEP_MIN_DELAY_US <= delay_us <= LA_STEP_MAX_DELAY_US:
1971
+ raise ValueError(f"delay must be {LA_STEP_MIN_DELAY_US} µs..{LA_STEP_MAX_DELAY_US / 1e3:g} ms "
1972
+ f"(half the step period), got {delay!r}")
1958
1973
  if direction not in (0, 1):
1959
1974
  raise ValueError(f"direction must be 0 or 1, got {direction!r}")
1960
1975
  req: Dict[str, Any] = {"cmd": "la", "la": self._resolve_la(la)[0], "steps": int(steps),
@@ -153,6 +153,12 @@ PULL_OHMS: Dict[int, str] = {
153
153
  PULLUP_CHANNELS: Tuple[int, ...] = (1, 2, 3, 4, 5, 6)
154
154
  PULLDOWN_CHANNELS: Tuple[int, ...] = (7, 8)
155
155
 
156
+ # LA step trains (``la_step``, ``GpioPin.pulse``): the gateware counts steps and the half-period in
157
+ # 16-bit counters, so the pod refuses more steps or a longer half-period.
158
+ LA_STEP_MAX_STEPS = 65535
159
+ LA_STEP_MIN_DELAY_US = 4
160
+ LA_STEP_MAX_DELAY_US = 65535
161
+
156
162
 
157
163
  def check_choice(value: str, choices: Sequence[str], name: str) -> str:
158
164
  """Return ``value`` if it is one of ``choices``, else raise a :class:`ValueError` naming them."""
@@ -18,6 +18,18 @@ class TransportError(BenchPodError):
18
18
  """A transport-level failure: could not reach or talk to the pod."""
19
19
 
20
20
 
21
+ class TransportTimeout(TransportError, TimeoutError):
22
+ """The pod (or the cloud tunnel) did not answer within the timeout.
23
+
24
+ Distinct from :class:`ConnectionClosedError`: the link is still up, the reply just did not
25
+ come in time. Also a :class:`TimeoutError`, which the raw socket timeout used to be.
26
+ """
27
+
28
+
29
+ class ConnectionClosedError(TransportError):
30
+ """The connection ended (EOF, reset or a closed tunnel) before the full reply arrived."""
31
+
32
+
21
33
  class DeviceBusyError(BenchPodError):
22
34
  """The shared cloud BenchPod is held by another consumer and did not free within the wait
23
35
  timeout. Raised when acquiring the device lease times out."""
@@ -67,6 +79,19 @@ class UartTimeout(BenchPodError):
67
79
  super().__init__(message)
68
80
 
69
81
 
82
+ class UartLinkError(UartTimeout, ConnectionClosedError):
83
+ """The UART proxy link ended with an error (the connection dropped, the pod went away)
84
+ while :class:`~embeddedci.benchpod.uart.UartSession` was waiting for output.
85
+
86
+ A :class:`UartTimeout` too, so code written for the timeout still catches it; :attr:`text`
87
+ has everything received before the link died and :attr:`cause` the error that ended it.
88
+ """
89
+
90
+ def __init__(self, message: str, *, text: str = "", cause: Optional[BaseException] = None) -> None:
91
+ super().__init__(message, text=text)
92
+ self.cause = cause
93
+
94
+
70
95
  class CanTimeout(BenchPodError):
71
96
  """A :meth:`CanBus.expect` waited for a matching CAN frame but none arrived.
72
97
 
@@ -113,9 +138,62 @@ class TriggerTimeout(FirmwareError):
113
138
  self.edge = edge
114
139
 
115
140
 
141
+ class PodLockedError(FirmwareError):
142
+ """The pod refused a command on its LAN connection: its LAN policy is ``locked`` (or the
143
+ command changes a policy), so the command needs the cloud (``embeddedci:<device>``) or the
144
+ USB console. Firmware text: ``locked: <cmd> needs the cloud or the USB console``."""
145
+
146
+
147
+ class PodBusyError(FirmwareError):
148
+ """The pod refused because it is busy (``busy: …``): a capture or upload is running, or a
149
+ cloud job holds it (:class:`PodLeasedError`)."""
150
+
151
+
152
+ class PodLeasedError(PodBusyError, DeviceBusyError, TransportError):
153
+ """A cloud job holds the pod, so this client may look but not touch.
154
+
155
+ Raised for the pod's own refusal on the LAN (``busy: a cloud job holds this pod (<holder>,
156
+ <n> s left)``) and for the server's HTTP 409 when another consumer holds the device lease.
157
+ :attr:`holder` names the job (``""`` when unknown), :attr:`left_s` the seconds its lease has
158
+ left (``None`` when unknown) and :attr:`expires_at` the server's expiry time, if it sent one.
159
+
160
+ Also a :class:`FirmwareError`, :class:`DeviceBusyError` and :class:`TransportError`, which
161
+ these refusals were before, so existing handlers keep working.
162
+ """
163
+
164
+ def __init__(self, message: str, *, cmd: Optional[str] = None, holder: str = "",
165
+ left_s: Optional[int] = None, expires_at: str = "",
166
+ status: Optional[int] = None) -> None:
167
+ super().__init__(message, cmd=cmd)
168
+ self.holder = holder
169
+ self.left_s = left_s
170
+ self.expires_at = expires_at
171
+ self.status = status
172
+
173
+
174
+ class PermissionDeniedError(FirmwareError, TransportError):
175
+ """The caller is not allowed to do this: the pod's ``forbidden: <cmd> needs an organization
176
+ owner or admin`` on a cloud tunnel, or an HTTP 403 from the server (the API key lacks a
177
+ scope, the session token does not cover the device, the user is not an owner or admin).
178
+
179
+ :attr:`status` is the HTTP status (403) when the server refused, else ``None``. Also a
180
+ :class:`FirmwareError` and :class:`TransportError`, which these refusals were before.
181
+ """
182
+
183
+ def __init__(self, message: str, *, cmd: Optional[str] = None,
184
+ status: Optional[int] = None) -> None:
185
+ super().__init__(message, cmd=cmd)
186
+ self.status = status
187
+
188
+
116
189
  _PIN_CONFLICT = re.compile(r"pin conflict: LA(\d+) is in use by (\w+)")
117
190
  _PULL_CONFLICT = re.compile(r"pull conflict: LA(\d+)")
118
191
  _TRIGGER_TIMEOUT = re.compile(r"trigger timeout: no (\w+) \w+ on LA(\d+)")
192
+ #: "busy: a cloud job holds this pod (<holder>, <n> s left)" (command_handler.c dispatch_line).
193
+ _LEASED = re.compile(r"busy: a cloud job holds this pod \((.*), (\d+) s left\)")
194
+ #: Policy changes the LAN may not make (pod_policy.c).
195
+ _POLICY_ELSEWHERE = ("change it from the cloud or the USB console",
196
+ "only the USB console can loosen")
119
197
 
120
198
 
121
199
  def classify_firmware_error(exc: FirmwareError) -> FirmwareError:
@@ -132,4 +210,18 @@ def classify_firmware_error(exc: FirmwareError) -> FirmwareError:
132
210
  m = _TRIGGER_TIMEOUT.search(msg)
133
211
  if m:
134
212
  return TriggerTimeout(msg, cmd=exc.cmd, la=int(m.group(2)), edge=m.group(1))
213
+ if msg.startswith("locked:") or any(p in msg for p in _POLICY_ELSEWHERE):
214
+ return PodLockedError(msg, cmd=exc.cmd)
215
+ m = _LEASED.search(msg)
216
+ if m:
217
+ return PodLeasedError(msg, cmd=exc.cmd, holder=m.group(1), left_s=int(m.group(2)))
218
+ if msg.startswith("busy:"):
219
+ return PodBusyError(msg, cmd=exc.cmd)
220
+ if msg.startswith("forbidden:"):
221
+ return PermissionDeniedError(msg, cmd=exc.cmd)
135
222
  return exc
223
+
224
+
225
+ def firmware_error(message: str, *, cmd: Optional[str] = None) -> FirmwareError:
226
+ """The :class:`FirmwareError` (its specific subclass when there is one) for a pod refusal."""
227
+ return classify_firmware_error(FirmwareError(message, cmd=cmd))
@@ -123,8 +123,8 @@ class GpioPin:
123
123
 
124
124
  def pulse(self, width: float, *, count: int = 1) -> None:
125
125
  """Emit ``count`` high pulses of ``width`` seconds (``width`` low between them), timed by the
126
- FPGA; returns immediately. The pin must be a GPIO output at level 0 (it goes back to its GPIO
127
- level afterwards) or unused."""
126
+ FPGA; returns immediately. ``count`` is 1..65535 and ``width`` 4 µs..65.535 ms. The pin must
127
+ be a GPIO output at level 0 (it goes back to its GPIO level afterwards) or unused."""
128
128
  self._pod.la_step(self.la, steps=count, delay=width)
129
129
 
130
130
  def release(self) -> None:
@@ -12,7 +12,7 @@ import json
12
12
  from dataclasses import dataclass
13
13
  from typing import Any, Optional
14
14
 
15
- from .errors import FirmwareError, TransportError
15
+ from .errors import TransportError, firmware_error
16
16
 
17
17
 
18
18
  @dataclass
@@ -50,12 +50,13 @@ def parse_reply(line: bytes) -> Reply:
50
50
 
51
51
 
52
52
  def raise_for_status(reply: Reply, cmd: Optional[str] = None) -> Reply:
53
- """Raise :class:`FirmwareError` if the reply is an error packet.
53
+ """Raise :class:`FirmwareError` (its specific subclass, see
54
+ :func:`~embeddedci.benchpod.errors.classify_firmware_error`) if the reply is an error packet.
54
55
 
55
56
  Returns the reply unchanged on success so callers can chain.
56
57
  """
57
58
  if reply.status == "error":
58
- raise FirmwareError(reply.message or "unknown firmware error", cmd=cmd)
59
+ raise firmware_error(reply.message or "unknown firmware error", cmd=cmd)
59
60
  if reply.status not in ("ok", "chunk"):
60
61
  raise TransportError(f"unexpected reply status {reply.status!r}")
61
62
  return reply
@@ -22,7 +22,7 @@ import urllib.request
22
22
  from typing import Any, Callable, Dict, List, Optional, Tuple
23
23
 
24
24
  from .cloud_auth import DEFAULT_API_BASE, USER_AGENT
25
- from .errors import BenchPodError, CloudAuthError, TransportError
25
+ from .errors import BenchPodError, CloudAuthError, PermissionDeniedError, TransportError
26
26
 
27
27
  _HTTP_TIMEOUT = 60.0
28
28
 
@@ -35,6 +35,15 @@ class ServerApiError(BenchPodError):
35
35
  super().__init__(message)
36
36
 
37
37
 
38
+ class ServerPermissionDeniedError(ServerApiError, PermissionDeniedError):
39
+ """The server answered HTTP 403: the credential is not allowed to do this. Both a
40
+ :class:`ServerApiError` (what it was before) and a
41
+ :class:`~embeddedci.benchpod.errors.PermissionDeniedError`."""
42
+
43
+ def __init__(self, message: str, *, status: Optional[int] = 403) -> None:
44
+ PermissionDeniedError.__init__(self, message, status=status)
45
+
46
+
38
47
  class ServerApi:
39
48
  """Thin client for the embeddedci server HTTP API (``{api_base}/api/...``)."""
40
49
 
@@ -120,7 +129,8 @@ class ServerApi:
120
129
  hint = (" — this endpoint needs the 'benchpod:control' capability; a cloud "
121
130
  "(OIDC/github_action) session token authorizes it for its allowed devices, "
122
131
  "as does an API key or a real-user session")
123
- raise ServerApiError(
132
+ cls = ServerPermissionDeniedError if exc.code == 403 else ServerApiError
133
+ raise cls(
124
134
  f"{method} {path} failed (HTTP {exc.code}): {detail}{hint}", status=exc.code
125
135
  ) from exc
126
136
  except urllib.error.URLError as exc:
@@ -11,7 +11,25 @@ operations on. :class:`RawLink` is the bidirectional byte stream that
11
11
  from __future__ import annotations
12
12
 
13
13
  from abc import ABC, abstractmethod
14
- from typing import Any, Optional, Protocol, runtime_checkable
14
+ from typing import Any, Mapping, Optional, Protocol, runtime_checkable
15
+
16
+
17
+ def request_timeout(req: Mapping[str, Any], base: float) -> float:
18
+ """How long to wait for the reply to ``req``: ``base`` plus the time the pod spends on
19
+ purpose before it answers (a blocking power profile's duration, a trigger's timeout, the
20
+ ``dap_start`` wait for the target, a reset pulse). A fixed timeout would end a 60 s
21
+ ``measure_power`` after 30 s."""
22
+ extra_ms = 0.0
23
+ keys = ["trigger_timeout_ms", "pulse_ms"]
24
+ if req.get("cmd") == "power_profile":
25
+ keys.append("duration_ms")
26
+ if req.get("cmd") == "dap_start":
27
+ keys.append("wait_ms")
28
+ for key in keys:
29
+ v = req.get(key)
30
+ if isinstance(v, (int, float)) and not isinstance(v, bool) and v > 0:
31
+ extra_ms += float(v)
32
+ return base + extra_ms / 1000.0
15
33
 
16
34
 
17
35
  @runtime_checkable
@@ -19,7 +37,9 @@ class RawLink(Protocol):
19
37
  """A raw, bidirectional byte stream (length-framed CMSIS-DAP during a flash).
20
38
 
21
39
  ``read`` blocks until at least one byte is available and returns ``b""``
22
- only when the stream has ended (EOF) or been closed. ``close`` returns the
40
+ only when the stream has ended (EOF) or been closed. A link that ended because of an error
41
+ (a reset connection, a dropped tunnel) sets ``error`` to that exception (optional attribute;
42
+ ``None`` or absent after a clean end). ``close`` returns the
23
43
  pod to a safe state (TCP: closes the socket; serial: sends the ``Q`` quit
24
44
  byte) and unblocks any in-flight ``read``.
25
45
  """