embeddedci 2.5.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.
- {embeddedci-2.5.0 → embeddedci-2.7.0}/CHANGELOG.md +66 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/PKG-INFO +12 -2
- {embeddedci-2.5.0 → embeddedci-2.7.0}/README.md +11 -1
- {embeddedci-2.5.0 → embeddedci-2.7.0}/pyproject.toml +1 -1
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/__init__.py +16 -1
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/capabilities.py +73 -1
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/client.py +26 -11
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/constants.py +6 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/errors.py +92 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/gpio.py +2 -2
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/protocol.py +4 -3
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/server_api.py +12 -2
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/state.py +10 -1
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/transport/base.py +22 -2
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/transport/cloud.py +196 -38
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/transport/serial.py +90 -45
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/transport/tcp.py +113 -39
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/uart.py +47 -5
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/api_surface.json +43 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_capabilities.py +54 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_client_v2.py +70 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_cloud.py +241 -8
- embeddedci-2.7.0/tests/test_dac_limits_fixture.py +112 -0
- embeddedci-2.7.0/tests/test_decode_parity.py +84 -0
- embeddedci-2.7.0/tests/test_refusal_errors.py +118 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_serial_json.py +32 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_tcp_transport.py +101 -0
- embeddedci-2.7.0/tests/test_transport_failures.py +468 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_uart_session.py +66 -0
- embeddedci-2.7.0/tests/testdata/spi_decode_vectors.json +784 -0
- embeddedci-2.7.0/tests/testdata/uart_decode_vectors.json +4017 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/.gitignore +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/LICENSE +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/docs/event-uart-design.md +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/examples/README.md +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/examples/test_bmp280.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/__init__.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/can.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/capture.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/ci.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/cloud_auth.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/connection.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/control_loop.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/decode.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/discovery.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/dsp.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/flash.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/i2c.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/lease.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/lowlevel.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/motor_emulator.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/power.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/pytest_plugin.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/replay.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/results.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/sensor.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/spi.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/transport/__init__.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/waveforms.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/benchpod/wiring.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/py.typed +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/src/embeddedci/upload_build.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/conftest.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/e2e/README.md +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/e2e/conftest.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/e2e/e2e_helpers.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/e2e/test_e2e_cloud.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/e2e/test_e2e_dut.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/e2e/test_e2e_gpio.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/e2e/test_e2e_la13_14.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/e2e/test_e2e_pod.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/e2e/test_e2e_rev3.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/e2e/test_e2e_spi.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/e2e/test_e2e_usb.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/examples/test_bmp280_hil.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/examples/test_can_ecu_demo.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/examples/test_can_loopback_hil.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/examples/test_smoke.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_adc_fft_hw.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_api_surface.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_can.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_capture.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_capture_replay_hw.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_ci.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_connection.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_control_loop.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_control_loop_hw.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_current_out_waveforms.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_dac_path_ranges.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_decode.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_dsp.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_flash_bridge.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_gpio.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_i2c_decode.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_i2c_parity.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_image_switching.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_integration.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_la_voltage_fixture.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_lease.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_motor_emulator.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_pins_bias.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_protocol.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_replay.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_sensor.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_serial_autodetect.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_serial_text_console.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_spi.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_timed_power.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_timing.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_trigger_power.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_uart_capture.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_upload_build.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_waveforms.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/test_wiring.py +0 -0
- {embeddedci-2.5.0 → embeddedci-2.7.0}/tests/testdata/i2c_decode_vectors.json +0 -0
|
@@ -1,5 +1,71 @@
|
|
|
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
|
+
|
|
52
|
+
## 2.6.0
|
|
53
|
+
|
|
54
|
+
- Cloud: the tunnel WebSocket sends the session token as an `Authorization: Bearer` header (and
|
|
55
|
+
the lease as `X-Benchpod-Lease`) instead of `?token=` in the URL, so the token stays out of
|
|
56
|
+
access logs. A tunnel error message never shows the token.
|
|
57
|
+
- Cloud: a client-side upload over `embeddedci:<device>` (`replay()` of a waveform, SPI PSRAM
|
|
58
|
+
staging) goes out in frames of at most 1 KiB, paced to 128 KiB/s. It used to be one WebSocket
|
|
59
|
+
frame, which the pod drops without an error above about 1.5 KB. The upload also skips the
|
|
60
|
+
`{"ack":N}` progress lines the pod sends on a cloud tunnel instead of taking the first one as
|
|
61
|
+
the completion.
|
|
62
|
+
- `PowerStatus.pod`: the pod's own 5 V draw, on boards that measure it (INA226 at 0x41, the
|
|
63
|
+
v3r1 analog and v3r2 digital boards). `PowerStatus.total_current` adds the DUT's internal rail
|
|
64
|
+
to it, which is what the USB input delivers. Both are `None` on other boards and on firmware
|
|
65
|
+
up to 3.5.1.
|
|
66
|
+
- `Capabilities.pod_current`: the board has that monitor. Parsed from the pod's `status` and the
|
|
67
|
+
server's `cap.pod_current`.
|
|
68
|
+
|
|
3
69
|
## 2.5.0
|
|
4
70
|
|
|
5
71
|
- `Capabilities.can`: the pod has classic CAN on CAN+/CAN- (`open_can`). Parsed from the pod's
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: embeddedci
|
|
3
|
-
Version: 2.
|
|
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,
|
|
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,
|
|
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` |
|
|
@@ -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()
|
|
@@ -171,6 +177,44 @@ class Capabilities:
|
|
|
171
177
|
#: Classic CAN on the pod's CAN+/CAN- terminals: :meth:`BenchPod.open_can`. Firmware older
|
|
172
178
|
#: than the flag still has CAN on every v2/v3 pod; it just doesn't say so.
|
|
173
179
|
can: bool = False
|
|
180
|
+
#: The board measures the pod's own 5 V current: :attr:`PowerStatus.pod`.
|
|
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
|
|
174
218
|
|
|
175
219
|
#: LA I/O-bank voltage the pod currently reports (mV), if known.
|
|
176
220
|
la_vccio_mv: int = 0
|
|
@@ -217,6 +261,18 @@ class Capabilities:
|
|
|
217
261
|
c.adc_channels = v
|
|
218
262
|
if (v := _as_int(status, "la_vccio_mv")) is not None:
|
|
219
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 "")
|
|
220
276
|
caps = status.get("caps")
|
|
221
277
|
if isinstance(caps, (list, tuple)):
|
|
222
278
|
names = {str(x).lower() for x in caps}
|
|
@@ -251,9 +307,14 @@ class Capabilities:
|
|
|
251
307
|
("calibrate", "calibrate"),
|
|
252
308
|
("current_out", "current_out"),
|
|
253
309
|
("can", "can"),
|
|
310
|
+
("pod_current", "pod_current"),
|
|
311
|
+
("dac_limits", "dac_limits"),
|
|
312
|
+
*((f, f) for f in _STATUS_FLAGS),
|
|
254
313
|
):
|
|
255
314
|
if name in names and hasattr(c, attr):
|
|
256
315
|
setattr(c, attr, True)
|
|
316
|
+
if "analog" in names:
|
|
317
|
+
c.analog = True
|
|
257
318
|
c.safe_mode = _as_bool(status, "safe_mode") is True
|
|
258
319
|
c.safe_reason = str(status.get("safe_reason", "") or "") if c.safe_mode else ""
|
|
259
320
|
c.last_crash = _crash_text(status.get("last_crash", status.get("crash")))
|
|
@@ -302,11 +363,22 @@ class Capabilities:
|
|
|
302
363
|
("nrst_pin", "cap.nrst_pin"), ("usb_cc", "cap.usb_cc"),
|
|
303
364
|
("spi_master", "cap.spi_master"), ("spi_stream", "cap.spi_stream"),
|
|
304
365
|
("calibrate", "cap.calibrate"), ("current_out", "cap.current_out"),
|
|
305
|
-
("can", "cap.can"),
|
|
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),
|
|
306
370
|
):
|
|
307
371
|
b = _as_bool(params, key)
|
|
308
372
|
if b is not None:
|
|
309
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 "")
|
|
310
382
|
c.safe_mode = _as_bool(params, "cap.safe_mode") is True
|
|
311
383
|
c.safe_reason = str(params.get("cap.safe_reason", "") or "") if c.safe_mode else ""
|
|
312
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
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
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 <=
|
|
1954
|
-
raise ValueError(f"steps must be
|
|
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
|
|
1957
|
-
raise ValueError(f"delay must be
|
|
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.
|
|
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
|
|
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`
|
|
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
|
|
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
|
-
|
|
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:
|
|
@@ -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
|
-
|
|
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)
|