oep-client-python 0.0.8__tar.gz → 0.0.10__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 (54) hide show
  1. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/CHANGELOG.md +10 -0
  2. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/PKG-INFO +16 -4
  3. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/README.ja.md +13 -3
  4. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/README.md +15 -3
  5. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/pyproject.toml +1 -1
  6. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/src/oep_client/__init__.py +1 -1
  7. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/src/oep_client/capture.py +147 -17
  8. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/src/oep_client/endpoint.py +180 -6
  9. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/src/oep_client/fake.py +51 -4
  10. oep_client_python-0.0.10/src/oep_client/fake_capture.py +437 -0
  11. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/src/oep_client/fake_serial.py +2 -0
  12. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/src/oep_client/fake_serve.py +8 -1
  13. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/src/oep_client/registry.py +8 -5
  14. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/tests/test_capabilities.py +3 -3
  15. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/tests/test_capture.py +1 -1
  16. oep_client_python-0.0.10/tests/test_fake_capture.py +256 -0
  17. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/tests/test_fake_spec.py +2 -0
  18. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/tests/test_link_serial.py +1 -1
  19. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/.gitignore +0 -0
  20. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/LICENSE +0 -0
  21. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/src/oep_client/__main__.py +0 -0
  22. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/src/oep_client/arm.py +0 -0
  23. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/src/oep_client/catalog.py +0 -0
  24. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/src/oep_client/ch32_flash.py +0 -0
  25. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/src/oep_client/cobs.py +0 -0
  26. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/src/oep_client/config.py +0 -0
  27. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/src/oep_client/console.py +0 -0
  28. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/src/oep_client/core.py +0 -0
  29. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/src/oep_client/decode.py +0 -0
  30. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/src/oep_client/dump.py +0 -0
  31. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/src/oep_client/esp32_targets.py +0 -0
  32. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/src/oep_client/fixture.py +0 -0
  33. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/src/oep_client/frames.py +0 -0
  34. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/src/oep_client/hid_stream.py +0 -0
  35. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/src/oep_client/host.py +0 -0
  36. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/src/oep_client/interfaces.py +0 -0
  37. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/src/oep_client/link.py +0 -0
  38. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/src/oep_client/message.py +0 -0
  39. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/src/oep_client/names.py +0 -0
  40. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/src/oep_client/riscv.py +0 -0
  41. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/src/oep_client/rp2350.py +0 -0
  42. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/src/oep_client/target.py +0 -0
  43. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/src/oep_client/uiapduino.py +0 -0
  44. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/src/oep_client/usb_stream.py +0 -0
  45. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/tests/test_cobs.py +0 -0
  46. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/tests/test_config.py +0 -0
  47. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/tests/test_esp32_targets.py +0 -0
  48. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/tests/test_flash_console.py +0 -0
  49. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/tests/test_hid_stream.py +0 -0
  50. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/tests/test_interfaces.py +0 -0
  51. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/tests/test_link_host.py +0 -0
  52. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/tests/test_session.py +0 -0
  53. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/tests/test_target_parts.py +0 -0
  54. {oep_client_python-0.0.8 → oep_client_python-0.0.10}/tests/test_usb_stream.py +0 -0
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.0.10
6
+ - (EN) Captures' times are ns on the probe's one clock with an uncertainty (oep-if-capture): `Segment.start_ns` / `start_uncertainty_ns` replace `start_us` (a segment is 33 bytes), the triggered event carries `trigger_ns`, and `Config.rate_measured` / `rate_ppm` say how sure the rate is. Analog captures: `AnalogCapture` (raw `values()`, the probe's `millivolts()`, `calibration()` - factory data and Vrefint, raw), with the answer read per channel (`zero`, `scale_nv`, `skew_ns`, `frontend`) and `reference`; `configure(frontends=)`. `CaptureGroup` binds tracks, starts them together with one trigger, and gives the group's `start_ns` and `trigger_ns`. The fake has them all (p4-x035: an analog on the ADC1 and a group), and describes a `chip`.
7
+ - (JA) キャプチャの時刻は、probe の 1 本の時計の ns と不確かさになった(oep-if-capture): `start_us` は `Segment.start_ns` / `start_uncertainty_ns` になり(区画は 33 byte)、出来事 triggered は `trigger_ns` を持ち、`Config.rate_measured` / `rate_ppm` がレートの確かさを示す。アナログ: `AnalogCapture`(生の `values()`、probe の 1 次式の `millivolts()`、`calibration()`: 出荷時の値と Vrefint を生のまま)。答えはチャネルごとに読む(`zero`、`scale_nv`、`skew_ns`、`frontend`)、`reference` も。`configure(frontends=)`。`CaptureGroup` はトラックを束ね、1 つのトリガで一緒に始め、組の `start_ns` と `trigger_ns` を返す。偽の probe はこれらをすべて持つ(p4-x035: ADC1 のアナログと組)。core の describe の `chip` も出す。
8
+
9
+ ## 0.0.9
10
+ - (EN) The fake probe has `oep.fixture.capture` (logic, `fake_capture`): one-shot, repeat (a ring and release), streaming (data pushes), level / edge triggers with a pretrigger, events, and a known waveform (sample i = the counter i, channel k = its bit k) in the profile's layout (P4: w 1-16; classic ESP32: w 8). A capture shares pins with other interfaces (it only listens). `Endpoint.pushes()` gives the frames the probe sends by itself; fake_serve sends them on the pty and TCP. `--capture-slipped` sets flags bit2 on every segment.
11
+ - (JA) 偽の probe に `oep.fixture.capture`(ロジック、`fake_capture`)を入れた: ワンショット、リピート(リングと release)、ストリーミング(データの push)、レベル / エッジのトリガとプリトリガ、出来事。取れる波形は決まっている(サンプル i = カウンタ i、チャネル k = そのビット k)。layout は profile のもの(P4: w 1〜16、classic ESP32: w 8)。capture はほかのインターフェースとピンを共有できる(聞くだけ)。`Endpoint.pushes()` が probe の送り出すフレームを返し、fake_serve は pty と TCP に流す。`--capture-slipped` は区画すべてに flags bit2 を立てる。
12
+ - (EN) The fake: an attach without pins joins the wire's one live connection, also on a wire whose pins the host chooses (oep-if-debug §1).
13
+ - (JA) 偽の probe: pins の無い attach は、その線の生きている接続が 1 つならそれに乗る。host がピンを選ぶ線でも同じ(oep-if-debug §1)。
14
+
5
15
  ## 0.0.8
6
16
  - (EN) Wires whose pins the host chooses (oep-if-debug §1, role_channels): the fake takes any allowed free pair, skips the pairs whose pins something holds in a count-0 scan, holds a live connection's pins against plans, and has a profile for it (`rp2350-pins`). `Wire.scan()` goes through the whole list (at most 255 pairs a request; count 0 continues with the new skip TLV until tried = 0).
7
17
  - (JA) host がピンを選ぶ wire(oep-if-debug §1、role_channels): 偽の probe は、許された空いている組ならどれでも受け、count = 0 の scan では何かが持っているピンの組を飛ばし、生きている接続のピンを plan から守る。そのための profile(`rp2350-pins`)を足した。`Wire.scan()` は並びを最後まで回る(1 回 255 組まで。count = 0 は新しい skip の TLV で tried = 0 まで続ける)。
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: oep-client-python
3
- Version: 0.0.8
3
+ Version: 0.0.10
4
4
  Summary: Open Embedded Probe (OEP) v1 host: serial / USB / TCP transports, the session rules, the standard interfaces, and a fake probe
5
5
  Project-URL: Homepage, https://github.com/Open-Embedded-Probe/oep-client-python
6
6
  Project-URL: Repository, https://github.com/Open-Embedded-Probe/oep-client-python
@@ -107,8 +107,19 @@ A run on hardware: ArduinoCore-CH32's `tests/manual/oep_smoke/` (`oep_smoke.py`,
107
107
 
108
108
  `endpoint.Endpoint` is a fake probe that answers as oep-spec says; ch32rv, this client and the probe firmware are checked
109
109
  against it (when the spec changes, this is brought in line before the firmware). `fake` holds example declarations (profiles
110
- `p4-x035`, `esp32-v003`, `p4-bench` = a made-up jig with three slots and two seats), `fake_serial` the byte side of a serial
111
- port (COBS candidates, raw bytes and binds, held during a session and resumed after it).
110
+ `p4-x035`, `esp32-v003`, `p4-bench` = a made-up jig with three slots and two seats, `rp2350-pins` = a wire whose pins the host
111
+ chooses), `fake_serial` the byte side of a serial port (COBS candidates, raw bytes and binds, held during a session and
112
+ resumed after it).
113
+
114
+ `fake_capture` is `oep.fixture.capture` (logic): one-shot, repeat (segments with the clock at the actual rate, a ring,
115
+ release) and streaming (data pushes while subscribed), level / edge triggers with a pretrigger, and events. What it
116
+ captures is known: sample i is the counter i, channel k its bit k (a square wave of period 2^(k+1) samples), in the layout
117
+ the profile allows (`p4-x035`: w 1-16 as the P4's PARLIO, three channels in w 4; `esp32-v003`: w 8 as the classic ESP32's
118
+ sampler, one-shot only). A capture only listens, so it may be planned on pins other interfaces hold. `p4-x035` also has
119
+ `oep.fixture.analog` (4 channels of the P4's ADC1 on GPIO16-23: an even channel k a square wave, an odd one a sine, of
120
+ period 64 (k // 2 + 1) samples, 12-bit values in 16-bit slots; ESP32-style frontends; a made-up two-point calibration and a
121
+ Vrefint) and `oep.fixture.capture-group` binding the logic and the analog: started together, the analog 5 us later
122
+ (+-2 us), the trigger of one marked on both. Times are ns on the probe's one clock.
112
123
 
113
124
  Other programs' tests run `fake_serve` as a child process:
114
125
 
@@ -122,4 +133,5 @@ The pty is a serial port (the host opens it with TIOCEXCL); `--tcp PORT` is `--f
122
133
  `--framing length` (the vendor bulk / TCP form). Faults: `--drop N` (the N-th answer is not sent, once; the request did run,
123
134
  so a resend gets the remembered result), `--noise TEXT` (noise before every answer), `--corrupt N` (the N-th answer's CRC
124
135
  broken once). `--uart-plan` / `--uart-rx` give the first fixture UART a plan and RX bytes, `--run-hook` a host's own model of
125
- riscv-dm run. The rest: `--help`.
136
+ riscv-dm run, `--capture-slipped` flags bit2 on every capture segment. Events and data pushes go out on the pty and on TCP
137
+ (both framings). The rest: `--help`.
@@ -80,8 +80,17 @@ oep config save <probe> # 再起動の後も残す(remove
80
80
 
81
81
  `endpoint.Endpoint` は oep-spec の規範どおりに答える偽の probe で、ch32rv・この client・probe の firmware を突き合わせる
82
82
  「動く spec」として使う(spec が変わったら、probe の firmware より先にここを合わせる)。`fake` は宣言の例(profile:
83
- `p4-x035`、`esp32-v003`、`p4-bench` = スロット 3 か所と席 2 つの架空の治具)、`fake_serial` はシリアルの口のバイトの側(COBS の
84
- 候補、生のバイトと bind、セッション中の停止と再開)。
83
+ `p4-x035`、`esp32-v003`、`p4-bench` = スロット 3 か所と席 2 つの架空の治具、`rp2350-pins` = host がピンを選ぶ wire)、`fake_serial` は
84
+ シリアルの口のバイトの側(COBS の候補、生のバイトと bind、セッション中の停止と再開)。
85
+
86
+ `fake_capture` は `oep.fixture.capture`(ロジック): ワンショット、リピート(実際のレートで時計どおりに区画ができ、リング、release)、
87
+ ストリーミング(購読している間のデータの push)、レベル / エッジのトリガとプリトリガ、出来事。取れるものは決まっている: サンプル i は
88
+ カウンタの値 i で、チャネル k はそのビット k(周期 2^(k+1) サンプルの方形波)。layout は profile が許す形(`p4-x035`: P4 の
89
+ PARLIO と同じ w 1〜16、3 本なら w 4。`esp32-v003`: classic ESP32 の sampler と同じ w 8、ワンショットだけ)。capture は聞くだけ
90
+ なので、ほかのインターフェースが持つピンにも plan できる。`p4-x035` には `oep.fixture.analog`(P4 の ADC1 の 4 チャネル、GPIO16〜23。
91
+ 偶数のチャネル k は方形波、奇数は正弦波で、周期は 64 (k // 2 + 1) サンプル。16 ビットの枠に 12 ビットの値。ESP32 と同じ形の
92
+ frontend、作り物の 2 点の較正と Vrefint)と、ロジックとアナログを束ねる `oep.fixture.capture-group` もある(一緒に始まり、
93
+ アナログは 5 µs 遅れ(±2 µs)、片方のトリガが両方に印される)。時刻は probe の 1 本の時計の ns。
85
94
 
86
95
  外のプログラムの試験には `fake_serve` を子プロセスで使う:
87
96
 
@@ -93,6 +102,7 @@ uv run python -m oep_client.fake_serve --pty --profile p4-bench --slot x035 --bi
93
102
 
94
103
  pty がシリアルの口(host が TIOCEXCL を掛けて開く)、`--tcp PORT` は `--framing cobs`(シリアルの口)か `--framing length`
95
104
  (vendor bulk / TCP の形)。故障の注入は `--drop N`(N 番目の答えを 1 回出さない。要求は実行済みなので送り直しは覚えた答えを
96
- 受ける)、`--noise TEXT`(答えの前に雑音)、`--corrupt N`(N 番目の答えの CRC を 1 回壊す)。ほかは `--help`。
105
+ 受ける)、`--noise TEXT`(答えの前に雑音)、`--corrupt N`(N 番目の答えの CRC を 1 回壊す)。`--capture-slipped` は capture の
106
+ 区画すべてに flags bit2 を立てる。出来事とデータの push は pty にも TCP(両方の framing)にも出る。ほかは `--help`。
97
107
 
98
108
  v0 の client(`oep_client.v0`)は 2026-09-26 に消した(git の履歴に残る)。v0 を話す probe はもう無い。
@@ -85,8 +85,19 @@ A run on hardware: ArduinoCore-CH32's `tests/manual/oep_smoke/` (`oep_smoke.py`,
85
85
 
86
86
  `endpoint.Endpoint` is a fake probe that answers as oep-spec says; ch32rv, this client and the probe firmware are checked
87
87
  against it (when the spec changes, this is brought in line before the firmware). `fake` holds example declarations (profiles
88
- `p4-x035`, `esp32-v003`, `p4-bench` = a made-up jig with three slots and two seats), `fake_serial` the byte side of a serial
89
- port (COBS candidates, raw bytes and binds, held during a session and resumed after it).
88
+ `p4-x035`, `esp32-v003`, `p4-bench` = a made-up jig with three slots and two seats, `rp2350-pins` = a wire whose pins the host
89
+ chooses), `fake_serial` the byte side of a serial port (COBS candidates, raw bytes and binds, held during a session and
90
+ resumed after it).
91
+
92
+ `fake_capture` is `oep.fixture.capture` (logic): one-shot, repeat (segments with the clock at the actual rate, a ring,
93
+ release) and streaming (data pushes while subscribed), level / edge triggers with a pretrigger, and events. What it
94
+ captures is known: sample i is the counter i, channel k its bit k (a square wave of period 2^(k+1) samples), in the layout
95
+ the profile allows (`p4-x035`: w 1-16 as the P4's PARLIO, three channels in w 4; `esp32-v003`: w 8 as the classic ESP32's
96
+ sampler, one-shot only). A capture only listens, so it may be planned on pins other interfaces hold. `p4-x035` also has
97
+ `oep.fixture.analog` (4 channels of the P4's ADC1 on GPIO16-23: an even channel k a square wave, an odd one a sine, of
98
+ period 64 (k // 2 + 1) samples, 12-bit values in 16-bit slots; ESP32-style frontends; a made-up two-point calibration and a
99
+ Vrefint) and `oep.fixture.capture-group` binding the logic and the analog: started together, the analog 5 us later
100
+ (+-2 us), the trigger of one marked on both. Times are ns on the probe's one clock.
90
101
 
91
102
  Other programs' tests run `fake_serve` as a child process:
92
103
 
@@ -100,4 +111,5 @@ The pty is a serial port (the host opens it with TIOCEXCL); `--tcp PORT` is `--f
100
111
  `--framing length` (the vendor bulk / TCP form). Faults: `--drop N` (the N-th answer is not sent, once; the request did run,
101
112
  so a resend gets the remembered result), `--noise TEXT` (noise before every answer), `--corrupt N` (the N-th answer's CRC
102
113
  broken once). `--uart-plan` / `--uart-rx` give the first fixture UART a plan and RX bytes, `--run-hook` a host's own model of
103
- riscv-dm run. The rest: `--help`.
114
+ riscv-dm run, `--capture-slipped` flags bit2 on every capture segment. Events and data pushes go out on the pty and on TCP
115
+ (both framings). The rest: `--help`.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "oep-client-python"
7
- version = "0.0.8"
7
+ version = "0.0.10"
8
8
  description = "Open Embedded Probe (OEP) v1 host: serial / USB / TCP transports, the session rules, the standard interfaces, and a fake probe"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.0.8"
5
+ __version__ = "0.0.10"
@@ -1,5 +1,9 @@
1
- """oep.fixture.capture / oep.fixture.analog revision 1 (oep-spec docs/oep-if-capture.ja.md: §1 layouts, §2 segments,
2
- §3 operations). Numbers from `registry`.
1
+ """oep.fixture.capture / oep.fixture.analog / oep.fixture.capture-group revision 1 (oep-spec docs/oep-if-capture.ja.md:
2
+ §1 layouts, §2 segments, §3 operations, §3.8 calibration, §4 groups). Numbers from `registry`.
3
+
4
+ Times are the probe's one clock (ns since its boot, comparable within one boot_id): estimates with an uncertainty, the
5
+ probe's known corrections applied. Analog values are always raw; the probe's 1st-order scale, its calibration data and
6
+ its reference are for the host to choose from.
3
7
 
4
8
  configure: a TLV the probe cannot honour is refused (rejected unsupported, 0x0B, payload = the tag) when the host marked
5
9
  it critical, and otherwise ignored and listed in the answer's ignored TLV (0x7F, oep-core §2.3)."""
@@ -17,12 +21,17 @@ from .frames import FramingLost
17
21
  from .core import Interface, confirm
18
22
 
19
23
  _CAP = reg.FIXTURE_CAPTURE
24
+ _ANA = reg.FIXTURE_ANALOG
25
+ _GRP = reg.FIXTURE_CAPTURE_GROUP
20
26
  # configure TLVs; bit 7 of a tag = critical (the probe must reject what it cannot do)
21
27
  MODE, RATE, SAMPLES, SEGMENTS, TRIGGER, PRETRIGGER, FRONTEND = (
22
28
  _CAP.tlv["configure"][k] for k in ("mode", "rate", "samples", "segments", "trigger", "pretrigger", "frontend"))
23
- ACTUAL_RATE, LAYOUT, ACTUAL_SAMPLES, ACTUAL_SEGMENTS, TIMING, SCALE, BLOCKING = (
24
- _CAP.tlv["configure_answer"][k] for k in ("actual_rate", "layout", "actual_samples", "actual_segments", "timing",
25
- "scale", "blocking_ms"))
29
+ ACTUAL_RATE, LAYOUT, ACTUAL_SAMPLES, ACTUAL_SEGMENTS, TIMING, SCALE, BLOCKING, SKEW, FRONTEND_USED, REFERENCE, \
30
+ RATE_ACCURACY = (_ANA.tlv["configure_answer"][k] for k in (
31
+ "actual_rate", "layout", "actual_samples", "actual_segments", "timing", "scale", "blocking_ms", "skew",
32
+ "frontend_used", "reference", "rate_accuracy"))
33
+ FACTORY, VREFINT = _ANA.tlv["calibration_answer"]["factory"], _ANA.tlv["calibration_answer"]["vrefint"]
34
+ REFERENCE_SOURCE = {v: k for k, v in _ANA.enum["reference_source"].items()}
26
35
  IGNORED = m.TAG_IGNORED
27
36
  CRITICAL = m.TAG_CRITICAL
28
37
  ONE_SHOT, REPEAT, STREAMING = (_CAP.enum["mode"][k] for k in ("one_shot", "repeat", "streaming"))
@@ -37,7 +46,8 @@ def tlvs(payload: bytes) -> list[tuple[int, bytes]]:
37
46
  return m.split_tlvs(payload)
38
47
 
39
48
 
40
- SEGMENT_BYTES = 29 # serial u32, position u64, samples u32, start_us u64, trigger_index u32, flags u8
49
+ SEGMENT_BYTES = 33 # serial u32, position u64, samples u32, start_ns u64, start_uncertainty_ns u32, trigger_index u32,
50
+ # flags u8
41
51
 
42
52
 
43
53
  @dataclass
@@ -45,7 +55,8 @@ class Segment:
45
55
  serial: int
46
56
  position: int
47
57
  samples: int
48
- start_us: int
58
+ start_ns: int # the first sample's time on the probe's clock (an estimate)
59
+ start_uncertainty_ns: int # +- of start_ns (a guide, not a promise)
49
60
  trigger_index: int | None
50
61
  flags: int
51
62
 
@@ -56,8 +67,8 @@ class Segment:
56
67
 
57
68
  @classmethod
58
69
  def unpack(cls, b: bytes) -> "Segment":
59
- serial, position, samples, start_us, trig, flags = struct.unpack_from("<IQIQIB", b)
60
- return cls(serial, position, samples, start_us, None if trig == 0xFFFFFFFF else trig, flags)
70
+ serial, position, samples, start_ns, uncertainty, trig, flags = struct.unpack_from("<IQIQIIB", b)
71
+ return cls(serial, position, samples, start_ns, uncertainty, None if trig == 0xFFFFFFFF else trig, flags)
61
72
 
62
73
 
63
74
  @dataclass
@@ -74,9 +85,13 @@ class Config:
74
85
  segments: int = 0
75
86
  jitter_kind: int = 0
76
87
  jitter_ns: int = 0
77
- skew_ns: list[int] = field(default_factory=list)
78
- zero: int = 0
79
- scale_nv: int = 0
88
+ rate_measured: bool = False # rate_accuracy: the rate was measured (else computed from a divider)
89
+ rate_ppm: int = 0 # its uncertainty (0: unknown)
90
+ skew_ns: dict[int, int] = field(default_factory=dict) # analog, per channel (role)
91
+ zero: dict[int, int] = field(default_factory=dict) # analog, per channel
92
+ scale_nv: dict[int, int] = field(default_factory=dict) # analog, nV per value, per channel
93
+ frontend: dict[int, int] = field(default_factory=dict) # analog: the frontend each channel took
94
+ reference: tuple[str, int, bool] | None = None # analog: (source, mV, measured)
80
95
  blocking_ms: int = 0
81
96
  ignored: list[int] = field(default_factory=list)
82
97
 
@@ -140,9 +155,19 @@ def _config(payload: bytes, analog: bool) -> Config:
140
155
  c.segments = struct.unpack("<I", v)[0]
141
156
  elif tag == TIMING:
142
157
  c.jitter_kind, c.jitter_ns = v[0], struct.unpack_from("<I", v, 1)[0]
143
- c.skew_ns = [struct.unpack_from("<I", v, 5 + 4 * i)[0] for i in range((len(v) - 5) // 4)]
144
- elif tag == SCALE:
145
- c.zero, c.scale_nv = struct.unpack("<II", v)
158
+ elif tag == RATE_ACCURACY:
159
+ c.rate_measured, c.rate_ppm = v[0] == 1, struct.unpack_from("<I", v, 1)[0]
160
+ elif tag == SCALE and analog:
161
+ role, zero, scale = struct.unpack("<BII", v)
162
+ c.zero[role], c.scale_nv[role] = zero, scale
163
+ elif tag == SKEW and analog:
164
+ role, ns = struct.unpack("<BI", v)
165
+ c.skew_ns[role] = ns
166
+ elif tag == FRONTEND_USED and analog:
167
+ c.frontend[v[0]] = v[1]
168
+ elif tag == REFERENCE and analog:
169
+ source, mv, how = struct.unpack("<BIB", v)
170
+ c.reference = (REFERENCE_SOURCE.get(source, str(source)), mv, how == 1)
146
171
  elif tag == BLOCKING:
147
172
  c.blocking_ms = struct.unpack("<I", v)[0]
148
173
  elif tag == IGNORED:
@@ -165,7 +190,7 @@ class LogicCapture(Interface):
165
190
 
166
191
  def configure(self, *, rate: int, mode: int = ONE_SHOT, samples: int | None = None, segments: int | None = None,
167
192
  trigger: tuple[int, int, int] | None = None, pretrigger: int | None = None, query: bool = False,
168
- critical: set[int] = frozenset()) -> Config:
193
+ critical: set[int] = frozenset(), frontends: dict[int, int] | None = None) -> Config:
169
194
  """-> the probe's actual values (Config.ignored: tags the probe ignored). `critical`: tags the probe must honour
170
195
  or reject (host.Unsupported, .tag = the one it cannot)."""
171
196
  def tlv(tag: int, value: bytes) -> bytes:
@@ -179,6 +204,8 @@ class LogicCapture(Interface):
179
204
  body += tlv(TRIGGER, struct.pack("<BBH", *trigger))
180
205
  if pretrigger is not None:
181
206
  body += tlv(PRETRIGGER, struct.pack("<I", pretrigger))
207
+ for role, fe in sorted((frontends or {}).items()): # analog: the input range per channel (describe frontend)
208
+ body += tlv(FRONTEND, bytes([role, fe]))
182
209
  # query is its own operation: the lock is decided per operation, before the payload is looked at
183
210
  op = self.QUERY_OP if query else self.CONFIGURE
184
211
  c = _config(self._call(op, body, locked=not query).payload, self.ANALOG)
@@ -331,7 +358,8 @@ class LogicCapture(Interface):
331
358
  """The segment's bytes; every Host.on_capture callback gets them as a CaptureRecord (a run recorder, e.g.
332
359
  pytest-embedded-wireskein, without this package knowing it)."""
333
360
  c = self.config
334
- data = self.read(segment.position, (segment.samples * c.width + 7) // 8)
361
+ n = (segment.samples * c.width + 7) // 8 if c.width else segment.samples * len(c.order) * c.slot // 8
362
+ data = self.read(segment.position, n)
335
363
  if self.host.on_capture:
336
364
  record = CaptureRecord(self.fn, self.name, c, segment, data, self.armed_s, time.monotonic())
337
365
  for callback in list(self.host.on_capture):
@@ -365,3 +393,105 @@ class LogicCapture(Interface):
365
393
  z.writestr("version", "2")
366
394
  z.writestr("metadata", "\n".join(meta))
367
395
  z.writestr("logic-1-1", out)
396
+
397
+
398
+ @dataclass
399
+ class Calibration:
400
+ """What the probe knows for turning an analog value into a voltage (oep-if-capture §3.8), raw: the probe applies
401
+ none of it. factory: (frontend or None, scheme, raw bytes) - scheme names how to read raw; vrefint: (raw, ns), the
402
+ internal reference measured after the last start."""
403
+ factory: list[tuple[int | None, str, bytes]] = field(default_factory=list)
404
+ vrefint: tuple[int, int] | None = None
405
+
406
+
407
+ class AnalogCapture(LogicCapture):
408
+ """Basic analog capture (oep.fixture.analog): the same operations as the logic one; values are raw (§1.2)."""
409
+ NAME = "oep.fixture.analog"
410
+ ANALOG = True
411
+ CALIBRATION = _ANA.op["calibration"]
412
+
413
+ def values(self, data: bytes, k: int, samples: int | None = None) -> list[int]:
414
+ """Channel k's raw values (§1.2: slot s bits little endian, the value in bits o .. o+b-1, frames in `order`)."""
415
+ c = self.config
416
+ width = c.slot // 8
417
+ frame = width * len(c.order)
418
+ n = samples if samples is not None else len(data) // frame
419
+ m_ = c.order.index(k)
420
+ mask = (1 << c.bits) - 1
421
+ return [(int.from_bytes(data[i * frame + m_ * width:i * frame + (m_ + 1) * width], "little") >> c.offset) & mask
422
+ for i in range(n)]
423
+
424
+ def millivolts(self, k: int, value: int) -> float:
425
+ """The probe's own 1st-order reading of a raw value of channel k, (value - zero) x scale_nv (a nominal
426
+ reference: see reference and calibration() for others)."""
427
+ c = self.config
428
+ return (value - c.zero.get(k, 0)) * c.scale_nv.get(k, 0) / 1_000_000
429
+
430
+ def calibration(self) -> Calibration:
431
+ out = Calibration()
432
+ for tag, v in tlvs(self._call(self.CALIBRATION, locked=False).payload):
433
+ if tag == FACTORY:
434
+ n = v[1]
435
+ out.factory.append((None if v[0] == 0xFF else v[0], v[2:2 + n].decode("utf-8", "replace"), bytes(v[2 + n:])))
436
+ elif tag == VREFINT:
437
+ out.vrefint = struct.unpack("<IQ", v)
438
+ return out
439
+
440
+
441
+ @dataclass
442
+ class GroupStatus:
443
+ state: int
444
+ start_ns: int | None
445
+ trigger_ns: int | None
446
+ trigger_fn: int | None
447
+
448
+
449
+ class CaptureGroup(Interface):
450
+ """oep.fixture.capture-group (§4): tracks (LogicCapture / AnalogCapture, each configured as usual) started together,
451
+ one of them the trigger. Each track is read as usual; a track's offset is its first segment's start_ns minus the
452
+ group's start_ns, and every track's segment marks the trigger's instant (trigger_index)."""
453
+ NAME = "oep.fixture.capture-group"
454
+ REVISION = 1
455
+ BIND, START, STOP, FORCE, STATUS = (_GRP.op[k] for k in ("bind", "start", "stop", "force", "status"))
456
+ TAG_TRIGGER_TRACK = _GRP.tlv["bind"]["trigger_track"]
457
+ EVENT_TRIGGERED, EVENT_STOPPED = _GRP.event["triggered"], _GRP.event["stopped"]
458
+ NO_TIME = 0xFFFFFFFFFFFFFFFF
459
+
460
+ def bind(self, tracks: list[LogicCapture], trigger: LogicCapture | None = None) -> None:
461
+ """Bind these (configured) tracks; [] unbinds. `trigger`: the track whose configure trigger starts them all."""
462
+ body = struct.pack(f"<B{len(tracks)}H", len(tracks), *(t.fn for t in tracks))
463
+ if trigger is not None:
464
+ body += m.tlv(self.TAG_TRIGGER_TRACK, struct.pack("<H", trigger.fn), critical=True)
465
+ self._call(self.BIND, body)
466
+
467
+ def start(self, tracks: list[LogicCapture] = ()) -> tuple[int, int]:
468
+ """-> (blocking_ms, the group's start_ns). `tracks`: whose armed_s to set (for records)."""
469
+ blocking, start_ns = m.Reader(self._call(self.START).payload).take("IQ")
470
+ now = time.monotonic()
471
+ for t in tracks:
472
+ t.armed_s = now
473
+ return blocking, start_ns
474
+
475
+ def stop(self) -> None:
476
+ self._call(self.STOP)
477
+
478
+ def force(self) -> None:
479
+ self._call(self.FORCE)
480
+
481
+ def status(self) -> GroupStatus:
482
+ state, start, trig, fn = m.Reader(self._call(self.STATUS, locked=False).payload).take("BQQH")
483
+ none = self.NO_TIME
484
+ return GroupStatus(state, None if start == none else start, None if trig == none else trig, fn or None)
485
+
486
+ def wait(self, timeout: float = 5.0) -> GroupStatus:
487
+ """Poll until every track is done (one-shot)."""
488
+ deadline = time.monotonic() + timeout
489
+ while time.monotonic() < deadline:
490
+ st = self.status()
491
+ if st.state == STATE["done"]:
492
+ return st
493
+ if st.state == STATE["error"]:
494
+ raise h.Failed(None, "the group's capture stopped with an error")
495
+ time.sleep(0.002)
496
+ raise TimeoutError("the group's capture did not finish")
497
+
@@ -39,7 +39,7 @@ from collections import OrderedDict
39
39
  from dataclasses import dataclass, field
40
40
  from typing import Callable
41
41
 
42
- from . import catalog, fake, message as m, registry as reg
42
+ from . import catalog, fake, fake_capture, message as m, registry as reg
43
43
 
44
44
  TOY_WRITE, TOY_READ = 0x01, 0x02
45
45
  OK, WAIT, LINE, FAULT, TIMEOUT, STATE = (reg.STATUS[k] for k in ("ok", "wait", "line", "fault", "timeout", "state"))
@@ -275,6 +275,11 @@ class Endpoint:
275
275
  first = self._role_pairs(fn)[0]
276
276
  self.targets[(fn, first)] = FakeTarget()
277
277
  self.target = next(iter(self.targets.values()), FakeTarget())
278
+ self.captures: dict[int, fake_capture.FakeCapture] = {
279
+ fn: self._capture_from(self.static[fn]) for fn, name in self.names.items()
280
+ if name in ("oep.fixture.capture", "oep.fixture.analog")}
281
+ self.groups: dict[int, fake_capture.FakeGroup] = {
282
+ fn: self._group_from(self.static[fn]) for fn, name in self.names.items() if name == "oep.fixture.capture-group"}
278
283
  self.mechanisms = set()
279
284
  for fn, name in self.names.items():
280
285
  if name == "oep.target.console":
@@ -302,6 +307,9 @@ class Endpoint:
302
307
  self.dropped = 0 # requests a v0 endpoint dropped (role 0x81)
303
308
  self.requests: list[m.Request] = []
304
309
  self.subscribed: set[int] = set()
310
+ self.push_seq: dict[int, int] = {} # fn -> the next event / data seq (core §11.2)
311
+ self.outbox: list[bytes] = [] # events and data frames waiting to go out (pushes())
312
+ self.capture_slipped = False # every capture segment says flags bit2 (a pace that fell behind)
305
313
  self.plan: set[tuple[int, int, int]] = set() # (fn, role, channel), from plan_apply and the config
306
314
  self.plan_from_config: set[int] = set() # fns whose plan came from the config (not a session's)
307
315
  self.resend: OrderedDict[int, tuple[int, int, int, bytes | None]] = OrderedDict()
@@ -340,6 +348,155 @@ class Endpoint:
340
348
  def target_id(self, value: int | None) -> None:
341
349
  self.target.target_id = value
342
350
 
351
+ @staticmethod
352
+ def _capture_from(tlvs: list[bytes]) -> fake_capture.FakeCapture:
353
+ """A capture as its describe declares it (modes, the w allowed, the rate range, max_read, the ring)."""
354
+ d = reg.FIXTURE_CAPTURE.tlv["describe"]
355
+ modes, widths, lo, hi, ring, most, fronts = set(), {8}, 1, 1_000_000, 1, 1024, {}
356
+ for t in tlvs:
357
+ tag, v = t[0], t[2:2 + t[1]]
358
+ if tag == d["frontend"]: # analog: frontend min_mv max_mv attenuation_mdb
359
+ fe, lo_mv, hi_mv, mdb = struct.unpack("<BiiI", v)
360
+ fronts[fe] = (lo_mv, hi_mv, mdb)
361
+ elif tag == d["mode"]:
362
+ modes.add(v[0])
363
+ elif tag == d["channels"]:
364
+ widths = {1 << i for i in range(6) if v[1] >> i & 1}
365
+ elif tag == catalog.MIN_CLOCK_HZ:
366
+ lo = struct.unpack("<I", v)[0]
367
+ elif tag == catalog.MAX_CLOCK_HZ:
368
+ hi = struct.unpack("<I", v)[0]
369
+ elif tag == d["segment_ring"]:
370
+ ring = struct.unpack("<H", v)[0]
371
+ elif tag == d["max_read"]:
372
+ most = struct.unpack("<I", v)[0]
373
+ return fake_capture.FakeCapture(modes or {fake_capture.MODE["one_shot"]}, widths, lo, hi, ring, most, fronts)
374
+
375
+ @staticmethod
376
+ def _group_from(tlvs: list[bytes]) -> fake_capture.FakeGroup:
377
+ d = reg.FIXTURE_CAPTURE_GROUP.tlv["describe"]
378
+ tracks, most, budgets = [], 1, []
379
+ for t in tlvs:
380
+ tag, v = t[0], t[2:2 + t[1]]
381
+ if tag == d["tracks"]:
382
+ tracks = list(struct.unpack(f"<{len(v) // 2}H", v))
383
+ elif tag == d["max_tracks"]:
384
+ most = v[0]
385
+ elif tag == d["budget"]:
386
+ budgets.append((struct.unpack_from("<I", v)[0], list(struct.unpack_from(f"<{(len(v) - 4) // 2}H", v, 4))))
387
+ return fake_capture.FakeGroup(tracks, most, budgets)
388
+
389
+ def _next_seq(self, fn: int) -> int:
390
+ seq = self.push_seq.get(fn, 0)
391
+ self.push_seq[fn] = (seq + 1) & 0xFFFF
392
+ return seq
393
+
394
+ def _events(self, fn: int, events: list[bytes]) -> None:
395
+ """Events (kind(u8) payload) of `fn` to go out while it is subscribed; unsubscribed, they are not sent."""
396
+ for e in events:
397
+ if fn in self.subscribed:
398
+ self.outbox.append(bytes([m.ROLE_EVENT]) + struct.pack("<HH", fn, self._next_seq(fn)) + e)
399
+
400
+ def pushes(self) -> list[bytes]:
401
+ """The frames the probe sends by itself now (core §11): events, and a streaming capture's data. A serving loop
402
+ frames and sends them; a test takes them from here."""
403
+ self.tick()
404
+ for fn, cap in self.captures.items():
405
+ if fn in self.subscribed:
406
+ self.outbox += cap.pushes(fn, lambda fn=fn: self._next_seq(fn), self.probe.max_frame)
407
+ out, self.outbox = self.outbox, []
408
+ return out
409
+
410
+ def _capture(self, fn: int, op: int, t: Take) -> tuple[int, int, bytes]:
411
+ cap, O = self.captures[fn], fake_capture.OP
412
+ self._events(fn, cap.tick(self.now())) # what the clock captured up to this request
413
+ roles = sorted(a[1] for a in self.plan if a[0] == fn)
414
+ budget = self.probe.max_frame - m.RESULT_HEADER
415
+ try:
416
+ if op in (O["configure"], O["query"]):
417
+ rest = t.data[t.at:]
418
+ analog = self.names[fn] == "oep.fixture.analog"
419
+ known = set(fake_capture.TLV.values()) - (set() if analog else {fake_capture.TLV["frontend"]})
420
+ got, ignored = t.tail(known)
421
+ # the frontend TLV comes once per channel: read them all
422
+ fronts = [tuple(v[:2]) for tag, v in m.split_tlvs(rest) if tag & 0x7F == fake_capture.TLV["frontend"]] \
423
+ if analog else []
424
+ settled = cap.settle(got, t.critical, len(roles), fronts)
425
+ if op == O["configure"]:
426
+ cap.apply(settled)
427
+ cap.slipped = self.capture_slipped
428
+ return self._answer(cap.answer(settled), ignored)
429
+ if op in (O["start"], O["stop"], O["force"]) and cap.group is not None:
430
+ raise Reject(m.UNAVAILABLE) # bound: the group starts and stops it
431
+ if op == O["start"]:
432
+ t.tail()
433
+ self._events(fn, cap.start(self.now()))
434
+ return m.COMPLETED, m.SUCCESS, struct.pack("<I", 0)
435
+ if op == O["stop"]:
436
+ t.tail()
437
+ self._events(fn, cap.stop())
438
+ return m.COMPLETED, m.SUCCESS, b""
439
+ if op == O["force"]:
440
+ t.tail()
441
+ return m.COMPLETED, m.SUCCESS, b"" # nothing waits: a trigger is found at start
442
+ if op == O["status"]:
443
+ t.tail()
444
+ return m.COMPLETED, m.SUCCESS, cap.status()
445
+ if op == O["read"]:
446
+ position, most = t.take("QI")
447
+ return m.COMPLETED, m.SUCCESS, cap.read(position, most, budget)
448
+ if op == O["segments"]:
449
+ first = t.take("I")
450
+ t.tail()
451
+ return m.COMPLETED, m.SUCCESS, cap.segment_list(first, budget)
452
+ if op == O["release"]:
453
+ serial = t.take("I")
454
+ t.tail()
455
+ cap.release(serial, self.now())
456
+ return m.COMPLETED, m.SUCCESS, b""
457
+ if op == fake_capture.ANA.op["calibration"] and cap.analog:
458
+ t.tail()
459
+ return m.COMPLETED, m.SUCCESS, cap.calibration()
460
+ except fake_capture.Reject as e:
461
+ raise Reject(e.reason, e.payload)
462
+ return m.REJECTED, m.UNKNOWN_OPERATION, b""
463
+
464
+ def _group(self, fn: int, op: int, t: Take) -> tuple[int, int, bytes]:
465
+ grp, O = self.groups[fn], fake_capture.GRP.op
466
+ try:
467
+ if op == O["bind"]:
468
+ n = t.take("B")
469
+ fns = [t.take("H") for _ in range(n)]
470
+ got, ignored = t.tail({fake_capture.GRP.tlv["bind"]["trigger_track"]})
471
+ src = got.get(fake_capture.GRP.tlv["bind"]["trigger_track"])
472
+ grp.bind(self.captures, fns, struct.unpack("<H", src)[0] if src else 0)
473
+ return self._answer(b"", ignored)
474
+ if op == O["start"]:
475
+ t.tail()
476
+ per, own = grp.start(self.captures, self.now())
477
+ for track, events in per:
478
+ self._events(track, events)
479
+ self._events(fn, own)
480
+ return m.COMPLETED, m.SUCCESS, struct.pack("<IQ", 0, grp.start_ns)
481
+ if op == O["stop"]:
482
+ t.tail()
483
+ per, own = grp.stop(self.captures)
484
+ for track, events in per:
485
+ self._events(track, events)
486
+ self._events(fn, own)
487
+ return m.COMPLETED, m.SUCCESS, b""
488
+ if op == O["force"]:
489
+ t.tail()
490
+ return m.COMPLETED, m.SUCCESS, b""
491
+ if op == O["status"]:
492
+ t.tail()
493
+ for track in grp.tracks:
494
+ self._events(track, self.captures[track].tick(self.now()))
495
+ return m.COMPLETED, m.SUCCESS, grp.status(self.captures)
496
+ except fake_capture.Reject as e:
497
+ raise Reject(e.reason, e.payload)
498
+ return m.REJECTED, m.UNKNOWN_OPERATION, b""
499
+
343
500
  def _role_pairs(self, fn: int) -> list[tuple[int, int]]:
344
501
  """A role_channels wire's pairs in the count = 0 order (oep-if-debug §1): swdio ascending, then swclk."""
345
502
  roles = self.pin_roles[fn]
@@ -361,7 +518,7 @@ class Endpoint:
361
518
  def _held(self, fn: int | None = None, pair: tuple[int, int] | None = None) -> set[int]:
362
519
  """Channels something holds (core §8.1): the plan, the slots' pairs, the live connections' pairs - except
363
520
  wire `fn`'s own connection on `pair` (attaching there again, or scanning through it)."""
364
- held = {a[2] for a in self.plan}
521
+ held = {a[2] for a in self.plan if a[0] not in self.captures} # a capture only listens
365
522
  held |= {p for s in self.slots.values() for p in s.pair if p != 0xFFFF and not (s.wire_fn == fn and s.pair == pair)}
366
523
  held |= {p for c in self.conns.values() for p in c.pair if p != 0xFFFF and not (c.fn == fn and c.pair == pair)}
367
524
  return held
@@ -613,6 +770,7 @@ class Endpoint:
613
770
  if fn != 0 and fn not in self.names:
614
771
  return m.REJECTED, m.UNAVAILABLE, b""
615
772
  self.subscribed.add(fn)
773
+ self.push_seq[fn] = 0 # seq from 0 at every subscribe (core §11.2)
616
774
  return m.COMPLETED, m.SUCCESS, b""
617
775
  if op == m.OP_UNSUBSCRIBE:
618
776
  fn = t.take("H")
@@ -644,14 +802,24 @@ class Endpoint:
644
802
  def _check_plan(self, got: list[tuple[int, int, int]]) -> None:
645
803
  """plan_apply's all-or-nothing check: the roles each fn has, and no pin another fn (or a slot) holds."""
646
804
  named = {fn for fn, _, _ in got}
647
- kept = {a for a in self.plan if a[0] not in named}
805
+ # a capture only listens: it shares pins with anything, and nothing is kept from a pin by it (core §8.1)
806
+ kept = {a for a in self.plan if a[0] not in named and a[0] not in self.captures}
648
807
  slot_pins = {p for s in self.slots.values() for p in s.pair if p != 0xFFFF}
649
808
  slot_pins |= {p for c in self.conns.values() for p in c.pair if p != 0xFFFF} # a live connection's pins too
650
809
  for fn, role, ch in got:
810
+ if fn in self.captures:
811
+ if role not in self._declared_roles(fn, ch):
812
+ raise Reject(m.UNAVAILABLE)
813
+ continue
651
814
  roles = {"oep.fixture.gpio": {1}, "oep.fixture.uart": {1, 2}}.get(self.names.get(fn, ""), set())
652
815
  if role not in roles or any(k[2] == ch for k in kept) or ch in slot_pins:
653
816
  raise Reject(m.UNAVAILABLE)
654
817
 
818
+ def _declared_roles(self, fn: int, channel: int) -> set[int]:
819
+ """The roles fn's describe offers on `channel` (role_channels)."""
820
+ return {t[2] for t in self.static[fn] if t[0] == catalog.ROLE_CHANNELS
821
+ and channel in catalog.bitmap_to_channels(struct.unpack_from("<H", t, 3)[0], t[5:2 + t[1]])}
822
+
655
823
  def _drop_plan(self, fn: int) -> None:
656
824
  for a in [a for a in self.plan if a[0] == fn]:
657
825
  self.gpio_modes.pop(a[2], None)
@@ -787,8 +955,11 @@ class Endpoint:
787
955
  if not self._allows(fn, pair) or set(pair) & self._held(fn, pair):
788
956
  raise Reject(m.UNAVAILABLE) # not allowed here, or its pins are held (§8.1)
789
957
  return pair
958
+ live = [c.pair for c in self.conns.values() if c.fn == fn]
959
+ if len(live) == 1:
960
+ return live[0] # no pins: the wire's one live connection
790
961
  allowed = self._allowed_pairs(fn)
791
- if len(allowed) != 1:
962
+ if live or len(allowed) != 1:
792
963
  raise Reject(m.UNAVAILABLE) # the host chooses among several
793
964
  return allowed[0]
794
965
 
@@ -1399,9 +1570,11 @@ class Endpoint:
1399
1570
  self.flows[(port, key)] = Flow(sid, 0)
1400
1571
 
1401
1572
  def tick(self) -> None:
1402
- """Time passes: the lease, at-boot retries, mixed lines closed by quiet."""
1573
+ """Time passes: the lease, at-boot retries, mixed lines closed by quiet, the captures."""
1403
1574
  self._lapse()
1404
1575
  now = self.now()
1576
+ for fn, cap in self.captures.items():
1577
+ self._events(fn, cap.tick(now))
1405
1578
  for n, s in self.slots.items():
1406
1579
  rt = self.slot_rt[n]
1407
1580
  if (s.attach == SLOT_ATTACH["at_boot"] and s.retry_s and not rt.evicted
@@ -1549,4 +1722,5 @@ class Endpoint:
1549
1722
 
1550
1723
  SIMS = {"oep.wire.rvswd": "wire", "oep.wire.swio": "wire", "oep.target.riscv-dm": "dm",
1551
1724
  "oep.target.console": "console", "oep.fixture.gpio": "gpio", "oep.fixture.uart": "uart",
1552
- "oep.probe.config": "config_op"}
1725
+ "oep.probe.config": "config_op", "oep.fixture.capture": "capture", "oep.fixture.analog": "capture",
1726
+ "oep.fixture.capture-group": "group"}