oep-client-python 0.0.4__tar.gz → 0.0.6__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 (52) hide show
  1. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/CHANGELOG.md +16 -0
  2. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/PKG-INFO +2 -2
  3. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/README.ja.md +1 -1
  4. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/README.md +1 -1
  5. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/pyproject.toml +1 -1
  6. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/src/oep_client/__init__.py +1 -1
  7. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/src/oep_client/__main__.py +6 -1
  8. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/src/oep_client/config.py +12 -7
  9. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/src/oep_client/core.py +41 -1
  10. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/src/oep_client/endpoint.py +29 -11
  11. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/src/oep_client/fake.py +2 -2
  12. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/src/oep_client/fake_serve.py +2 -2
  13. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/src/oep_client/registry.py +4 -4
  14. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/src/oep_client/riscv.py +31 -12
  15. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/src/oep_client/usb_stream.py +32 -4
  16. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/tests/test_config.py +55 -1
  17. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/tests/test_fake_spec.py +52 -4
  18. oep_client_python-0.0.6/tests/test_usb_stream.py +96 -0
  19. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/.gitignore +0 -0
  20. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/LICENSE +0 -0
  21. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/src/oep_client/arm.py +0 -0
  22. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/src/oep_client/capture.py +0 -0
  23. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/src/oep_client/catalog.py +0 -0
  24. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/src/oep_client/ch32_flash.py +0 -0
  25. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/src/oep_client/cobs.py +0 -0
  26. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/src/oep_client/console.py +0 -0
  27. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/src/oep_client/decode.py +0 -0
  28. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/src/oep_client/dump.py +0 -0
  29. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/src/oep_client/esp32_targets.py +0 -0
  30. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/src/oep_client/fake_serial.py +0 -0
  31. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/src/oep_client/fixture.py +0 -0
  32. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/src/oep_client/frames.py +0 -0
  33. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/src/oep_client/hid_stream.py +0 -0
  34. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/src/oep_client/host.py +0 -0
  35. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/src/oep_client/interfaces.py +0 -0
  36. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/src/oep_client/link.py +0 -0
  37. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/src/oep_client/message.py +0 -0
  38. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/src/oep_client/names.py +0 -0
  39. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/src/oep_client/rp2350.py +0 -0
  40. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/src/oep_client/target.py +0 -0
  41. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/src/oep_client/uiapduino.py +0 -0
  42. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/tests/test_capabilities.py +0 -0
  43. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/tests/test_capture.py +0 -0
  44. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/tests/test_cobs.py +0 -0
  45. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/tests/test_esp32_targets.py +0 -0
  46. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/tests/test_flash_console.py +0 -0
  47. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/tests/test_hid_stream.py +0 -0
  48. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/tests/test_interfaces.py +0 -0
  49. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/tests/test_link_host.py +0 -0
  50. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/tests/test_link_serial.py +0 -0
  51. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/tests/test_session.py +0 -0
  52. {oep_client_python-0.0.4 → oep_client_python-0.0.6}/tests/test_target_parts.py +0 -0
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.0.6
6
+ - (EN) No default reset line (oep-if-debug §3): `Wire.attach_under_reset(channel, ...)` needs its channel, and `Wire.reset_channels()` reads the ones the probe allows (describe role_channels, role reset). The fake takes only those, and not one a plan holds.
7
+ - (JA) 既定の reset 線は無くなった(oep-if-debug §3): `Wire.attach_under_reset(channel, ...)` は channel が必須。probe が許す channel は `Wire.reset_channels()` で読む(describe の role_channels の role reset)。偽の probe はそれだけを受け、plan が持つ channel は断る。
8
+ - (EN) The line's settings are the target's, passed by the host: `attach(..., idle_clock="low")` (rvswd, critical) and the probe.config slot's new max_speed / idle_clock fields (`oep config slot --max-speed --idle-clock`) for the probe's own attach. The slot item's layout changed.
9
+ - (JA) 線の設定は target のもので、host が渡す: `attach(..., idle_clock="low")`(rvswd、critical)と、probe が自分で attach するための probe.config のスロットの max_speed / idle_clock(`oep config slot --max-speed --idle-clock`)。スロットの項目の形が変わった。
10
+ - (EN) The fake probe keeps a plan the settings put in (oep-core §8): plan_release leaves it (n = 0 included) and plan_apply naming its fn is rejected unavailable.
11
+ - (JA) 偽の probe は、設定が入れた plan を設定のものとして扱う(oep-core §8): plan_release はそれを解かず(n = 0 でも)、その fn を挙げた plan_apply は rejected unavailable。
12
+
13
+ ## 0.0.5
14
+ - (EN) USB (python-libusb1) streams close cleanly and at exit: the IN transfers are cancelled and taken back by the event thread before the handle and the context go. A program that ended without closing the link hung at exit, or libusb aborted.
15
+ - (JA) USB(python-libusb1)の stream は、終了時にも片付いて閉じる: IN の転送を取り消し、event thread が取り戻してから handle と context を閉じる。link を閉じずに終わったプログラムが終了で固まったり、libusb が abort したりしていた。
16
+ - (EN) `core.plan_apply` refused as unavailable names what holds the pins when the settings say so (`core.PinsTaken`: another fn's saved plan, a slot).
17
+ - (JA) `core.plan_apply` が unavailable で断られたとき、設定から分かれば何がピンを持っているかを示す(`core.PinsTaken`: 別の fn の保存した plan、スロット)。
18
+ - (EN) Fake probe: a read from the last mark of a kind that is not there starts now, not at the oldest byte (oep-if-common §1.2).
19
+ - (JA) 偽の probe: その kind のマークが無いときの「最後のマークから」の read は、一番古い位置ではなく今から(oep-if-common §1.2)。
20
+
5
21
  ## 0.0.4
6
22
  - (EN) Fake serial port: a candidate that is only its 0x00 is not raw after the 200 ms gap (the probe firmware had the same bug: a stray 0x00 reached the target's console).
7
23
  - (JA) 偽のシリアルの口: 0x00 だけの候補は、200 ms の後も生のバイトにしない(probe の firmware にも同じバグがあり、target のコンソールに 0x00 が届いていた)。
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: oep-client-python
3
- Version: 0.0.4
3
+ Version: 0.0.6
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
@@ -43,7 +43,7 @@ uv run pytest # in a checkout
43
43
  `oep-client` is another project, so the distribution is named `oep-client-python`.
44
44
 
45
45
  Releases: run the GitHub Actions workflow Release (workflow_dispatch, version X.Y.Z or X.Y.ZbN). `tools/prepare_release.py`
46
- sets the version in pyproject.toml and `oep_client.__version__` and turns CHANGELOG.md's Unreleased into that version; after
46
+ sets the version in pyproject.toml, uv.lock and `oep_client.__version__` and turns CHANGELOG.md's Unreleased into that version; after
47
47
  the tests and the build it commits, tags, makes the GitHub Release and publishes to PyPI (Trusted Publishing). Record changes
48
48
  under Unreleased in CHANGELOG.md, (EN) and (JA).
49
49
 
@@ -18,7 +18,7 @@ uv run pytest # in a
18
18
  oep-spec から写す。PyPI の `oep-client` は別のプロジェクトなので、配布名は `oep-client-python`。
19
19
 
20
20
  リリースは GitHub Actions の Release(workflow_dispatch、version = X.Y.Z か X.Y.ZbN): `tools/prepare_release.py` が
21
- pyproject.toml と `oep_client.__version__` を書き換え、CHANGELOG.md の Unreleased をその版にし、試験と build の後に commit と tag、
21
+ pyproject.toml、uv.lock、`oep_client.__version__` を書き換え、CHANGELOG.md の Unreleased をその版にし、試験と build の後に commit と tag、
22
22
  GitHub Release、PyPI(Trusted Publishing)へ出す。変更は CHANGELOG.md の Unreleased に (EN) / (JA) で書き足しておく。
23
23
 
24
24
  ## モジュール(`oep_client`)
@@ -21,7 +21,7 @@ uv run pytest # in a checkout
21
21
  `oep-client` is another project, so the distribution is named `oep-client-python`.
22
22
 
23
23
  Releases: run the GitHub Actions workflow Release (workflow_dispatch, version X.Y.Z or X.Y.ZbN). `tools/prepare_release.py`
24
- sets the version in pyproject.toml and `oep_client.__version__` and turns CHANGELOG.md's Unreleased into that version; after
24
+ sets the version in pyproject.toml, uv.lock and `oep_client.__version__` and turns CHANGELOG.md's Unreleased into that version; after
25
25
  the tests and the build it commits, tags, makes the GitHub Release and publishes to PyPI (Trusted Publishing). Record changes
26
26
  under Unreleased in CHANGELOG.md, (EN) and (JA).
27
27
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "oep-client-python"
7
- version = "0.0.4"
7
+ version = "0.0.6"
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.4"
5
+ __version__ = "0.0.6"
@@ -64,6 +64,10 @@ def _config_parser(sub) -> None:
64
64
  slot.add_argument("--attach", choices=sorted(config.ATTACH), default="host")
65
65
  slot.add_argument("--retry", type=int, default=0, help="at-boot: try again every N s while absent (0: never)")
66
66
  slot.add_argument("--mechanism", choices=sorted(config.MECHANISM), default="dmseq")
67
+ slot.add_argument("--max-speed", type=int, default=0,
68
+ help="the line's ceiling in Hz for the probe's own attach (0: none); the target's, e.g. 1000000")
69
+ slot.add_argument("--idle-clock", choices=sorted(config.IDLE_CLOCK), default="high",
70
+ help="rvswd: SWCLK while the line rests (the target's: low on CH32L103 / V203)")
67
71
  slot.add_argument("--lock", help="MASK:VALUE (hex u32) the target_id (WCH DMI 0x7F) must match, e.g. ffffff0f:035e0600")
68
72
  slot.add_argument("--save", action="store_true", help="save after the change")
69
73
  bind = cs.add_parser("bind", help="what a serial port carries")
@@ -183,7 +187,7 @@ def _config(args) -> int:
183
187
  mask, _, value = args.lock.partition(":")
184
188
  lock = (1, struct.pack("<I", int(mask, 16)), struct.pack("<I", int(value, 16)))
185
189
  it = config.Slot(args.slot, fn, _pins(hst, fn, args.pins, args.wire), args.name, args.attach, args.retry,
186
- args.mechanism, lock)
190
+ args.mechanism, lock, args.max_speed, args.idle_clock)
187
191
  _change(hst, cfg, [it], args.save)
188
192
  elif args.action == "bind":
189
193
  slots = {it.name: it.slot for it in cfg.items() if isinstance(it, config.Slot)}
@@ -250,6 +254,7 @@ def _show(hst, cfg, as_json: bool) -> int:
250
254
  s = by_slot.get(it.slot)
251
255
  pins = f"{it.pins[0]}" if it.pins[1] == 0xFFFF else f"{it.pins[0]},{it.pins[1]}"
252
256
  retry = f" retry {it.retry_s} s" if it.attach == "at-boot" else ""
257
+ retry += (f" max {it.max_speed} Hz" if it.max_speed else "") + (" idle-low" if it.idle_clock == "low" else "")
253
258
  lock = (f" lock {int.from_bytes(it.lock[1], 'little'):08x}:{int.from_bytes(it.lock[2], 'little'):08x}"
254
259
  if it.lock else "")
255
260
  live = ""
@@ -29,6 +29,7 @@ MECHANISM = {k: v for k, v in reg.TARGET_CONSOLE.enum["mechanism"].items()}
29
29
  SLOT_STATE = {v: k.replace("_", "-") for k, v in _CFG.enum["slot_state"].items()}
30
30
  BIND_FLOW = {v: k for k, v in _CFG.enum["bind_flow"].items()}
31
31
  IDLE = {k.replace("_", "-"): v for k, v in _CFG.enum["idle_mode"].items()}
32
+ IDLE_CLOCK = dict(reg.WIRE_RVSWD.enum["idle_clock"]) # a slot's idle_clock (oep-if-debug §3)
32
33
  STORAGE_STATE = {v: k for k, v in _CFG.enum["storage_state"].items()}
33
34
 
34
35
 
@@ -79,12 +80,15 @@ class Slot:
79
80
  retry_s: int = 0 # at-boot: try again every retry_s while the target is not there (0: never)
80
81
  mechanism: str = "dmseq" # sdi, dmdata, dmseq
81
82
  lock: tuple[int, bytes, bytes] | None = None
83
+ max_speed: int = 0 # the line's ceiling in Hz for the probe's own attach (0: none) - the target's
84
+ idle_clock: str = "high" # rvswd: SWCLK while the line rests, high / low - the target's (oep-if-debug §3)
82
85
  TAG = ITEM["slot"]
83
86
 
84
87
  def value(self) -> bytes:
85
88
  name = self.name.encode()
86
- v = struct.pack("<BHHHBHBB", self.slot, self.wire_fn, *self.pins, ATTACH[self.attach],
87
- self.retry_s if self.attach == "at-boot" else 0, MECHANISM[self.mechanism], len(name)) + name
89
+ v = struct.pack("<BHHHBHIBBB", self.slot, self.wire_fn, *self.pins, ATTACH[self.attach],
90
+ self.retry_s if self.attach == "at-boot" else 0, self.max_speed, IDLE_CLOCK[self.idle_clock],
91
+ MECHANISM[self.mechanism], len(name)) + name
88
92
  if self.lock is None:
89
93
  return v + b"\x00"
90
94
  scheme, mask, value = self.lock
@@ -127,15 +131,16 @@ def decode(tag: int, v: bytes):
127
131
  return Label(struct.unpack_from("<H", v)[0], v[2:].decode("utf-8", "replace"))
128
132
  if tag == ITEM["idle"] and len(v) == 3:
129
133
  return Idle(struct.unpack_from("<H", v)[0], _name(IDLE, v[2]))
130
- if tag == ITEM["slot"] and len(v) >= 13:
131
- n, wire_fn, swdio, swclk, attach, retry_s, mech, name_len = struct.unpack_from("<BHHHBHBB", v)
132
- name = v[12:12 + name_len].decode("ascii", "replace")
133
- rest = v[12 + name_len:]
134
+ if tag == ITEM["slot"] and len(v) >= 18:
135
+ n, wire_fn, swdio, swclk, attach, retry_s, max_speed, idle, mech, name_len = struct.unpack_from("<BHHHBHIBBB", v)
136
+ name = v[17:17 + name_len].decode("ascii", "replace")
137
+ rest = v[17 + name_len:]
134
138
  lock = None
135
139
  if rest and rest[0]:
136
140
  half = (len(rest) - 1) // 2
137
141
  lock = (rest[0], rest[1:1 + half], rest[1 + half:])
138
- return Slot(n, wire_fn, (swdio, swclk), name, _name(ATTACH, attach), retry_s, _name(MECHANISM, mech), lock)
142
+ return Slot(n, wire_fn, (swdio, swclk), name, _name(ATTACH, attach), retry_s, _name(MECHANISM, mech), lock,
143
+ max_speed, _name(IDLE_CLOCK, idle))
139
144
  if tag == ITEM["bind"] and len(v) >= 4:
140
145
  port, mode, selected, n = struct.unpack_from("<BBBB", v)
141
146
  streams = [(_name(STREAM, v[4 + 3 * k]), struct.unpack_from("<H", v, 5 + 3 * k)[0]) for k in range(n)]
@@ -109,7 +109,47 @@ def plan_apply(hst: h.Host, assignments: list[tuple[int, int, int]]) -> None:
109
109
  all interfaces accept their roles or nothing changes. The plan is the
110
110
  session's resource: kept over an explicit end, released at a lease lapse or a force takeover (oep-core §9)."""
111
111
  tlv = b"".join(bytes([TAG_ROLE_ASSIGNMENT, 5]) + struct.pack("<HBH", fn, role, ch) for fn, role, ch in assignments)
112
- hst.call(m.CORE_FN, OP_PLAN_APPLY, tlv)
112
+ try:
113
+ hst.call(m.CORE_FN, OP_PLAN_APPLY, tlv)
114
+ except h.Rejected as e:
115
+ if e.result.detail != m.UNAVAILABLE:
116
+ raise
117
+ why = _pin_holders(hst, assignments)
118
+ if why:
119
+ raise PinsTaken(e.result, why) from None
120
+ raise
121
+
122
+
123
+ class PinsTaken(h.Rejected):
124
+ """plan_apply refused (unavailable) and the probe's settings say why: a pin kept by another fn's saved plan or by a
125
+ slot (oep.probe.config). `holders`: (channel, what holds it)."""
126
+
127
+ def __init__(self, result, holders: list[tuple[int, str]]):
128
+ super().__init__(result)
129
+ self.holders = holders
130
+
131
+ def __str__(self) -> str:
132
+ return "pins taken: " + "; ".join(f"channel {ch} by {who}" for ch, who in self.holders) + \
133
+ " (oep config show; oep config remove <probe> plan <fn> frees a saved plan)"
134
+
135
+
136
+ def _pin_holders(hst: h.Host, assignments: list[tuple[int, int, int]]) -> list[tuple[int, str]]:
137
+ """What the settings keep on the channels asked for (best effort: an empty list when there is no oep.probe.config
138
+ or it cannot be read)."""
139
+ from . import config
140
+ try:
141
+ items = config.ProbeConfig(hst).items()
142
+ except (LookupError, h.OepError):
143
+ return []
144
+ fns = {fn for fn, _, _ in assignments}
145
+ out = []
146
+ for fn, role, ch in assignments:
147
+ for it in items:
148
+ if isinstance(it, config.Plan) and it.channel == ch and it.fn not in fns:
149
+ out.append((ch, f"the saved plan of fn {it.fn} (role {it.role})"))
150
+ elif isinstance(it, config.Slot) and ch in it.pins:
151
+ out.append((ch, f"slot {it.slot} {it.name}"))
152
+ return out
113
153
 
114
154
 
115
155
  def plan_release(hst: h.Host, fns: list[int] | tuple[int, ...] = ()) -> None:
@@ -60,6 +60,7 @@ WIRES = ("oep.wire.rvswd", "oep.wire.swio")
60
60
  OWNER = reg.CORE.tlv["open"]["owner"]
61
61
  SLOT_NAME = re.compile(r"[a-z0-9_-]{1,32}")
62
62
  NO_SLOT, NEVER = 0xFF, 0xFFFFFFFF
63
+ PIN_ROLE_RESET = reg.WIRE_RVSWD.enum["pin_role"]["reset"] # the channels attach_under_reset may take
63
64
 
64
65
 
65
66
  class Reject(Exception):
@@ -180,6 +181,7 @@ class Connection:
180
181
  speed: int = 4_000_000
181
182
  tid: int | None = None
182
183
  users: set = field(default_factory=set) # "host" and/or ("slot", n)
184
+ idle_clock: int = 0 # rvswd: SWCLK while the line rests, 0 high / 1 low
183
185
 
184
186
 
185
187
  @dataclass(frozen=True)
@@ -189,6 +191,8 @@ class Slot:
189
191
  pair: tuple[int, int]
190
192
  attach: int
191
193
  retry_s: int
194
+ max_speed: int # 0: no ceiling (oep-if-debug §3: the target's, the host's to set)
195
+ idle_clock: int
192
196
  mechanism: int
193
197
  name: str
194
198
  lock: tuple[int, bytes, bytes] | None # (scheme, mask, value)
@@ -249,9 +253,13 @@ class Endpoint:
249
253
  self.serial_ports = {i for i, k in enumerate(self.transports) if k in fake.SERIAL_KINDS}
250
254
  self.pairs: dict[int, list[tuple[int, int]]] = {} # wire fn -> allowed (swdio, swclk), declared order
251
255
  self.max_connections: dict[int, int] = {}
256
+ self.reset_channels: dict[int, set[int]] = {} # wire fn -> channels attach_under_reset may take (role 3)
252
257
  for fn, name in self.names.items():
253
258
  if name in WIRES:
254
259
  self.pairs[fn] = [self._group_pair(name, t) for t in self.static[fn] if t[0] == catalog.CHANNEL_GROUP]
260
+ self.reset_channels[fn] = {
261
+ c for t in self.static[fn] if t[0] == catalog.ROLE_CHANNELS and t[2] == PIN_ROLE_RESET
262
+ for c in catalog.bitmap_to_channels(struct.unpack_from("<H", t, 3)[0], t[5:2 + t[1]])}
255
263
  self.max_connections[fn] = next((t[2] for t in self.static[fn] if t[0] == fake.MAX_CONNECTIONS), 1)
256
264
  self.targets: dict[tuple[int, tuple[int, int]], FakeTarget] = {
257
265
  (fn, p): FakeTarget() for fn in sorted(self.pairs) for p in self.pairs[fn]}
@@ -575,17 +583,18 @@ class Endpoint:
575
583
  got.append(struct.unpack_from("<HBH", value))
576
584
  elif tag & m.TAG_CRITICAL:
577
585
  return m.REJECTED, m.UNSUPPORTED, bytes([tag])
578
- self._check_plan(got)
579
586
  named = {fn for fn, _, _ in got}
587
+ if named & self.plan_from_config: # the settings' plan is the settings' (core §8)
588
+ raise Reject(m.UNAVAILABLE)
589
+ self._check_plan(got)
580
590
  self.plan = {a for a in self.plan if a[0] not in named} | set(got)
581
- self.plan_from_config -= named
582
591
  return m.COMPLETED, m.SUCCESS, b""
583
592
  if op == m.OP_PLAN_RELEASE: # n(u8) n x fn(u16); n = 0: every fn
584
593
  n = t.take("B")
585
594
  fns = {t.take("H") for _ in range(n)}
586
595
  t.tail()
587
- for fn in {a[0] for a in self.plan if not fns or a[0] in fns}:
588
- self._drop_plan(fn)
596
+ for fn in {a[0] for a in self.plan if not fns or a[0] in fns} - self.plan_from_config:
597
+ self._drop_plan(fn) # the settings' plans stay, n = 0 too (core §8)
589
598
  return m.COMPLETED, m.SUCCESS, b""
590
599
  return m.REJECTED, m.UNKNOWN_OPERATION, b""
591
600
 
@@ -657,10 +666,14 @@ class Endpoint:
657
666
  if method > 1:
658
667
  raise Reject(m.UNSUPPORTED)
659
668
  else:
660
- channel, _hold = t.take("HH")
661
- if channel not in (0xFFFF, self._channel_named("NRST")):
669
+ channel, _hold = t.take("HH") # no default reset line (oep-if-debug §3)
670
+ if channel not in self.reset_channels.get(fn, set()) or any(a[2] == channel for a in self.plan):
662
671
  raise Reject(m.UNAVAILABLE)
663
- got, ignored = t.tail({0x01, 0x03})
672
+ rvswd = self.names[fn] == "oep.wire.rvswd"
673
+ got, ignored = t.tail({0x01, 0x03, 0x04} if rvswd else {0x01, 0x03})
674
+ if 0x04 in got and (len(got[0x04]) != 1 or got[0x04][0] > 1):
675
+ raise Reject(m.MALFORMED)
676
+ idle_clock = got[0x04][0] if 0x04 in got else 0
664
677
  pair = self._pick_pair(fn, got)
665
678
  tg = self.targets[(fn, pair)]
666
679
  speed = min(4_000_000, struct.unpack("<I", got[0x01])[0]) if 0x01 in got else 4_000_000
@@ -676,6 +689,7 @@ class Endpoint:
676
689
  flags |= 2
677
690
  self.conns[cid].speed = min(self.conns[cid].speed, speed)
678
691
  c = self.conns[cid]
692
+ c.idle_clock = idle_clock # an existing connection takes the new rest level
679
693
  c.users.add("host")
680
694
  for n, s in self.slots.items(): # a new connection for an evicted slot: a new cue
681
695
  if s.wire_fn == fn and s.pair == pair:
@@ -923,7 +937,7 @@ class Endpoint:
923
937
  pos = s.end
924
938
  elif frm == 3:
925
939
  hits = [mk for mk in s.marks if arg == 0 or mk[2] == arg]
926
- pos = hits[-1][1] if hits else s.base
940
+ pos = hits[-1][1] if hits else s.end # no such mark: from now (common §1.2)
927
941
  else:
928
942
  raise Reject(m.UNSUPPORTED)
929
943
  flags = 0
@@ -1218,7 +1232,7 @@ class Endpoint:
1218
1232
 
1219
1233
  def _parse_slot(self, v: bytes) -> Slot:
1220
1234
  t = Take(v)
1221
- n, wire_fn, swdio, swclk, attach, retry_s, mech, name_len = t.take("BHHHBHBB")
1235
+ n, wire_fn, swdio, swclk, attach, retry_s, max_speed, idle_clock, mech, name_len = t.take("BHHHBHIBBB")
1222
1236
  name = t.bytes(name_len)
1223
1237
  scheme = t.take("B")
1224
1238
  rest = v[t.at:]
@@ -1226,6 +1240,8 @@ class Endpoint:
1226
1240
  raise Reject(m.MALFORMED)
1227
1241
  if retry_s and attach != SLOT_ATTACH["at_boot"]:
1228
1242
  raise Reject(m.MALFORMED)
1243
+ if idle_clock > 1 or (idle_clock and self.names.get(wire_fn) != "oep.wire.rvswd"):
1244
+ raise Reject(m.MALFORMED)
1229
1245
  if not SLOT_NAME.fullmatch(name.decode("ascii", "replace")):
1230
1246
  raise Reject(m.MALFORMED)
1231
1247
  if self.names.get(wire_fn) not in WIRES or (swdio, swclk) not in self.pairs.get(wire_fn, []):
@@ -1239,7 +1255,7 @@ class Endpoint:
1239
1255
  lock = (scheme, rest[:len(rest) // 2], rest[len(rest) // 2:])
1240
1256
  elif rest:
1241
1257
  raise Reject(m.MALFORMED)
1242
- return Slot(n, wire_fn, (swdio, swclk), attach, retry_s, mech, name.decode(), lock)
1258
+ return Slot(n, wire_fn, (swdio, swclk), attach, retry_s, max_speed, idle_clock, mech, name.decode(), lock)
1243
1259
 
1244
1260
  def _parse_bind(self, v: bytes, slots: dict[int, Slot]) -> Bind:
1245
1261
  t = Take(v)
@@ -1291,7 +1307,9 @@ class Endpoint:
1291
1307
  if not tg.present:
1292
1308
  return
1293
1309
  try:
1294
- cid = self._seat(s.wire_fn, s.pair, tg, 4_000_000, evict=False) # automatic: never evicts
1310
+ speed = min(4_000_000, s.max_speed) if s.max_speed else 4_000_000 # the slot's line settings
1311
+ cid = self._seat(s.wire_fn, s.pair, tg, speed, evict=False) # automatic: never evicts
1312
+ self.conns[cid].idle_clock = s.idle_clock
1295
1313
  except Reject:
1296
1314
  return
1297
1315
  tg.havereset = False
@@ -185,8 +185,8 @@ def esp32_v003() -> FakeProbe:
185
185
  _core("3.0.0", "esp32-d0wd", bytes.fromhex("0070070d9394"), 40, reserved, f"{NS}.esp32-v003",
186
186
  {16: "SWIO", 23: "NRST", 22: "DUT TX", 21: "DUT RX"},
187
187
  _transports([(TRANSPORT["uart_bridge"], 0xFF)])),
188
- Offered(1, 1, "oep.wire.swio", (catalog.channel_group(1, [(1, 16)]), catalog.u8(IMPLEMENTATION, 1),
189
- catalog.u8(MAX_CONNECTIONS, 1))),
188
+ Offered(1, 1, "oep.wire.swio", (catalog.channel_group(1, [(1, 16)]), catalog.role_channels(3, [23]),
189
+ catalog.u8(IMPLEMENTATION, 1), catalog.u8(MAX_CONNECTIONS, 1))),
190
190
  Offered(2, 1, "oep.target.riscv-dm", (catalog.u32(FEATURES, 0b0111), catalog.u8(IMPLEMENTATION, 1),
191
191
  catalog.u16(MAX_LENGTH, 40))),
192
192
  Offered(3, 1, "oep.target.console", (catalog.tlv(MECHANISMS, bytes([0, 1, 2])),)),
@@ -77,8 +77,8 @@ def build(a: argparse.Namespace) -> endpoint.Endpoint:
77
77
  swdio, swclk = ep.pairs[wire][n]
78
78
  mech = 2 if 2 in ep.mechanisms else min(ep.mechanisms)
79
79
  raw = name.encode()
80
- value = struct.pack("<BHHHBHBB", n, wire, swdio, swclk, reg.PROBE_CONFIG.enum["slot_attach"]["at_boot"], 1,
81
- mech, len(raw)) + raw + b"\x00" # no lock
80
+ value = struct.pack("<BHHHBHIBBB", n, wire, swdio, swclk, reg.PROBE_CONFIG.enum["slot_attach"]["at_boot"], 1,
81
+ 0, 0, mech, len(raw)) + raw + b"\x00" # no speed ceiling, rests high, no lock
82
82
  items.append(bytes([_ITEM["slot"], len(value)]) + value)
83
83
  if a.bind:
84
84
  streams = b"".join(struct.pack("<BH", reg.PROBE_CONFIG.enum["bind_stream"]["slot_console"], n)
@@ -2,7 +2,7 @@
2
2
 
3
3
  from types import SimpleNamespace as _NS
4
4
 
5
- REGISTRY_HASH = "7ba704094c2898d2"
5
+ REGISTRY_HASH = "049bd156b4775990"
6
6
  SCHEMA = 1
7
7
  PROTOCOL_REVISION = 1
8
8
  CONFIRM_REQUEST_MAGIC = 'OEP?'
@@ -22,13 +22,13 @@ TIMING = {"resync_quiet_ms": 0x32, "probe_frame_gap_ms": 0xC8, "host_frame_pause
22
22
 
23
23
  INTERFACES = {}
24
24
  CORE = _NS(name="oep.core", revision=1, op={"confirm": 0x01, "list": 0x02, "describe": 0x03, "plan_apply": 0x04, "plan_release": 0x05, "open": 0x10, "end": 0x11, "keepalive": 0x12, "lock_state": 0x13, "subscribe": 0x30, "unsubscribe": 0x32, "link_source": 0x40, "link_sink": 0x41}, lock_free={0x01, 0x02, 0x03, 0x10, 0x13, 0x40, 0x41},
25
- closed_tail={0x40}, tlv={"plan_apply": {"role_assignment": 0x90}, "open": {"owner": 0x01}, "lock_state_answer": {"owner": 0x01}, "locked_payload": {"owner": 0x01}, "describe": {"firmware": 0x40, "model": 0x41, "unit_id": 0x42, "channels": 0x43, "reserved": 0x44, "profile": 0x45, "label": 0x46, "resets_on_open": 0x47, "transport": 0x49, "oep_pid": 0x4A}}, event={"heartbeat": 0x01}, enum={"transport_kind": {"uart_bridge": 0x01, "usb_cdc": 0x02, "usb_serial_jtag": 0x03, "vendor_bulk": 0x04, "hid": 0x05, "tcp": 0x06}}, own={})
25
+ closed_tail={0x40}, tlv={"plan_apply": {"role_assignment": 0x90}, "open": {"owner": 0x01}, "lock_state_answer": {"owner": 0x01}, "locked_payload": {"owner": 0x01}, "describe": {"firmware": 0x40, "model": 0x41, "unit_id": 0x42, "channels": 0x43, "reserved": 0x44, "profile": 0x45, "label": 0x46, "resets_on_open": 0x47, "transport": 0x49, "oep_pid": 0x4A, "plan_roles": 0x4B}}, event={"heartbeat": 0x01}, enum={"transport_kind": {"uart_bridge": 0x01, "usb_cdc": 0x02, "usb_serial_jtag": 0x03, "vendor_bulk": 0x04, "hid": 0x05, "tcp": 0x06}}, own={})
26
26
  INTERFACES["oep.core"] = CORE
27
27
  WIRE_RVSWD = _NS(name="oep.wire.rvswd", revision=1, op={"scan": 0x01, "attach": 0x02, "detach": 0x03, "attach_under_reset": 0x04, "connections": 0x05}, lock_free={0x05},
28
- closed_tail={}, tlv={"describe": {"max_connections": 0x40}, "detach": {"force": 0x01}, "attach": {"max_speed": 0x01, "pins": 0x03}, "attach_under_reset": {"max_speed": 0x01, "pins": 0x03}, "attach_answer": {"target_id": 0x10}}, event={}, enum={"connection_users": {"host_session": 0x01, "slot": 0x02}, "target_id_scheme": {"wch_dmi_7f": 0x01}, "attach_method": {"run": 0x00, "halt": 0x01}, "pin_role": {"swdio": 0x01, "swclk": 0x02}}, own={})
28
+ closed_tail={}, tlv={"describe": {"max_connections": 0x40}, "detach": {"force": 0x01}, "attach": {"max_speed": 0x01, "pins": 0x03, "idle_clock": 0x04}, "attach_under_reset": {"max_speed": 0x01, "pins": 0x03, "idle_clock": 0x04}, "attach_answer": {"target_id": 0x10}}, event={}, enum={"connection_users": {"host_session": 0x01, "slot": 0x02}, "target_id_scheme": {"wch_dmi_7f": 0x01}, "attach_method": {"run": 0x00, "halt": 0x01}, "pin_role": {"swdio": 0x01, "swclk": 0x02, "reset": 0x03}, "idle_clock": {"high": 0x00, "low": 0x01}}, own={})
29
29
  INTERFACES["oep.wire.rvswd"] = WIRE_RVSWD
30
30
  WIRE_SWIO = _NS(name="oep.wire.swio", revision=1, op={"scan": 0x01, "attach": 0x02, "detach": 0x03, "attach_under_reset": 0x04, "connections": 0x05}, lock_free={0x05},
31
- closed_tail={}, tlv={"describe": {"max_connections": 0x40}, "detach": {"force": 0x01}, "attach": {"max_speed": 0x01, "pins": 0x03}, "attach_under_reset": {"max_speed": 0x01, "pins": 0x03}, "attach_answer": {"target_id": 0x10}}, event={}, enum={"connection_users": {"host_session": 0x01, "slot": 0x02}, "target_id_scheme": {"wch_dmi_7f": 0x01}, "attach_method": {"run": 0x00, "halt": 0x01}, "pin_role": {"swdio": 0x01}}, own={})
31
+ closed_tail={}, tlv={"describe": {"max_connections": 0x40}, "detach": {"force": 0x01}, "attach": {"max_speed": 0x01, "pins": 0x03}, "attach_under_reset": {"max_speed": 0x01, "pins": 0x03}, "attach_answer": {"target_id": 0x10}}, event={}, enum={"connection_users": {"host_session": 0x01, "slot": 0x02}, "target_id_scheme": {"wch_dmi_7f": 0x01}, "attach_method": {"run": 0x00, "halt": 0x01}, "pin_role": {"swdio": 0x01, "reset": 0x03}}, own={})
32
32
  INTERFACES["oep.wire.swio"] = WIRE_SWIO
33
33
  WIRE_SWD = _NS(name="oep.wire.swd", revision=1, op={"scan": 0x01, "attach": 0x02, "detach": 0x03, "connections": 0x05}, lock_free={0x05},
34
34
  closed_tail={}, tlv={"describe": {"max_connections": 0x40}, "detach": {"force": 0x01}, "attach": {"max_speed": 0x01, "targetsel": 0x02, "pins": 0x03}}, event={}, enum={"connection_users": {"host_session": 0x01, "slot": 0x02}, "pin_role": {"swdio": 0x01, "swclk": 0x02}}, own={})
@@ -14,8 +14,8 @@ import struct
14
14
  import time
15
15
  from dataclasses import dataclass, field
16
16
 
17
- from . import host as h, message as m, registry as reg
18
- from .core import Interface
17
+ from . import catalog, host as h, message as m, registry as reg
18
+ from .core import Interface, describe
19
19
  from .fixture import Gpio
20
20
 
21
21
  STATUS = reg.STATUS
@@ -109,7 +109,9 @@ class WireBase(Interface):
109
109
  class Wire(WireBase):
110
110
  """oep.wire.rvswd / oep.wire.swio (CH32 debug links to a RISC-V debug module)."""
111
111
  NAME = "oep.wire.rvswd"
112
- DEFAULT_RESET = 0xFFFF
112
+ ROLE_RESET = reg.WIRE_RVSWD.enum["pin_role"]["reset"]
113
+ TAG_IDLE_CLOCK = reg.WIRE_RVSWD.tlv["attach"]["idle_clock"]
114
+ IDLE_CLOCK = reg.WIRE_RVSWD.enum["idle_clock"]
113
115
  RUN, HALT = reg.WIRE_RVSWD.enum["attach_method"]["run"], reg.WIRE_RVSWD.enum["attach_method"]["halt"]
114
116
  TAG_TARGET_ID = reg.WIRE_RVSWD.tlv["attach_answer"]["target_id"]
115
117
  SCHEME_WCH_DMI_7F = reg.WIRE_RVSWD.enum["target_id_scheme"]["wch_dmi_7f"]
@@ -125,12 +127,28 @@ class Wire(WireBase):
125
127
  v = tail.get(self.TAG_TARGET_ID)
126
128
  self.target_id = (v[0], bytes(v[1:])) if v else None
127
129
 
128
- def attach(self, halt: bool = True, max_speed: int | None = None, pins: tuple[int, int] | None = None) -> tuple[int, int]:
130
+ def _idle_tlv(self, idle_clock: str | None) -> bytes:
131
+ # rvswd only, critical: a probe that cannot rest the line that way must refuse (oep-if-debug §3)
132
+ return b"" if idle_clock is None else m.tlv(self.TAG_IDLE_CLOCK, bytes([self.IDLE_CLOCK[idle_clock]]), critical=True)
133
+
134
+ def reset_channels(self) -> list[int]:
135
+ """The channels attach_under_reset may take (describe role_channels, role reset). There is no default reset
136
+ line: the host names one every time (oep-if-debug §3)."""
137
+ out = set()
138
+ for tag, v in describe(self.host, self.fn):
139
+ if tag & ~m.TAG_CRITICAL == catalog.ROLE_CHANNELS and v[0] == self.ROLE_RESET:
140
+ out.update(catalog.bitmap_to_channels(struct.unpack_from("<H", v, 1)[0], v[3:]))
141
+ return sorted(out)
142
+
143
+ def attach(self, halt: bool = True, max_speed: int | None = None, pins: tuple[int, int] | None = None,
144
+ idle_clock: str | None = None) -> tuple[int, int]:
129
145
  """-> (connection, DMSTATUS). Attaching an attached wire returns its connection as it is (self.existing).
130
146
  self.had_reset: a pending havereset was acknowledged first (a V00x's DMSTATUS halt / run bits stay frozen
131
147
  until then); self.speed_hz: the speed the probe chose; max_speed: a ceiling the probe must keep (critical);
132
- self.target_id: (scheme, value) of the target's identity when the probe could read one (oep-if-debug §1)."""
133
- body = bytes([self.HALT if halt else self.RUN]) + self._speed_tlv(max_speed) + self._pins_tlv(pins)
148
+ idle_clock: "high" / "low", how rvswd rests SWCLK (critical). Both are the target's, known by the host
149
+ (oep-if-debug §3). self.target_id: (scheme, value) of the target's identity when the probe could read one."""
150
+ body = (bytes([self.HALT if halt else self.RUN]) + self._speed_tlv(max_speed) + self._pins_tlv(pins)
151
+ + self._idle_tlv(idle_clock))
134
152
  rd = m.Reader(self._call(self.ATTACH, body).payload)
135
153
  conn, status, flags, self.speed_hz = rd.take("HIBI")
136
154
  self.had_reset, self.existing = bool(flags & 1), bool(flags & 2)
@@ -139,12 +157,13 @@ class Wire(WireBase):
139
157
  self._take_target_id(tail)
140
158
  return conn, status
141
159
 
142
- def attach_under_reset(self, channel: int | None = None, hold_ms: int = 20,
143
- max_speed: int | None = None, pins: tuple[int, int] | None = None) -> tuple[int, int]:
144
- """Hold the target in reset through `channel` (None: the probe's default reset line), attach, release and
145
- halt it at once - the way back from firmware that turns the debug pins into GPIOs. -> (connection, dpc)"""
146
- body = (struct.pack("<HH", self.DEFAULT_RESET if channel is None else channel, hold_ms) + self._speed_tlv(max_speed)
147
- + self._pins_tlv(pins))
160
+ def attach_under_reset(self, channel: int, hold_ms: int = 20, max_speed: int | None = None,
161
+ pins: tuple[int, int] | None = None, idle_clock: str | None = None) -> tuple[int, int]:
162
+ """Hold the target in reset through `channel` (always named: there is no default reset line; the probe
163
+ allows reset_channels()), attach, release and halt it at once - the way back from firmware that turns the
164
+ debug pins into GPIOs. -> (connection, dpc)"""
165
+ body = (struct.pack("<HH", channel, hold_ms) + self._speed_tlv(max_speed) + self._pins_tlv(pins)
166
+ + self._idle_tlv(idle_clock))
148
167
  rd = m.Reader(self._call(self.ATTACH_UNDER_RESET, body).payload)
149
168
  conn, dpc, self.speed_hz = rd.take("HII")
150
169
  self._take_target_id(rd.tail())
@@ -10,6 +10,7 @@ transfer; if its timeout hits first, pyusb raises and the bytes already received
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
+ import atexit
13
14
  import threading
14
15
  import time
15
16
 
@@ -114,15 +115,21 @@ class UsbAsyncStream:
114
115
  self.timeout = 0.05
115
116
  self._buffer = bytearray()
116
117
  self._cond = threading.Condition()
117
- self._closed = False
118
+ self._closed = False # no more resubmits (close() started)
119
+ self._stop = False # the event thread ends (every transfer is back)
118
120
  self._transfers = []
121
+ self._active = 0 # transfers submitted and not yet back for good
119
122
  for _ in range(self.DEPTH):
120
123
  t = handle.getTransfer()
121
124
  t.setBulk(endpoint_in, self.URB_SIZE, callback=self._done, timeout=0)
122
125
  t.submit()
123
126
  self._transfers.append(t)
127
+ self._active += 1
124
128
  self._events = threading.Thread(target=self._run, daemon=True)
125
129
  self._events.start()
130
+ # Closed at exit too: left to usb1's own finalizers, the context went while the event thread was inside
131
+ # handleEventsTimeout with transfers out - the process hung at exit or libusb aborted (usbi_mutex_destroy).
132
+ atexit.register(self.close)
126
133
 
127
134
  @classmethod
128
135
  def open(cls, vid: int, pid: int, serial: str | None = None) -> "UsbAsyncStream":
@@ -158,14 +165,23 @@ class UsbAsyncStream:
158
165
  self._buffer += data
159
166
  self._cond.notify_all()
160
167
  if not self._closed and status in (usb1.TRANSFER_COMPLETED, usb1.TRANSFER_TIMED_OUT):
161
- transfer.submit()
168
+ try:
169
+ transfer.submit()
170
+ return
171
+ except usb1.USBError:
172
+ pass
173
+ with self._cond:
174
+ self._active -= 1 # back for good (cancelled, failed, or closing)
175
+ self._cond.notify_all()
162
176
 
163
177
  def _run(self) -> None:
164
- while not self._closed:
178
+ while not self._stop:
165
179
  try:
166
180
  self.context.handleEventsTimeout(0.1)
167
181
  except self._usb1.USBErrorInterrupted:
168
182
  continue
183
+ except self._usb1.USBError:
184
+ break
169
185
 
170
186
  @property
171
187
  def in_waiting(self) -> int:
@@ -195,15 +211,27 @@ class UsbAsyncStream:
195
211
  self._buffer.clear()
196
212
 
197
213
  def close(self) -> None:
214
+ """Cancel the IN transfers and let the event thread take them back before the handle and the context go
215
+ (closing with transfers still out hung the process or aborted libusb). Safe to call twice (atexit)."""
216
+ if self._closed:
217
+ return
198
218
  self._closed = True
219
+ atexit.unregister(self.close)
199
220
  for t in self._transfers:
200
221
  try:
201
222
  t.cancel()
202
223
  except self._usb1.USBError:
203
224
  pass
204
- self._events.join(timeout=0.5)
225
+ deadline = time.monotonic() + 1.0
226
+ with self._cond:
227
+ while self._active > 0 and self._events.is_alive() and time.monotonic() < deadline:
228
+ self._cond.wait(0.05)
229
+ self._stop = True
230
+ self._events.join(timeout=1.0)
205
231
  try:
206
232
  self.handle.releaseInterface(self.interface)
233
+ except self._usb1.USBError:
234
+ pass
207
235
  finally:
208
236
  self.handle.close()
209
237
  self.context.close()
@@ -2,7 +2,9 @@
2
2
 
3
3
  import struct
4
4
 
5
- from oep_client import __main__ as cli, config, endpoint, fake, host as h
5
+ import pytest
6
+
7
+ from oep_client import __main__ as cli, config, endpoint, fake, host as h, message as m
6
8
 
7
9
 
8
10
  class Clock:
@@ -97,3 +99,55 @@ def test_slot_takes_the_only_wire_by_default(monkeypatch):
97
99
  assert cli.main(["config", "slot", "x", "--name", "v003", "--attach", "at-boot", "--retry", "1"]) == 0
98
100
  (slot,) = [i for i in config.ProbeConfig(hst).items() if isinstance(i, config.Slot)]
99
101
  assert slot.wire_fn == 1 and slot.pins == (16, 0xFFFF)
102
+
103
+
104
+ def test_a_plan_refused_for_a_saved_plan_names_the_holder():
105
+ import pytest
106
+ from oep_client import core
107
+ ep, hst = open_bench()
108
+ cfg = config.ProbeConfig(hst)
109
+ cfg.set([config.Plan(5, 1, 20), config.Plan(5, 2, 21)]) # fixture.uart keeps 20 / 21 as a setting
110
+ with pytest.raises(core.PinsTaken) as e:
111
+ core.plan_apply(hst, [(4, 1, 21)]) # gpio wants 21
112
+ assert e.value.holders == [(21, "the saved plan of fn 5 (role 2)")] and "fn 5" in str(e.value)
113
+
114
+
115
+ def test_a_settings_plan_is_not_the_sessions():
116
+ """core §8: plan_release leaves a plan the settings put in (n = 0 too); plan_apply naming its fn is refused."""
117
+ ep, hst = open_bench()
118
+ config.ProbeConfig(hst).set([config.Plan(5, 1, 20), config.Plan(5, 2, 21)])
119
+ assert 5 in ep.plan_from_config
120
+ with pytest.raises(h.Rejected) as e:
121
+ hst.call(m.CORE_FN, m.OP_PLAN_APPLY, m.tlv(0x90, struct.pack("<HBH", 5, 1, 22)))
122
+ assert e.value.result.detail == m.UNAVAILABLE
123
+ hst.call(m.CORE_FN, m.OP_PLAN_RELEASE, b"\x00")
124
+ assert {(5, 1, 20), (5, 2, 21)} <= ep.plan and 5 in ep.plan_from_config
125
+
126
+
127
+ def test_reset_channels_and_a_named_reset_line():
128
+ """oep-if-debug §3: the host reads the reset channels the probe allows and names one; there is no default."""
129
+ from oep_client import target
130
+ ep = endpoint.Endpoint(fake.esp32_v003(), Clock())
131
+ hst = h.Host(lambda b: ep.handle(b, 0))
132
+ hst.open(3000)
133
+ wire = target.Wire(hst, "oep.wire.swio")
134
+ assert wire.reset_channels() == [23]
135
+ conn, _dpc = wire.attach_under_reset(23)
136
+ assert conn in ep.conns
137
+
138
+
139
+ def test_slot_line_settings_from_the_command(capsys, monkeypatch):
140
+ ep, hst = open_bench()
141
+
142
+ class FakeLink:
143
+ def close(self):
144
+ pass
145
+ hst.link = FakeLink()
146
+ hst.end()
147
+ monkeypatch.setattr(cli.link, "open_host", lambda target: hst)
148
+ assert cli.main(["config", "slot", "x", "--name", "l103", "--pins", "2,3", "--attach", "at-boot", "--retry", "1",
149
+ "--max-speed", "1000000", "--idle-clock", "low"]) == 0
150
+ (slot,) = [i for i in config.ProbeConfig(hst).items() if isinstance(i, config.Slot)]
151
+ assert (slot.max_speed, slot.idle_clock) == (1_000_000, "low")
152
+ assert cli.main(["config", "show", "x"]) == 0
153
+ assert "max 1000000 Hz idle-low" in capsys.readouterr().out
@@ -49,10 +49,10 @@ class Host:
49
49
  return r.payload
50
50
 
51
51
 
52
- def slot_item(n, wire, pair, attach=ATTACH["at_boot"], retry=1, mech=2, name=None, lock=None):
52
+ def slot_item(n, wire, pair, attach=ATTACH["at_boot"], retry=1, mech=2, name=None, lock=None, max_speed=0, idle=0):
53
53
  raw = (name or f"s{n}").encode()
54
- value = struct.pack("<BHHHBHBB", n, wire, *pair, attach, retry if attach == ATTACH["at_boot"] else 0, mech,
55
- len(raw)) + raw
54
+ value = struct.pack("<BHHHBHIBBB", n, wire, *pair, attach, retry if attach == ATTACH["at_boot"] else 0, max_speed,
55
+ idle, mech, len(raw)) + raw
56
56
  value += b"\x00" if lock is None else bytes([1]) + lock[0] + lock[1]
57
57
  return m.tlv(ITEM["slot"], value)
58
58
 
@@ -198,7 +198,7 @@ def test_too_many_at_boot_slots_are_refused():
198
198
  def test_slot_names_are_url_safe(name):
199
199
  ep, h = bench()
200
200
  assert h.raw(6, 0x02, slot_item(0, 1, ep.pairs[1][0], name=name or None) if name else
201
- m.tlv(ITEM["slot"], struct.pack("<BHHHBHBB", 0, 1, *ep.pairs[1][0], 1, 1, 2, 0) + b"\x00")).detail == m.MALFORMED
201
+ m.tlv(ITEM["slot"], struct.pack("<BHHHBHIBBB", 0, 1, *ep.pairs[1][0], 1, 1, 0, 0, 2, 0) + b"\x00")).detail == m.MALFORMED
202
202
 
203
203
 
204
204
  # ---- binds and the serial port ---------------------------------------------------------------------------
@@ -383,3 +383,51 @@ def test_the_closing_0x00_of_a_frame_is_not_raw_after_the_gap():
383
383
  port.tick()
384
384
  sid = ep.stream_keys[(ep._conn_at(1, p[0]), 2)]
385
385
  assert bytes(ep.streams[sid].written) == b"" # no stray 0x00 at the target
386
+
387
+
388
+ def test_read_from_a_last_mark_that_is_not_there_is_from_now():
389
+ ep, h = bench()
390
+ p = ep.pairs[1][0]
391
+ r = h.raw(1, 0x02, bytes([0]) + m.tlv(0x03, struct.pack("<HH", *p), critical=True))
392
+ uart_fn = 5
393
+ h.ok(0, m.OP_PLAN_APPLY, m.tlv(0x90, struct.pack("<HBH", uart_fn, 1, 20)) + m.tlv(0x90, struct.pack("<HBH", uart_fn, 2, 21)))
394
+ h.ok(uart_fn, 0x01, struct.pack("<I", 115200))
395
+ ep.uart_rx(uart_fn, b"old output")
396
+ reset = reg.TARGET_CONSOLE.enum["mark_kind"]["reset"]
397
+ got = h.raw(uart_fn, 0x02, struct.pack("<BQH", 3, reset, 64), session=False).payload
398
+ assert struct.unpack_from("<Q", got)[0] == 10 and got[9:] == b"" # no reset mark: from now, not the old bytes
399
+
400
+
401
+ def test_no_default_reset_line_only_the_declared_channels():
402
+ """oep-if-debug §3: attach_under_reset names its channel; the probe takes only role 3 (reset) channels, and not
403
+ one a plan holds."""
404
+ ep = endpoint.Endpoint(fake.esp32_v003(), Clock())
405
+ h = Host(ep)
406
+ h.open()
407
+ assert ep.reset_channels[1] == {23}
408
+ for channel in (0xFFFF, 22): # no default; 22 is not a reset line
409
+ assert h.raw(1, 0x04, struct.pack("<HH", channel, 20)).detail == m.UNAVAILABLE
410
+ h.ok(1, 0x04, struct.pack("<HH", 23, 20))
411
+ h.ok(0, m.OP_PLAN_APPLY, m.tlv(0x90, struct.pack("<HBH", 4, 1, 23))) # fixture.gpio takes NRST
412
+ assert h.raw(1, 0x04, struct.pack("<HH", 23, 20)).detail == m.UNAVAILABLE
413
+
414
+
415
+ def test_idle_clock_is_rvswd_s_and_a_slot_carries_the_line_settings():
416
+ ep, h = bench()
417
+ pair = ep.pairs[1][0]
418
+ idle_low = m.tlv(0x04, b"\x01", critical=True)
419
+ h.ok(1, 0x02, b"\x01" + m.tlv(0x03, struct.pack("<HH", *pair), critical=True) + idle_low)
420
+ assert ep.conns[ep._conn_at(1, pair)].idle_clock == 1
421
+ assert h.raw(1, 0x02, b"\x01" + m.tlv(0x03, struct.pack("<HH", *pair), critical=True)
422
+ + m.tlv(0x04, b"\x02", critical=True)).detail == m.MALFORMED
423
+ ep2, h2 = bench()
424
+ p2 = ep2.pairs[1][1]
425
+ h2.ok(6, 0x02, slot_item(0, 1, p2, name="l103", max_speed=1_000_000, idle=1))
426
+ c = ep2.conns[ep2._conn_at(1, p2)]
427
+ assert (c.speed, c.idle_clock) == (1_000_000, 1) # the probe's own attach uses the slot's settings
428
+ v003 = endpoint.Endpoint(fake.esp32_v003(), Clock())
429
+ hv = Host(v003)
430
+ hv.open()
431
+ assert hv.raw(9, 0x02, slot_item(0, 1, v003.pairs[1][0], idle=1)).detail == m.MALFORMED # swio has no idle_clock
432
+ r = hv.raw(1, 0x02, b"\x01" + idle_low)
433
+ assert r.resolution == m.REJECTED and r.detail == m.UNSUPPORTED # an unknown critical TLV on swio
@@ -0,0 +1,96 @@
1
+ """UsbAsyncStream closes cleanly: the IN transfers are cancelled and taken back by the event thread before the handle and
2
+ the context go (closing with transfers out hung the process at exit or aborted libusb), and close is hooked to exit."""
3
+
4
+ import atexit
5
+ import sys
6
+ import threading
7
+ import types
8
+
9
+ import pytest
10
+
11
+
12
+ class FakeUsb1(types.ModuleType):
13
+ TRANSFER_COMPLETED, TRANSFER_TIMED_OUT, TRANSFER_CANCELLED = 0, 2, 3
14
+
15
+ class USBError(Exception):
16
+ pass
17
+
18
+ class USBErrorInterrupted(USBError):
19
+ pass
20
+
21
+
22
+ usb1 = FakeUsb1("usb1")
23
+
24
+
25
+ class Transfer:
26
+ def __init__(self, ctx):
27
+ self.ctx, self.status, self.out = ctx, None, False
28
+
29
+ def setBulk(self, ep, size, callback, timeout):
30
+ self.callback = callback
31
+
32
+ def submit(self):
33
+ self.out = True
34
+
35
+ def cancel(self):
36
+ self.ctx.pending.append(self) # comes back as cancelled on a later event pass, like libusb
37
+
38
+ def getStatus(self):
39
+ return self.status
40
+
41
+
42
+ class Context:
43
+ def __init__(self):
44
+ self.pending, self.lock, self.closed = [], threading.Lock(), False
45
+
46
+ def handleEventsTimeout(self, t):
47
+ import time
48
+ time.sleep(0.005)
49
+ while self.pending:
50
+ tr = self.pending.pop(0)
51
+ tr.out, tr.status = False, usb1.TRANSFER_CANCELLED
52
+ tr.callback(tr)
53
+
54
+ def close(self):
55
+ self.closed = True
56
+
57
+
58
+ class Handle:
59
+ def __init__(self, ctx):
60
+ self.ctx, self.transfers, self.closed_with_out = ctx, [], None
61
+
62
+ def getTransfer(self):
63
+ t = Transfer(self.ctx)
64
+ self.transfers.append(t)
65
+ return t
66
+
67
+ def releaseInterface(self, n):
68
+ pass
69
+
70
+ def close(self):
71
+ self.closed_with_out = sum(t.out for t in self.transfers)
72
+
73
+
74
+ @pytest.fixture
75
+ def stream(monkeypatch):
76
+ monkeypatch.setitem(sys.modules, "usb1", usb1)
77
+ from oep_client import usb_stream
78
+ ctx = Context()
79
+ h = Handle(ctx)
80
+ s = usb_stream.UsbAsyncStream(ctx, h, 0x81, 0x01, 0)
81
+ return s, ctx, h
82
+
83
+
84
+ def test_close_takes_every_transfer_back_before_the_handle_goes(stream):
85
+ s, ctx, h = stream
86
+ s.close()
87
+ assert h.closed_with_out == 0 and ctx.closed and not s._events.is_alive()
88
+ s.close() # twice (atexit after an explicit close): nothing more
89
+
90
+
91
+ def test_close_is_hooked_to_exit(stream, monkeypatch):
92
+ s, ctx, h = stream
93
+ calls = []
94
+ monkeypatch.setattr(atexit, "unregister", lambda f: calls.append(f))
95
+ s.close()
96
+ assert calls == [s.close] # it was registered, and an explicit close takes it off