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.
- {embeddedci-2.4.0 → embeddedci-2.6.0}/CHANGELOG.md +28 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/PKG-INFO +1 -1
- {embeddedci-2.4.0 → embeddedci-2.6.0}/pyproject.toml +1 -1
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/can.py +2 -1
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/capabilities.py +8 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/flash.py +3 -1
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/state.py +10 -1
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/transport/cloud.py +60 -13
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/transport/tcp.py +16 -2
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/api_surface.json +4 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/e2e/test_e2e_rev3.py +8 -2
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_can.py +9 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_client_v2.py +22 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_cloud.py +122 -6
- {embeddedci-2.4.0 → embeddedci-2.6.0}/.gitignore +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/LICENSE +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/README.md +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/docs/event-uart-design.md +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/examples/README.md +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/examples/test_bmp280.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/__init__.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/__init__.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/capture.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/ci.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/client.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/cloud_auth.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/connection.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/constants.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/control_loop.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/decode.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/discovery.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/dsp.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/errors.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/gpio.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/i2c.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/lease.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/lowlevel.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/motor_emulator.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/power.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/protocol.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/pytest_plugin.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/replay.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/results.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/sensor.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/server_api.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/spi.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/transport/__init__.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/transport/base.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/transport/serial.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/uart.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/waveforms.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/benchpod/wiring.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/py.typed +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/src/embeddedci/upload_build.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/conftest.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/e2e/README.md +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/e2e/conftest.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/e2e/e2e_helpers.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/e2e/test_e2e_cloud.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/e2e/test_e2e_dut.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/e2e/test_e2e_gpio.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/e2e/test_e2e_la13_14.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/e2e/test_e2e_pod.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/e2e/test_e2e_spi.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/e2e/test_e2e_usb.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/examples/test_bmp280_hil.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/examples/test_can_ecu_demo.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/examples/test_can_loopback_hil.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/examples/test_smoke.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_adc_fft_hw.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_api_surface.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_capabilities.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_capture.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_capture_replay_hw.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_ci.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_connection.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_control_loop.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_control_loop_hw.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_current_out_waveforms.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_dac_path_ranges.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_decode.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_dsp.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_flash_bridge.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_gpio.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_i2c_decode.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_i2c_parity.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_image_switching.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_integration.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_la_voltage_fixture.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_lease.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_motor_emulator.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_pins_bias.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_protocol.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_replay.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_sensor.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_serial_autodetect.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_serial_json.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_serial_text_console.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_spi.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_tcp_transport.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_timed_power.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_timing.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_trigger_power.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_uart_capture.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_uart_session.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_upload_build.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_waveforms.py +0 -0
- {embeddedci-2.4.0 → embeddedci-2.6.0}/tests/test_wiring.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
229
|
-
return
|
|
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 =
|
|
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 =
|
|
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.
|
|
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.
|
|
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
|
-
#
|
|
187
|
-
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
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(
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|