oep-client-python 0.0.21__py3-none-any.whl → 0.0.23__py3-none-any.whl

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.
oep_client/__init__.py CHANGED
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.0.21"
5
+ __version__ = "0.0.23"
oep_client/__main__.py CHANGED
@@ -1,10 +1,12 @@
1
1
  """The oep command: what a probe offers (dump) and its settings (config).
2
2
 
3
3
  oep dump --port /run/board-identify/by-id/<probe> oep dump --fake p4-x035 --prefix oep.target --json
4
- oep config show <probe>
4
+ oep config show <probe> (the settings and what the probe declares; lock-free)
5
+ oep config state <probe> (the live slot / bind / storage state; lock-free)
5
6
  oep config slot <probe> --name x035 --wire rvswd --pins 2,54 --attach at-boot --retry 1 --mechanism dmseq
6
7
  oep config bind <probe> --port 1 --mode last-reset --stream slot:x035
7
8
  oep config plan <probe> oep.fixture.uart#1 rx=48 tx=49 (the fn's whole plan; fn number or name#instance)
9
+ oep config uart <probe> oep.fixture.uart#1 115200 --format 8N1
8
10
  oep config remove <probe> bind 1 oep config save <probe> oep config erase <probe>
9
11
 
10
12
  <probe>: a serial port, tcp://HOST:PORT or usb[:VID:PID[:SERIAL]]. A change takes the lock (owner "oep config") and
@@ -52,9 +54,12 @@ def main(argv=None) -> int:
52
54
  def _config_parser(sub) -> None:
53
55
  c = sub.add_parser("config", help="the probe's settings (oep.probe.config): slots, binds, save")
54
56
  cs = c.add_subparsers(dest="action", required=True)
55
- show = cs.add_parser("show", help="the settings and the live slot / bind state")
57
+ show = cs.add_parser("show", help="the settings, what the probe declares, and the live slot / bind state")
56
58
  show.add_argument("probe")
57
59
  show.add_argument("--json", action="store_true")
60
+ state = cs.add_parser("state", help="the live slot / bind / storage state (op state, lock-free)")
61
+ state.add_argument("probe")
62
+ state.add_argument("--json", action="store_true")
58
63
  slot = cs.add_parser("slot", help="register a slot (a place a target is wired to)")
59
64
  slot.add_argument("probe")
60
65
  slot.add_argument("--slot", type=int, default=0, help="the slot number (default 0)")
@@ -62,8 +67,9 @@ def _config_parser(sub) -> None:
62
67
  slot.add_argument("--wire", help="rvswd or swio (or an fn); default: the probe's only wire")
63
68
  slot.add_argument("--pins", help="swdio,swclk (one pin on swio); default: the wire's only pin set")
64
69
  slot.add_argument("--attach", choices=sorted(config.ATTACH), default="host")
65
- slot.add_argument("--retry", type=int, default=0, help="at-boot: try again every N s while absent (0: never)")
66
- slot.add_argument("--mechanism", choices=sorted(config.MECHANISM), default="dmseq")
70
+ slot.add_argument("--retry", type=float, default=0, help="at-boot: try again every N s while absent (0: never)")
71
+ slot.add_argument("--mechanism", choices=sorted(config.MECHANISM), default="dmseq",
72
+ help="the console's mechanism, or none (no console on this slot)")
67
73
  slot.add_argument("--max-speed", type=int, default=0,
68
74
  help="the line's ceiling in Hz for the probe's own attach (0: none); the target's, e.g. 1000000")
69
75
  slot.add_argument("--idle-clock", choices=sorted(config.IDLE_CLOCK), default="high",
@@ -92,9 +98,15 @@ def _config_parser(sub) -> None:
92
98
  idle.add_argument("channel", type=int)
93
99
  idle.add_argument("mode", choices=sorted(config.IDLE))
94
100
  idle.add_argument("--save", action="store_true")
95
- rm = cs.add_parser("remove", help="remove one item: slot N, bind PORT, plan FN, label CH, idle CH")
101
+ uart = cs.add_parser("uart", help="a fixture UART's baud / format, applied whenever its plan has RX or TX")
102
+ uart.add_argument("probe")
103
+ uart.add_argument("fn", help="an fn, or name#instance of the oep.fixture.uart")
104
+ uart.add_argument("baud", type=int)
105
+ uart.add_argument("--format", default="8N1", help="data bits, parity, stop bits: 8N1 (default), 8E1, 7O2 ...")
106
+ uart.add_argument("--save", action="store_true")
107
+ rm = cs.add_parser("remove", help="remove one item (unset): slot N, bind PORT, plan FN, label CH, idle CH, uart FN")
96
108
  rm.add_argument("probe")
97
- rm.add_argument("kind", choices=["slot", "bind", "plan", "label", "idle"])
109
+ rm.add_argument("kind", choices=sorted(config.ITEM))
98
110
  rm.add_argument("key", type=int)
99
111
  rm.add_argument("--save", action="store_true")
100
112
  for name in ("save", "erase"):
@@ -108,7 +120,7 @@ def _pins(hst, fn: int, text: str | None, wire: str) -> tuple[int, int]:
108
120
  groups = []
109
121
  for tag, v in core.describe(hst, fn):
110
122
  if tag & 0x7F == catalog.CHANNEL_GROUP:
111
- roles = {v[1 + 3 * i]: struct.unpack_from("<H", v, 2 + 3 * i)[0] for i in range((len(v) - 1) // 3)}
123
+ roles = dict(catalog.unpack_channel_group(v)[1])
112
124
  groups.append((roles.get(1, 0xFFFF), roles.get(2, 0xFFFF)))
113
125
  if len(groups) != 1:
114
126
  raise SystemExit(f"{wire}: {len(groups)} pin sets on this probe - name one with --pins")
@@ -180,6 +192,8 @@ def _config(args) -> int:
180
192
  cfg = config.ProbeConfig(hst)
181
193
  if args.action == "show":
182
194
  return _show(hst, cfg, args.json)
195
+ if args.action == "state":
196
+ return _state(cfg, args.json)
183
197
  if args.action == "slot":
184
198
  fn = _wire_fn(hst, args.wire)
185
199
  args.wire = args.wire or str(fn)
@@ -208,6 +222,15 @@ def _config(args) -> int:
208
222
  raise SystemExit(f"{role}: not a role of fn {fn} (roles: {', '.join(sorted(roles)) or 'numbers only'})")
209
223
  items.append(config.Plan(fn=fn, role=number, channel=int(ch, 0)))
210
224
  _change(hst, cfg, items, args.save)
225
+ elif args.action == "uart":
226
+ fn, _ = _plan_fn(hst, args.fn)
227
+ from .fixture import FixtureUart
228
+ fmt = args.format.upper()
229
+ try:
230
+ byte = FixtureUart.format_byte(int(fmt[0]), fmt[1], int(fmt[2]))
231
+ except (KeyError, ValueError, IndexError):
232
+ raise SystemExit(f"--format {args.format}: want <data bits><parity><stop bits>, e.g. 8N1, 8E2, 7O1") from None
233
+ _change(hst, cfg, [config.Uart(fn=fn, baud=args.baud, format=byte)], args.save)
211
234
  elif args.action == "label":
212
235
  _change(hst, cfg, [config.Label(channel=args.channel, text=args.text)], args.save)
213
236
  elif args.action == "idle":
@@ -232,42 +255,66 @@ def _config(args) -> int:
232
255
  hst.link.close()
233
256
 
234
257
 
258
+ def _plain(o):
259
+ return {k: (v.hex() if isinstance(v, bytes) else v) for k, v in vars(o).items()} if hasattr(o, "__dict__") \
260
+ else list(o)
261
+
262
+
263
+ def _state_dict(st) -> dict:
264
+ return {**{k: v for k, v in vars(st).items() if k not in ("slots", "binds")},
265
+ "slots": [_plain(s) for s in st.slots], "binds": [_plain(b) for b in st.binds]}
266
+
267
+
268
+ def _state(cfg, as_json: bool) -> int:
269
+ """The live state alone (op state, lock-free): what a monitor polls."""
270
+ st = cfg.state()
271
+ if as_json:
272
+ print(json.dumps(_state_dict(st), indent=2, default=lambda o: o.hex() if isinstance(o, bytes) else str(o)))
273
+ return 0
274
+ why = f" ({st.unreadable})" if st.unreadable else ""
275
+ print(f"storage: {st.storage}{why}, saved hash 0x{st.saved_hash:08x}")
276
+ for s in st.slots:
277
+ print(f" slot {s.slot}: {s.state}" + (f", connection {s.connection}" if s.connection else "")
278
+ + (f", tried at {s.last_try_at_ns / 1e9:.3f} s" if s.last_try_at_ns is not None else "")
279
+ + (f", target_id {s.target_id[::-1].hex()}" if s.target_id else ""))
280
+ for b in st.binds:
281
+ print(f" port {b.port}: {b.mode}, {b.flow}" + (f", carrying {b.selected}" if b.selected is not None else ""))
282
+ return 0
283
+
284
+
235
285
  def _show(hst, cfg, as_json: bool) -> int:
236
286
  h, _ = cfg.get()
237
287
  items = cfg.items()
288
+ decl = cfg.describe()
238
289
  st = cfg.state()
239
290
  kinds = {i: _name(k) for i, k, _ in core.transports(hst)}
240
291
  if as_json:
241
- def plain(o):
242
- return {k: (v.hex() if isinstance(v, bytes) else v) for k, v in vars(o).items()} if hasattr(o, "__dict__") \
243
- else list(o)
244
- out = {"hash": h, "items": [dict(type=type(i).__name__, **plain(i)) if hasattr(i, "__dict__") else plain(i)
292
+ out = {"hash": h, "items": [dict(type=type(i).__name__, **_plain(i)) if hasattr(i, "__dict__") else _plain(i)
245
293
  for i in items],
246
- "state": {**{k: v for k, v in vars(st).items() if k not in ("slots", "binds")},
247
- "slots": [plain(s) for s in st.slots], "binds": [plain(b) for b in st.binds]},
248
- "transports": kinds}
294
+ "declared": _plain(decl), "state": _state_dict(st), "transports": kinds}
249
295
  print(json.dumps(out, indent=2, default=lambda o: o.hex() if isinstance(o, bytes) else str(o)))
250
296
  return 0
251
- print(f"storage: {st.storage} ({st.storage_bytes} bytes), saved hash 0x{st.saved_hash:08x}; now 0x{h:08x}")
297
+ why = f" ({st.unreadable})" if st.unreadable else ""
298
+ print(f"storage: {st.storage}{why} ({decl.storage_bytes} bytes), saved hash 0x{st.saved_hash:08x}; now 0x{h:08x}")
252
299
  print("transports: " + ", ".join(f"{i} {k}" for i, k in kinds.items()))
253
300
  by_slot = {s.slot: s for s in st.slots}
254
- print(f"slots (up to {st.slots_max}):")
301
+ print(f"slots (up to {decl.slots_max}):")
255
302
  for it in items:
256
303
  if isinstance(it, config.Slot):
257
304
  s = by_slot.get(it.slot)
258
305
  pins = f"{it.pins[0]}" if it.pins[1] == 0xFFFF else f"{it.pins[0]},{it.pins[1]}"
259
- retry = f" retry {it.retry_s} s" if it.attach == "at-boot" else ""
306
+ retry = f" retry {it.retry_s:g} s" if it.attach == "at-boot" else ""
260
307
  retry += (f" max {it.max_speed} Hz" if it.max_speed else "") + (" idle-low" if it.idle_clock == "low" else "")
261
308
  lock = (f" lock {int.from_bytes(it.lock[1], 'little'):08x}:{int.from_bytes(it.lock[2], 'little'):08x}"
262
309
  if it.lock else "")
263
310
  live = ""
264
311
  if s:
265
- tried = "never tried" if s.last_try_ms is None else f"tried {s.last_try_ms} ms ago"
312
+ tried = "never tried" if s.last_try_at_ns is None else f"tried at {s.last_try_at_ns / 1e9:.3f} s"
266
313
  tid = f" target_id {s.target_id[::-1].hex()}" if s.target_id else ""
267
314
  live = f" -> {s.state}" + (f" (connection {s.connection})" if s.connection else f" ({tried})") + tid
268
315
  print(f" {it.slot} {it.name}: fn {it.wire_fn} pins {pins} {it.attach}{retry} {it.mechanism}{lock}{live}")
269
316
  by_port = {b.port: b for b in st.binds}
270
- print(f"binds (modes: {', '.join(st.bind_modes) or '-'}):")
317
+ print(f"binds (modes: {', '.join(decl.bind_modes) or '-'}):")
271
318
  names = {it.slot: it.name for it in items if isinstance(it, config.Slot)}
272
319
  for it in items:
273
320
  if isinstance(it, config.Bind):
oep_client/arm.py CHANGED
@@ -29,17 +29,19 @@ class SwdWire(WireBase):
29
29
  self.existing = False
30
30
 
31
31
  def attach(self, targetsel: int | None = None, max_speed: int | None = None,
32
- pins: tuple[int, int] | None = None) -> tuple[int, int, bool]:
33
- """-> (connection, DPIDR, woke from dormant). targetsel (multidrop) and max_speed go as critical TLVs: a probe
34
- that cannot honour them refuses. self.existing: the wire was attached already (its connection returned)."""
35
- body = self._speed_tlv(max_speed) + self._pins_tlv(pins)
32
+ pins: tuple[int, int] | None = None, reset: tuple[int, int] | None = None) -> tuple[int, int, bool]:
33
+ """-> (connection, DPIDR, woke from dormant). The request is method 0 (swd has no halting attach) and TLVs:
34
+ max_speed (required, critical; None: the wire's declared max_clock_hz), targetsel (multidrop, critical), pins,
35
+ reset = (channel, hold_ms) (critical). self.existing: the wire was attached already (its connection returned).
36
+ self.flags: the attach_flags byte."""
37
+ body = bytes([0]) + self._speed_tlv(self._speed_or_default(max_speed)) + self._pins_tlv(pins) + self._reset_tlv(reset)
36
38
  if targetsel is not None:
37
39
  body += m.tlv(self.TAG_TARGETSEL, struct.pack("<I", targetsel), critical=True)
38
40
  rd = m.Reader(self._call(self.ATTACH, body).payload)
39
- conn, dpidr, flags, self.speed_hz = rd.take("HIBI")
40
- self.existing = bool(flags & 2)
41
+ conn, dpidr, self.flags, self.speed_hz = rd.take("HIBI")
42
+ self.existing = bool(self.flags & self.FLAGS["existing"])
41
43
  rd.tail()
42
- return conn, dpidr, bool(flags & 1)
44
+ return conn, dpidr, bool(self.flags & self.FLAGS["dormant_woken"])
43
45
 
44
46
 
45
47
  class AdiError(TargetError):
@@ -82,8 +84,8 @@ class ArmAdi(Interface):
82
84
  reads = transfer_reads(steps)
83
85
  r = self._request(self.TRANSFER, struct.pack("<H", len(reads)) + steps)
84
86
  rd = ran(r)
85
- done, status, self.last_ack = rd.take("HBB")
86
- values = rd.words(sum(reads[:done]))
87
+ done, status, self.last_ack, nvals = rd.take("HBBH") # nvals: the values the answer carries (debug §6)
88
+ values = rd.words(nvals)
87
89
  rd.tail()
88
90
  if status != OK or not r.succeeded or done != len(reads):
89
91
  raise AdiError(f"transfer (ack {self.last_ack:#x})", status, r, done=done, values=values)
oep_client/capture.py CHANGED
@@ -5,6 +5,11 @@ Times are the probe's one clock (ns since its boot, comparable within one boot_i
5
5
  probe's known corrections applied. Analog values are always raw; the probe's 1st-order scale, its calibration data and
6
6
  its reference are for the host to choose from.
7
7
 
8
+ Every start begins a new generation (u32, from 1): segment serials and positions count from 0 inside it, and read and
9
+ release name it, so a read sent for the last capture never returns the next one's bytes (oep-if-capture §3.2). The
10
+ client keeps it (`LogicCapture.generation`, from start / status / the group's start) and passes it on; `read_segment`
11
+ takes the segment's own.
12
+
8
13
  configure: a TLV the probe cannot honour is refused (rejected unsupported, 0x0B, payload = the tag) when the host marked
9
14
  it critical, and otherwise ignored and listed in the answer's ignored TLV (0x7F, oep-core §2.3)."""
10
15
 
@@ -24,13 +29,17 @@ _CAP = reg.FIXTURE_LOGIC
24
29
  _ANA = reg.FIXTURE_ANALOG
25
30
  _GRP = reg.FIXTURE_CAPTURE_GROUP
26
31
  # configure TLVs; bit 7 of a tag = critical (the probe must reject what it cannot do)
27
- MODE, RATE, SAMPLES, SEGMENTS, TRIGGER, PRETRIGGER, FRONTEND = (
28
- _CAP.tlv["configure"][k] for k in ("mode", "rate", "samples", "segments", "trigger", "pretrigger", "frontend"))
32
+ MODE, RATE, SAMPLES, SEGMENTS, TRIGGER, PRETRIGGER = (
33
+ _CAP.tlv["configure"][k] for k in ("mode", "rate", "samples", "segments", "trigger", "pretrigger"))
34
+ FRONTEND = _ANA.tlv["configure"]["frontend"] # analog only
29
35
  ACTUAL_RATE, LAYOUT, ACTUAL_SAMPLES, ACTUAL_SEGMENTS, TIMING, SCALE, BLOCKING, SKEW, FRONTEND_USED, REFERENCE, \
30
36
  RATE_ACCURACY = (_ANA.tlv["configure_answer"][k] for k in (
31
37
  "actual_rate", "layout", "actual_samples", "actual_segments", "timing", "scale", "blocking_ms", "skew",
32
38
  "frontend_used", "reference", "rate_accuracy"))
33
39
  FACTORY, VREFINT = _ANA.tlv["calibration_answer"]["factory"], _ANA.tlv["calibration_answer"]["vrefint"]
40
+ STATUS_ERROR = _CAP.tlv["status_answer"]["error"] # status's TLV: why the state is 6
41
+ DATA_GENERATION = _CAP.tlv["data"]["generation"] # a data frame's TLV: its generation (always in streaming)
42
+ GROUP_GENERATIONS = _GRP.tlv["start_answer"]["generations"]
34
43
  REFERENCE_SOURCE = {v: k for k, v in _ANA.enum["reference_source"].items()}
35
44
  IGNORED = m.TAG_IGNORED
36
45
  CRITICAL = m.TAG_CRITICAL
@@ -38,6 +47,8 @@ ONE_SHOT, REPEAT, STREAMING = (_CAP.enum["mode"][k] for k in ("one_shot", "repea
38
47
  IMMEDIATE, LEVEL, EDGE, CROSS_UP, CROSS_DOWN = range(5)
39
48
  STATE = _CAP.enum["state"]
40
49
  SEGMENT_SLIPPED = _CAP.enum["segment_flag"]["slipped"]
50
+ STATUS_FLAGS = _CAP.enum["status_flag"] # dropped, slipped (reset at start)
51
+ ERRORS = {v: k for k, v in _CAP.enum["error"].items()} # state 6's reason
41
52
  # events (oep-core §11, role 0x05)
42
53
  EVENT_SEGMENT, EVENT_STOPPED, EVENT_TRIGGERED = (_CAP.event[k] for k in ("segment", "stopped", "triggered"))
43
54
 
@@ -46,8 +57,8 @@ def tlvs(payload: bytes) -> list[tuple[int, bytes]]:
46
57
  return m.split_tlvs(payload)
47
58
 
48
59
 
49
- SEGMENT_BYTES = 33 # serial u32, position u64, samples u32, start_ns u64, start_uncertainty_ns u32, trigger_index u32,
50
- # flags u8
60
+ SEGMENT_BYTES = 37 # serial u32, position u64, samples u32, start_ns u64, start_uncertainty_ns u32, trigger_index u32,
61
+ # flags u8, generation u32 (oep-if-capture §2)
51
62
 
52
63
 
53
64
  @dataclass
@@ -59,6 +70,7 @@ class Segment:
59
70
  start_uncertainty_ns: int # +- of start_ns (a guide, not a promise)
60
71
  trigger_index: int | None
61
72
  flags: int
73
+ generation: int = 0 # the start this segment belongs to (read / release name it)
62
74
 
63
75
  @property
64
76
  def slipped(self) -> bool:
@@ -67,8 +79,34 @@ class Segment:
67
79
 
68
80
  @classmethod
69
81
  def unpack(cls, b: bytes) -> "Segment":
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)
82
+ serial, position, samples, start_ns, uncertainty, trig, flags, generation = struct.unpack_from("<IQIQIIBI", b)
83
+ return cls(serial, position, samples, start_ns, uncertainty, None if trig == 0xFFFFFFFF else trig, flags,
84
+ generation)
85
+
86
+
87
+ @dataclass
88
+ class Status:
89
+ """status's answer (oep-if-capture §3.2). Iterates as (state, serial_done, write_pos, flags), the first shape."""
90
+ state: int
91
+ serial_done: int # segments finished
92
+ write_pos: int # bytes taken so far (dropped ones counted: the next byte's position)
93
+ flags: int # bit0 dropped, bit1 slipped - since start
94
+ generation: int
95
+ error: int | None = None # state 6: why (ERRORS)
96
+
97
+ def __iter__(self):
98
+ return iter((self.state, self.serial_done, self.write_pos, self.flags))
99
+
100
+ def __getitem__(self, i: int):
101
+ return (self.state, self.serial_done, self.write_pos, self.flags)[i]
102
+
103
+ @property
104
+ def dropped(self) -> bool:
105
+ return bool(self.flags & STATUS_FLAGS["dropped"])
106
+
107
+ @property
108
+ def error_name(self) -> str | None:
109
+ return None if self.error is None else ERRORS.get(self.error, f"error 0x{self.error:02x}")
72
110
 
73
111
 
74
112
  @dataclass
@@ -124,17 +162,27 @@ class Received:
124
162
  gaps: list[tuple[int, int]] = field(default_factory=list) # (index in data where it skipped, bytes skipped)
125
163
  seq_lost: int = 0 # push frames missing by seq
126
164
  frames: int = 0
165
+ stale: int = 0 # pushes of an earlier generation, dropped
166
+
167
+
168
+ def unpack_push(frame: bytes) -> tuple[int, int, int, bytes, int | None]:
169
+ """A data frame (core §11.2: role fn seq position(u64) len(u16) data [TLV]) -> (fn, seq, position, data,
170
+ generation): the TLV 0x01 generation, None when the frame carries none."""
171
+ fn, seq, position = struct.unpack_from("<HHQ", frame, 1)
172
+ rd = m.Reader(frame[13:])
173
+ data = rd.counted("H")
174
+ g = rd.tail().get(DATA_GENERATION)
175
+ return fn, seq, position, data, struct.unpack("<I", g)[0] if g is not None and len(g) == 4 else None
127
176
 
128
177
 
129
- def take_pushes(link, fn: int) -> list[tuple[int, int, bytes]]:
130
- """Remove this fn's data pushes (role 0x06) from the link: [(seq, position, data)], oldest first."""
178
+ def take_pushes(link, fn: int) -> list[tuple[int, int, bytes, int | None]]:
179
+ """Remove this fn's data pushes (role 0x06) from the link: [(seq, position, data, generation)], oldest first."""
131
180
  mine, rest = [], []
132
181
  for f in link.pushes:
133
182
  (mine if struct.unpack_from("<H", f, 1)[0] == fn else rest).append(f)
134
183
  link.pushes.clear()
135
184
  link.pushes.extend(rest)
136
- # the core header is role fn seq; the capture's payload is position(u64) then data (standard position stream)
137
- return [(struct.unpack_from("<H", f, 3)[0], struct.unpack_from("<Q", f, 5)[0], f[13:]) for f in mine]
185
+ return [unpack_push(f)[1:] for f in mine]
138
186
 
139
187
 
140
188
  def _config(payload: bytes, analog: bool) -> Config:
@@ -183,10 +231,13 @@ class LogicCapture(Interface):
183
231
  CONFIGURE, START, STOP, FORCE, STATUS, READ, SEGMENTS, RELEASE, QUERY_OP = (
184
232
  _CAP.op[k] for k in ("configure", "start", "stop", "force", "status", "read", "segments", "release", "query"))
185
233
 
234
+ generation: int | None = None # the current capture's generation (start / status / the group's start)
235
+
186
236
  def __init__(self, hst: h.Host, fn: int | None = None, name: str | None = None):
187
237
  super().__init__(hst, name, fn=fn)
188
238
  self.config: Config | None = None
189
239
  self.armed_s: float | None = None # time.monotonic() at the last start()
240
+ self.generation = None
190
241
 
191
242
  def configure(self, *, rate: int, mode: int = ONE_SHOT, samples: int | None = None, segments: int | None = None,
192
243
  trigger: tuple[int, int, int] | None = None, pretrigger: int | None = None, query: bool = False,
@@ -201,7 +252,7 @@ class LogicCapture(Interface):
201
252
  if segments is not None:
202
253
  body += tlv(SEGMENTS, struct.pack("<I", segments))
203
254
  if trigger is not None:
204
- body += tlv(TRIGGER, struct.pack("<BBH", *trigger))
255
+ body += tlv(TRIGGER, struct.pack("<BBI", *trigger)) # type, role, value (u32)
205
256
  if pretrigger is not None:
206
257
  body += tlv(PRETRIGGER, struct.pack("<I", pretrigger))
207
258
  for role, fe in sorted((frontends or {}).items()): # analog: the input range per channel (describe frontend)
@@ -225,8 +276,9 @@ class LogicCapture(Interface):
225
276
  into: Received | None = None, keepalive_s: float = 1.0) -> Received:
226
277
  """Streaming: collect data pushes until `nbytes` have arrived or `seconds` have passed (at least one is needed).
227
278
  A position that does not follow the previous push is a probe-side drop (a gap); a seq that skips is a lost frame.
228
- The subscription ends with the lock, so the lock is kept alive every `keepalive_s` while collecting (a stream
229
- longer than the lease otherwise stopped: 35 MB missing at the end of 10 s at 150 MHz with a 10 s lease)."""
279
+ A push of another generation than this capture's (a leftover of the start before, oep-if-capture §3.4) is
280
+ dropped. The subscription ends with the lock, so the lock is kept alive every `keepalive_s` while collecting (a
281
+ stream longer than the lease otherwise stopped: 35 MB missing at the end of 10 s at 150 MHz with a 10 s lease)."""
230
282
  if seconds is None and nbytes is None:
231
283
  raise ValueError("stream() needs seconds or nbytes")
232
284
  got = into or Received()
@@ -238,7 +290,7 @@ class LogicCapture(Interface):
238
290
  for e in link.events:
239
291
  if struct.unpack_from("<H", e, 1)[0] == self.fn:
240
292
  event_seqs.add(struct.unpack_from("<H", e, 3)[0])
241
- for seq, position, data in take_pushes(link, self.fn):
293
+ for seq, position, data, generation in take_pushes(link, self.fn):
242
294
  while expect_seq is not None and expect_seq != seq:
243
295
  if expect_seq in event_seqs:
244
296
  event_seqs.discard(expect_seq)
@@ -246,6 +298,9 @@ class LogicCapture(Interface):
246
298
  got.seq_lost += 1
247
299
  expect_seq = (expect_seq + 1) & 0xFFFF
248
300
  expect_seq = (seq + 1) & 0xFFFF
301
+ if generation is not None and self.generation is not None and generation != self.generation:
302
+ got.stale += 1 # the generation before: not this capture's bytes
303
+ continue
249
304
  if got.start is None:
250
305
  got.start = position
251
306
  else:
@@ -268,7 +323,7 @@ class LogicCapture(Interface):
268
323
  def finish(self, link, got: Received, timeout: float = 5.0) -> Received:
269
324
  """Streaming, after stop(): collect the pushes still to come, up to the last byte captured (status's write
270
325
  position), or until `timeout`."""
271
- end = self.status()[2]
326
+ end = self.status().write_pos
272
327
  deadline = time.monotonic() + timeout
273
328
  while time.monotonic() < deadline:
274
329
  if got.start is not None:
@@ -279,8 +334,10 @@ class LogicCapture(Interface):
279
334
  return got
280
335
 
281
336
  def start(self) -> int:
282
- """-> blocking_ms (0: the probe keeps answering while it captures)."""
283
- blocking = m.Reader(self._call(self.START).payload).u32()
337
+ """-> blocking_ms (0: the probe keeps answering while it captures). self.generation: the new capture's."""
338
+ rd = m.Reader(self._call(self.START).payload)
339
+ blocking, self.generation = rd.take("II")
340
+ rd.tail()
284
341
  self.armed_s = time.monotonic()
285
342
  return blocking
286
343
 
@@ -291,19 +348,44 @@ class LogicCapture(Interface):
291
348
  """Waiting for the trigger: start now (the segment's trigger_index marks where)."""
292
349
  self._call(self.FORCE)
293
350
 
294
- def status(self) -> tuple[int, int, int, int]:
295
- """-> state, segments done, write position, flags."""
296
- return m.Reader(self._call(self.STATUS, locked=False).payload).take("BIQB")
297
-
298
- def release(self, serial: int) -> None:
299
- """Repeat: segments up to `serial` may be reused."""
300
- self._call(self.RELEASE, struct.pack("<I", serial))
301
-
302
- def segments(self, from_serial: int = 0) -> list[Segment]:
351
+ def status(self) -> Status:
352
+ """-> Status (state, segments done, write position, flags, generation, error). Lock-free; it also brings the
353
+ generation a host that did not start the capture needs for read."""
354
+ rd = m.Reader(self._call(self.STATUS, locked=False).payload)
355
+ state, done, pos, flags, generation = rd.take("BIQBI")
356
+ error = rd.tail().get(STATUS_ERROR)
357
+ self.generation = generation
358
+ return Status(state, done, pos, flags, generation, error[0] if error else None)
359
+
360
+ def release(self, serial: int, generation: int | None = None) -> None:
361
+ """Repeat: segments up to and including `serial` may be reused (of this generation; another one is rejected
362
+ unavailable). In state 5 (no free segment) the probe goes on by itself once there is room."""
363
+ self._call(self.RELEASE, struct.pack("<II", self._generation(generation), serial))
364
+
365
+ def _generation(self, generation: int | None) -> int:
366
+ if generation is not None:
367
+ return generation
368
+ if self.generation is None:
369
+ self.status() # a host that did not start it: ask
370
+ return self.generation
371
+
372
+ def segments_page(self, from_serial: int = 0) -> tuple[list[Segment], bool]:
373
+ """One answer's segment records from `from_serial` on. -> (segments, more)."""
303
374
  rd = m.Reader(self._call(self.SEGMENTS, struct.pack("<I", from_serial), locked=False).payload)
304
- out = [Segment.unpack(rd.element().bytes(SEGMENT_BYTES)) for _ in range(rd.u8())]
375
+ more, count = rd.take("BB")
376
+ out = [Segment.unpack(rd.element().bytes(SEGMENT_BYTES)) for _ in range(count)]
305
377
  rd.tail()
306
- return out
378
+ return out, bool(more)
379
+
380
+ def segments(self, from_serial: int = 0) -> list[Segment]:
381
+ """Every segment record from `from_serial` on, following `more`."""
382
+ out: list[Segment] = []
383
+ while True:
384
+ page, more = self.segments_page(from_serial)
385
+ out += page
386
+ if not more or not page:
387
+ return out
388
+ from_serial = page[-1].serial + 1
307
389
 
308
390
  def wait(self, timeout: float = 5.0, keepalive_s: float = 1.0) -> list[Segment]:
309
391
  """Poll status until the one-shot is done (or failed). -> its segments. Waiting for a trigger may take longer
@@ -314,11 +396,12 @@ class LogicCapture(Interface):
314
396
  if keepalive_s and self.host.session is not None and time.monotonic() - kept >= keepalive_s:
315
397
  self.host.keepalive()
316
398
  kept = time.monotonic()
317
- state = self.status()[0]
399
+ st = self.status()
400
+ state = st.state
318
401
  if state == STATE["done"]:
319
402
  return self.segments()
320
403
  if state == STATE["error"]:
321
- raise h.Failed(None, "the capture stopped with an error")
404
+ raise h.Failed(None, f"the capture stopped with an error ({st.error_name or 'unknown'})")
322
405
  if state not in STATE.values():
323
406
  raise h.ProtocolError(f"capture state {state} is not one this client knows")
324
407
  time.sleep(0.002)
@@ -327,14 +410,25 @@ class LogicCapture(Interface):
327
410
  READ_TRIES = 4 # batches of reads sent again after the link's own repeat failed too
328
411
  BATCH = 16 # frame-sized reads per pipeline; a keepalive between batches when a session is open
329
412
 
330
- def read(self, position: int, length: int) -> bytes:
331
- """Bytes [position, position+length) of the stream, pipelined in frame-sized reads.
413
+ READ_HEAD = 13 # the answer's position(u64) flags(u8) len(u32) in front of the data
414
+
415
+ @staticmethod
416
+ def _read_data(payload: bytes) -> bytes:
417
+ rd = m.Reader(payload)
418
+ rd.take("QB")
419
+ return rd.counted("I") # position flags len(u32) data [TLV]
420
+
421
+ def read(self, position: int, length: int, generation: int | None = None) -> bytes:
422
+ """Bytes [position, position+length) of the stream, pipelined in frame-sized reads. `generation`: the capture
423
+ they belong to (default: the one this client saw at start / status); the probe refuses another one as
424
+ unavailable (cause 6), so an old read never gets the next capture's bytes.
332
425
 
333
426
  The reads need no lock and go without the session id, so a repeat after a broken reply is simply run again
334
427
  (reads are not deduplicated, oep-core §5.2). They do not extend the lease, though: a long read (64 KB over a
335
428
  115200 bps UART probe takes seconds) sends a keepalive between batches, or the lease lapsed mid-read, the plan
336
429
  went with it (core §9) and the capture read back nothing (2026-09-26, V003 jig)."""
337
- chunk = max(1, confirm(self.host)["max_frame"] - 16)
430
+ g = self._generation(generation)
431
+ chunk = max(1, confirm(self.host)["max_frame"] - m.RESULT_HEADER - self.READ_HEAD)
338
432
  offsets = list(range(0, length, chunk))
339
433
  out = bytearray()
340
434
  for at in range(0, len(offsets), self.BATCH):
@@ -342,7 +436,8 @@ class LogicCapture(Interface):
342
436
  self.host.keepalive()
343
437
  batch = offsets[at:at + self.BATCH]
344
438
  for attempt in range(self.READ_TRIES):
345
- reqs = [self.request(self.READ, struct.pack("<QI", position + off, min(chunk, length - off))) for off in batch]
439
+ reqs = [self.request(self.READ, struct.pack("<IQI", g, position + off, min(chunk, length - off)))
440
+ for off in batch]
346
441
  try:
347
442
  replies = self.host.pipeline_calls(reqs, locked=False)
348
443
  break
@@ -352,11 +447,11 @@ class LogicCapture(Interface):
352
447
  if attempt == self.READ_TRIES - 1:
353
448
  raise
354
449
  for off, r in zip(batch, replies):
355
- data = r.payload[9:] # after position(u64) flags(u8)
450
+ data = self._read_data(r.payload)
356
451
  want = min(chunk, length - off)
357
452
  while len(data) < want: # a short answer: read on from where it stopped
358
- more = self._call(self.READ, struct.pack("<QI", position + off + len(data), want - len(data)),
359
- locked=False).payload[9:]
453
+ more = self._read_data(self._call(self.READ, struct.pack(
454
+ "<IQI", g, position + off + len(data), want - len(data)), locked=False).payload)
360
455
  if not more:
361
456
  raise h.ProtocolError(f"read at {position + off + len(data)} returned nothing")
362
457
  data += more
@@ -364,11 +459,11 @@ class LogicCapture(Interface):
364
459
  return bytes(out)
365
460
 
366
461
  def read_segment(self, segment: Segment) -> bytes:
367
- """The segment's bytes; every Host.on_capture callback gets them as a CaptureRecord (a run recorder, e.g.
368
- pytest-embedded-wireskein, without this package knowing it)."""
462
+ """The segment's bytes (of its generation); every Host.on_capture callback gets them as a CaptureRecord (a run
463
+ recorder, e.g. pytest-embedded-wireskein, without this package knowing it)."""
369
464
  c = self.config
370
465
  n = (segment.samples * c.width + 7) // 8 if c.width else segment.samples * len(c.order) * c.slot // 8
371
- data = self.read(segment.position, n)
466
+ data = self.read(segment.position, n, segment.generation or None)
372
467
  if self.host.on_capture:
373
468
  record = CaptureRecord(self.fn, self.name, c, segment, data, self.armed_s, time.monotonic())
374
469
  for callback in list(self.host.on_capture):
@@ -408,9 +503,11 @@ class LogicCapture(Interface):
408
503
  class Calibration:
409
504
  """What the probe knows for turning an analog value into a voltage (oep-if-capture §3.8), raw: the probe applies
410
505
  none of it. factory: (frontend or None, scheme, raw bytes) - scheme names how to read raw; vrefint: (raw, ns), the
411
- internal reference measured after the last start."""
506
+ internal reference measured after the last start, and vrefint_nominal_mv its nominal voltage (what the supply is
507
+ worked back from)."""
412
508
  factory: list[tuple[int | None, str, bytes]] = field(default_factory=list)
413
509
  vrefint: tuple[int, int] | None = None
510
+ vrefint_nominal_mv: int | None = None
414
511
 
415
512
 
416
513
  class AnalogCapture(LogicCapture):
@@ -439,11 +536,14 @@ class AnalogCapture(LogicCapture):
439
536
  def calibration(self) -> Calibration:
440
537
  out = Calibration()
441
538
  for tag, v in tlvs(self._call(self.CALIBRATION, locked=False).payload):
442
- if tag == FACTORY:
443
- n = v[1]
444
- out.factory.append((None if v[0] == 0xFF else v[0], v[2:2 + n].decode("utf-8", "replace"), bytes(v[2 + n:])))
445
- elif tag == VREFINT:
446
- out.vrefint = struct.unpack("<IQ", v)
539
+ if tag == FACTORY: # frontend scheme_len scheme raw_len(u16) raw
540
+ rd = m.Reader(v)
541
+ fe = rd.u8()
542
+ scheme = rd.counted("B").decode("utf-8", "replace")
543
+ out.factory.append((None if fe == 0xFF else fe, scheme, bytes(rd.counted("H"))))
544
+ elif tag == VREFINT: # raw(u32) ns(u64) nominal_mv(u32)
545
+ raw, ns, nominal = struct.unpack_from("<IQI", v)
546
+ out.vrefint, out.vrefint_nominal_mv = (raw, ns), nominal
447
547
  return out
448
548
 
449
549
 
@@ -474,11 +574,17 @@ class CaptureGroup(Interface):
474
574
  self._call(self.BIND, body)
475
575
 
476
576
  def start(self, tracks: list[LogicCapture] = ()) -> tuple[int, int]:
477
- """-> (blocking_ms, the group's start_ns). `tracks`: whose armed_s to set (for records)."""
478
- blocking, start_ns = m.Reader(self._call(self.START).payload).take("IQ")
577
+ """-> (blocking_ms, the group's start_ns). `tracks`: whose armed_s and generation to set (the answer's TLV
578
+ generations names each track's new generation; self.generations keeps them by fn)."""
579
+ rd = m.Reader(self._call(self.START).payload)
580
+ blocking, start_ns = rd.take("IQ")
581
+ gens = rd.tail().get(GROUP_GENERATIONS) or b""
582
+ self.generations = {fn: g for fn, g in struct.iter_unpack("<HI", gens[:len(gens) // 6 * 6])}
479
583
  now = time.monotonic()
480
584
  for t in tracks:
481
585
  t.armed_s = now
586
+ if t.fn in self.generations:
587
+ t.generation = self.generations[t.fn]
482
588
  return blocking, start_ns
483
589
 
484
590
  def stop(self) -> None:
@@ -488,7 +594,9 @@ class CaptureGroup(Interface):
488
594
  self._call(self.FORCE)
489
595
 
490
596
  def status(self) -> GroupStatus:
491
- state, start, trig, fn = m.Reader(self._call(self.STATUS, locked=False).payload).take("BQQH")
597
+ rd = m.Reader(self._call(self.STATUS, locked=False).payload)
598
+ state, start, trig, fn = rd.take("BQQH")
599
+ rd.tail()
492
600
  none = self.NO_TIME
493
601
  return GroupStatus(state, None if start == none else start, None if trig == none else trig, fn or None)
494
602