oep-client-python 0.0.34__py3-none-any.whl → 0.0.36__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.34"
5
+ __version__ = "0.0.36"
oep_client/capture.py CHANGED
@@ -35,19 +35,26 @@ from __future__ import annotations
35
35
  import struct
36
36
  import time
37
37
  import zipfile
38
+ from typing import NamedTuple
38
39
  from dataclasses import dataclass, field
39
40
  from fractions import Fraction
40
41
 
41
- from . import cobs, host as h, message as m, registry as reg
42
+ from . import capture_blocks, cobs, host as h, message as m, registry as reg
42
43
  from .frames import FramingLost
43
- from .core import Interface, confirm
44
+ from .core import Interface, confirm, describe
44
45
 
45
46
  _CAP = reg.FIXTURE_LOGIC
46
47
  _ANA = reg.FIXTURE_ANALOG
47
48
  _GRP = reg.FIXTURE_CAPTURE_GROUP
48
49
  # configure TLVs; bit 7 of a tag = critical (the probe must reject what it cannot do)
49
- MODE, RATE, SAMPLES, SEGMENTS, TRIGGER, PRETRIGGER = (
50
- _CAP.tlv["configure"][k] for k in ("mode", "rate", "samples", "segments", "trigger", "pretrigger"))
50
+ MODE, RATE, SAMPLES, SEGMENTS, TRIGGER, PRETRIGGER, TRIGGER_OPTS, FORMAT, CARRIAGE, BLOCK_MAX = (
51
+ _CAP.tlv["configure"][k] for k in ("mode", "rate", "samples", "segments", "trigger", "pretrigger", "trigger_opts",
52
+ "format", "carriage", "block_max"))
53
+ ANSWER_FORMAT, ANSWER_BLOCK = _CAP.tlv["configure_answer"]["format"], _CAP.tlv["configure_answer"]["block"]
54
+ STREAM_BUFFER = _CAP.tlv["configure_answer"]["stream_buffer"]
55
+ JUDGE_LATENCY = _CAP.tlv["configure_answer"]["judge_latency_ns"]
56
+ DATA_FORMAT = _CAP.enum["data_format"]
57
+ LOGIC_CARRIAGE = _CAP.enum["logic_carriage"]
51
58
  FRONTEND = _ANA.tlv["configure"]["frontend"] # analog only
52
59
  ACTUAL_RATE, LAYOUT, ACTUAL_SAMPLES, ACTUAL_SEGMENTS, SCALE, SKEW, FRONTEND_USED, REFERENCE = (
53
60
  _ANA.tlv["configure_answer"][k] for k in (
@@ -60,7 +67,8 @@ REFERENCE_SOURCE = {v: k for k, v in _ANA.enum["reference_source"].items()}
60
67
  REFERENCE_MEASURED = {_ANA.enum["reference_how"]["nominal"]: False, _ANA.enum["reference_how"]["measured"]: True}
61
68
  CRITICAL = m.TAG_CRITICAL
62
69
  ONE_SHOT, REPEAT, STREAMING = (_CAP.enum["mode"][k] for k in ("one_shot", "repeat", "streaming"))
63
- IMMEDIATE, LEVEL, EDGE, CROSS_UP, CROSS_DOWN = range(5)
70
+ IMMEDIATE, LEVEL, EDGE, LINKED = (_CAP.enum["trigger"][k] for k in ("immediate", "level", "edge", "linked"))
71
+ CROSS_UP, CROSS_DOWN = _ANA.enum["trigger"]["cross_up"], _ANA.enum["trigger"]["cross_down"]
64
72
  STATE = _CAP.enum["state"]
65
73
  SEGMENT_SLIPPED = _CAP.enum["segment_flag"]["slipped"]
66
74
  STATUS_FLAGS = _CAP.enum["status_flag"] # dropped, slipped (reset at start)
@@ -123,6 +131,7 @@ class Status:
123
131
  flags: int # bit0 dropped, bit1 slipped - since start
124
132
  generation: int
125
133
  error: int | None = None # state 6: why (ERRORS)
134
+ named: dict[str, bytes] = field(default_factory=dict) # named extensions (core §2.2): a probe's own diagnostics
126
135
 
127
136
  def __iter__(self):
128
137
  return iter((self.state, self.serial_done, self.write_pos, self.flags))
@@ -157,6 +166,21 @@ class Config:
157
166
  frontend: dict[int, int] = field(default_factory=dict) # analog: the frontend each channel took
158
167
  reference: tuple[str, int, bool | str] | None = None # analog: (source, mV, measured; an unknown how as str)
159
168
  pretrigger: int = 0 # what this host asked
169
+ format: int = 0 # the data format in use (§1; absent: 0)
170
+ block: int = 0 # format 1 / 2: L
171
+ stream_buffer: int | None = None # streaming: the most bytes held unsent (§3.3; None: not said)
172
+ judge_latency_ns: int | None = None # judging a group's trigger source condition, the longest to know it (§3.3, §4.4)
173
+ raw_roles: list[int] = field(default_factory=list) # format 1: the raw channels' roles (as asked)
174
+ event_d: dict[int, int] = field(default_factory=dict) # format 1: events role -> D (as asked)
175
+
176
+ def tolerable_delay_s(self, worst_bytes_per_s: float | None = None) -> float | None:
177
+ """Streaming: how long the host may stop reading before the probe drops (§3.3: stream_buffer over the data rate;
178
+ formats 1 / 2: pass their worst bandwidth, a lower bound). None when the probe does not say."""
179
+ if self.stream_buffer is None:
180
+ return None
181
+ rate = worst_bytes_per_s if worst_bytes_per_s is not None else float(self.rate) * (
182
+ self.width / 8 if self.width else len(self.order) * self.slot / 8)
183
+ return self.stream_buffer / rate if rate else None
160
184
 
161
185
  def segment_bytes(self, samples: int) -> int:
162
186
  """Length of a segment of `samples` samples in the stream (§1.1 rule 4, §1.2 rule 3)."""
@@ -272,7 +296,7 @@ def take_pushes(link, fn: int) -> list[tuple[int, int, bytes, int | None, Segmen
272
296
  class Event:
273
297
  """A capture event (oep-if-capture §3.4, §4.2; core §10.2 role 0x03). `generation`: the start it was made in - a
274
298
  track's for a track's event, the group's for the group's stopped. kind "segment" has `segment`; "stopped" has
275
- `reason` and `error`; "triggered" has `serial`, `trigger_index` and `trigger_ns`. An event of another kind (or a
299
+ `reason` and `error`; "triggered" has `serial`, `trigger_index`, `record`, `sample`, `trigger_ns` and `uncertainty_ns`. An event of another kind (or a
276
300
  group's other than stopped) keeps its fixed part in `raw` and generation None."""
277
301
  fn: int
278
302
  seq: int
@@ -284,6 +308,10 @@ class Event:
284
308
  serial: int | None = None
285
309
  trigger_index: int | None = None
286
310
  trigger_ns: int | None = None
311
+ record: int | None = None # triggered: the trigger record's serial (None: no record, §3.10)
312
+ sample: int | None = None # triggered: the original sample from the generation's start
313
+ uncertainty_ns: int | None = None # triggered: trigger_ns's +-ns
314
+ trigger_fn: int | None = None # a group's triggered: the track that judged it (0: force, timer)
287
315
  raw: bytes = b""
288
316
 
289
317
  @property
@@ -309,9 +337,14 @@ def unpack_event(frame: bytes, group: bool = False) -> Event:
309
337
  elif kind == EVENT_STOPPED:
310
338
  reason, error, generation = rd.take("BBI")
311
339
  e = Event(fn, seq, "stopped", generation, reason=reason, error=error)
340
+ elif kind == EVENT_TRIGGERED and group: # §4.4: trigger_fn record trigger_ns uncertainty generation
341
+ trigger_fn, record, trigger_ns, uncertainty, generation = rd.take("HIQII")
342
+ e = Event(fn, seq, "triggered", generation, trigger_ns=trigger_ns, uncertainty_ns=uncertainty,
343
+ record=None if record == NO_RECORD else record, trigger_fn=trigger_fn)
312
344
  elif kind == EVENT_TRIGGERED and not group:
313
- serial, index, trigger_ns, generation = rd.take("IIQI")
314
- e = Event(fn, seq, "triggered", generation, serial=serial, trigger_index=index, trigger_ns=trigger_ns)
345
+ serial, index, record, sample, trigger_ns, uncertainty, generation = rd.take("IIIQQII")
346
+ e = Event(fn, seq, "triggered", generation, serial=serial, trigger_index=index, trigger_ns=trigger_ns,
347
+ record=None if record == NO_RECORD else record, sample=sample, uncertainty_ns=uncertainty)
315
348
  else:
316
349
  return Event(fn, seq, f"kind 0x{kind:02x}", None, raw=bytes(body))
317
350
  except m.ShortPayload as err:
@@ -320,6 +353,73 @@ def unpack_event(frame: bytes, group: bool = False) -> Event:
320
353
  return e
321
354
 
322
355
 
356
+ NO_RECORD = 0xFFFFFFFF
357
+ TRIGGER_RECORD_BYTES = 24 # serial u32, sample u64, trigger_ns u64, uncertainty_ns u32 (§3.10)
358
+
359
+
360
+ CAPTURE_DROPS = "io.github.open-embedded-probe.capture-drops"
361
+
362
+
363
+ @dataclass(frozen=True)
364
+ class CaptureDrops:
365
+ """The Arduino reference firmware's own diagnostics in a capture's status (named extension CAPTURE_DROPS, not an OEP
366
+ definition): where data a stream dropped went, since start - bytes no free stage took, bytes the transport would not
367
+ queue, DMA chunks rewritten before they were copied (a hole: the track stops in error), chunks the harvest queue had
368
+ no room for - then the way the data goes (1 the zero-copy stages, 0 the store), the stages held, and the memory a
369
+ capture draws on: internal RAM's and DMA-capable internal RAM's largest free blocks, PSRAM's total (0: none); then
370
+ the most the stream's buffer (the DMA ring, in PSRAM on a P4) held not yet sent since start (bytes; None from a
371
+ firmware before it)."""
372
+ no_stage_bytes: int
373
+ unqueued_bytes: int
374
+ dma_overruns: int
375
+ queue_overflows: int
376
+ zero_copy: bool
377
+ stages: int
378
+ internal_largest: int
379
+ dma_largest: int
380
+ psram_total: int
381
+ buffer_peak: int | None = None
382
+
383
+ FORMAT = "<IIIIBBIIII"
384
+ FORMAT_BEFORE_PEAK = "<IIIIBBIII" # a firmware before buffer_peak (until 2026-10-20)
385
+
386
+ @classmethod
387
+ def unpack(cls, data: bytes) -> "CaptureDrops | None":
388
+ """-> its fields, or None for data of another length (a form this host does not know)."""
389
+ for form in (cls.FORMAT, cls.FORMAT_BEFORE_PEAK):
390
+ if len(data) == struct.calcsize(form):
391
+ f = struct.unpack(form, data)
392
+ return cls(*f[:4], bool(f[4]), *f[5:])
393
+ return None
394
+
395
+ @classmethod
396
+ def of(cls, status: "Status") -> "CaptureDrops | None":
397
+ data = status.named.get(CAPTURE_DROPS)
398
+ return None if data is None else cls.unpack(data)
399
+
400
+
401
+ @dataclass(frozen=True)
402
+ class TriggerOpts:
403
+ """configure's trigger_opts (§3.3): `arm` 1 looks once the pretrigger is there (0: at once, a short segment);
404
+ `rearm` 1 a trigger for every segment (streaming: a record for every event, §2.1, §3.10); `when` 1 a level
405
+ already true at arming counts; `hysteresis` (analog cross, in ADC values) back that far before the next crossing.
406
+ The defaults are every probe's; the others as describe's trigger opts says."""
407
+ arm: int = 1
408
+ rearm: int = 0
409
+ when: int = 0
410
+ hysteresis: int = 0
411
+
412
+
413
+ @dataclass(frozen=True)
414
+ class TriggerRecord:
415
+ """A trigger record (§3.10): serial (from 0 in the generation), the original sample from the generation's start,
416
+ trigger_ns and its uncertainty."""
417
+ serial: int
418
+ sample: int
419
+ trigger_ns: int
420
+ uncertainty_ns: int
421
+
422
+
323
423
  def take_events(link, fn: int) -> list[bytes]:
324
424
  """Remove this fn's event frames (role 0x03) from the link, oldest first."""
325
425
  mine, rest = [], []
@@ -337,7 +437,8 @@ def _config(payload: bytes, analog: bool) -> Config:
337
437
  c = Config()
338
438
  seen: set[int] = set()
339
439
  for tag, v in tlvs(payload):
340
- if tag in (ACTUAL_RATE, LAYOUT, ACTUAL_SAMPLES, ACTUAL_SEGMENTS, REFERENCE):
440
+ if tag in (ACTUAL_RATE, LAYOUT, ACTUAL_SAMPLES, ACTUAL_SEGMENTS, REFERENCE, ANSWER_FORMAT, ANSWER_BLOCK, STREAM_BUFFER,
441
+ JUDGE_LATENCY):
341
442
  if tag in seen:
342
443
  continue
343
444
  seen.add(tag)
@@ -350,6 +451,14 @@ def _config(payload: bytes, analog: bool) -> Config:
350
451
  elif tag == LAYOUT and analog and len(v) >= 4 and len(v) == 4 + v[3]:
351
452
  c.slot, c.offset, c.bits, n = v[0], v[1], v[2], v[3]
352
453
  c.order = list(v[4:4 + n])
454
+ elif tag == ANSWER_FORMAT and len(v) == 1:
455
+ c.format = v[0]
456
+ elif tag == ANSWER_BLOCK and len(v) == 4:
457
+ c.block = struct.unpack("<I", v)[0]
458
+ elif tag == STREAM_BUFFER and len(v) == 4:
459
+ c.stream_buffer = struct.unpack("<I", v)[0]
460
+ elif tag == JUDGE_LATENCY and len(v) == 4:
461
+ c.judge_latency_ns = struct.unpack("<I", v)[0]
353
462
  elif tag == ACTUAL_SAMPLES and len(v) == 4:
354
463
  c.samples = struct.unpack("<I", v)[0]
355
464
  elif tag == ACTUAL_SEGMENTS and len(v) == 4:
@@ -376,8 +485,9 @@ class LogicCapture(Interface):
376
485
  NAME = "oep.fixture.logic"
377
486
  REVISION = 1
378
487
  ANALOG = False
379
- CONFIGURE, START, STOP, FORCE, STATUS, READ, SEGMENTS, RELEASE, QUERY_OP = (
380
- _CAP.op[k] for k in ("configure", "start", "stop", "force", "status", "read", "segments", "release", "query"))
488
+ CONFIGURE, START, STOP, FORCE, STATUS, READ, SEGMENTS, RELEASE, QUERY_OP, TRIGGERS = (
489
+ _CAP.op[k] for k in ("configure", "start", "stop", "force", "status", "read", "segments", "release", "query",
490
+ "triggers"))
381
491
 
382
492
  generation: int | None = None # the current capture's generation (start / status / the group's start)
383
493
 
@@ -389,11 +499,17 @@ class LogicCapture(Interface):
389
499
 
390
500
  def configure(self, *, rate: int, mode: int = ONE_SHOT, samples: int | None = None, segments: int | None = None,
391
501
  trigger: tuple[int, int, int] | None = None, pretrigger: int | None = None, query: bool = False,
392
- frontends: dict[int, int] | None = None) -> Config:
502
+ frontends: dict[int, int] | None = None, trigger_opts: TriggerOpts | None = None,
503
+ data_format: int = 0, events: dict[int, int] | None = None, roles: list[int] | None = None,
504
+ block_max: int | None = None) -> Config:
393
505
  """-> the probe's actual values. A value the probe cannot honour is refused: host.Unsupported, .tag = the TLV as
394
506
  sent (§3.3). The TLVs go without the critical bit (§3.3). Read Config.samples / .segments: the probe rounds
395
507
  samples down.
396
508
 
509
+ Format 1 (logic blocks, §1.3): `data_format` DATA_FORMAT["logic_blocks"], `events` role -> D for the channels
510
+ carried as events (the others raw), `roles` the plan's roles (to name the raw channels), `block_max` the
511
+ longest block. Format 4 (test counter) needs nothing else. These go critical.
512
+
397
513
  §3.3's contract is checked before anything is sent (ValueError): `samples` is needed in one-shot and repeat
398
514
  and not taken in streaming, `segments` is repeat's only, `pretrigger` needs a trigger (type other than 0) - a
399
515
  pretrigger of 0 without one is simply not sent."""
@@ -426,10 +542,24 @@ class LogicCapture(Interface):
426
542
  body += tlv(PRETRIGGER, struct.pack("<I", pretrigger))
427
543
  for role, fe in sorted((frontends or {}).items()): # analog: the input range per channel (describe frontend)
428
544
  body += tlv(FRONTEND, bytes([role, fe]))
545
+ if data_format: # critical: a probe without it refuses (§3.3)
546
+ body += m.tlv(FORMAT | CRITICAL, bytes([data_format]))
547
+ if data_format == DATA_FORMAT["logic_blocks"] and roles is None:
548
+ raise ValueError("capture configure: format 1 needs the plan's roles (to name the raw channels)")
549
+ for role, d in sorted((events or {}).items()):
550
+ body += m.tlv(CARRIAGE | CRITICAL, struct.pack("<BBI", role, LOGIC_CARRIAGE["events"], d))
551
+ if block_max is not None:
552
+ body += m.tlv(BLOCK_MAX | CRITICAL, struct.pack("<I", block_max))
553
+ if trigger_opts is not None: # critical: a probe without it refuses (§3.3)
554
+ o = trigger_opts
555
+ body += m.tlv(TRIGGER_OPTS | CRITICAL, struct.pack("<BBBI", o.arm, o.rearm, o.when, o.hysteresis))
429
556
  # query is its own operation: the lock is decided per operation, before the payload is looked at
430
557
  op = self.QUERY_OP if query else self.CONFIGURE
431
558
  c = _config(self._call(op, body, locked=not query).payload, self.ANALOG)
432
559
  c.pretrigger = pretrigger or 0
560
+ if data_format == DATA_FORMAT["logic_blocks"]:
561
+ c.event_d = dict(events or {})
562
+ c.raw_roles = [r for r in sorted(roles) if r not in c.event_d]
433
563
  if mode == ONE_SHOT and not c.segments:
434
564
  c.segments = 1 # one-shot's answer has no actual_segments (§3.3)
435
565
  if not query:
@@ -468,13 +598,15 @@ class LogicCapture(Interface):
468
598
  A position that does not follow the previous push is a probe-side drop (a gap); a seq that skips is a lost frame.
469
599
  A push of another generation than this capture's (a leftover of the start before, oep-if-capture §3.4) is
470
600
  dropped; each push's segment_info goes to `segments`. The subscription ends with the lock, so the lock is kept alive every `keepalive_s` while collecting (a
471
- stream longer than the lease otherwise stopped: 35 MB missing at the end of 10 s at 150 MHz with a 10 s lease)."""
601
+ stream longer than the lease otherwise stopped: 35 MB missing at the end of 10 s at 150 MHz with a 10 s lease) -
602
+ counted across calls with the same `into` (a host calling every 0.2 s lost its 30 s lease: each call began the
603
+ count again)."""
472
604
  if seconds is None and nbytes is None:
473
605
  raise ValueError("stream() needs seconds or nbytes")
474
606
  got = into or Received()
475
607
  deadline = time.monotonic() + seconds if seconds is not None else None
476
- kept = time.monotonic()
477
- expect_seq = getattr(got, "_seq", None)
608
+ kept = getattr(got, "_kept", None) or time.monotonic() # on from the last call with the same `into`: a host
609
+ expect_seq = getattr(got, "_seq", None) # calling in short slices still keeps the lease
478
610
  event_seqs = getattr(got, "_events", set()) # events share the fn's seq (they stay on the link for the caller)
479
611
  while True:
480
612
  for e in link.events:
@@ -506,6 +638,7 @@ class LogicCapture(Interface):
506
638
  if keepalive_s and time.monotonic() - kept >= keepalive_s:
507
639
  self.host.keepalive()
508
640
  kept = time.monotonic()
641
+ got._kept = kept
509
642
  if nbytes is not None and len(got.data) >= nbytes:
510
643
  return got
511
644
  if deadline is not None and time.monotonic() >= deadline:
@@ -559,9 +692,11 @@ class LogicCapture(Interface):
559
692
  generation a host that did not start the capture needs for read."""
560
693
  rd = m.Reader(self._call(self.STATUS, locked=False).payload)
561
694
  state, done, pos, flags, generation = rd.take("BIQBI")
562
- error = rd.tail().get(STATUS_ERROR) # u8: another length is broken, not used (core §2.3)
695
+ tail = rd.tail()
696
+ error = tail.get(STATUS_ERROR) # u8: another length is broken, not used (core §2.3)
563
697
  self.generation = generation
564
- return Status(state, done, pos, flags, generation, error[0] if error is not None and len(error) == 1 else None)
698
+ return Status(state, done, pos, flags, generation, error[0] if error is not None and len(error) == 1 else None,
699
+ tail.named())
565
700
 
566
701
  def release(self, serial: int, generation: int | None = None) -> None:
567
702
  """Repeat: segments up to and including `serial` may be reused (of this generation; another one is rejected
@@ -584,6 +719,25 @@ class LogicCapture(Interface):
584
719
  rd.tail()
585
720
  return out, bool(more)
586
721
 
722
+ def triggers_page(self, from_serial: int = 0, generation: int | None = None) -> tuple[list[TriggerRecord], bool]:
723
+ """One answer's trigger records (§3.10) from `from_serial` on, paged as segments are. -> (records, more)."""
724
+ payload = struct.pack("<II", self._generation(generation), from_serial)
725
+ rd = m.Reader(self._call(self.TRIGGERS, payload, locked=False).payload)
726
+ more, count = rd.take("BB")
727
+ out = [TriggerRecord(*struct.unpack("<IQQI", rd.bytes(TRIGGER_RECORD_BYTES))) for _ in range(count)]
728
+ rd.tail()
729
+ return out, bool(more)
730
+
731
+ def triggers(self, from_serial: int = 0, generation: int | None = None) -> list[TriggerRecord]:
732
+ """Every trigger record kept from `from_serial` on; a jump in the serials is what was overwritten."""
733
+ out: list[TriggerRecord] = []
734
+ while True:
735
+ page, more = self.triggers_page(from_serial, generation)
736
+ out += page
737
+ if not more or not page:
738
+ return out
739
+ from_serial = (page[-1].serial + 1) & 0xFFFFFFFF
740
+
587
741
  def segments(self, from_serial: int = 0) -> list[Segment]:
588
742
  """Every segment record from `from_serial` on, following `more` (each next page from the last serial + 1,
589
743
  until more 0)."""
@@ -678,14 +832,53 @@ class LogicCapture(Interface):
678
832
  """The segment's bytes (of its generation); every Host.on_capture callback gets them as a CaptureRecord (a run
679
833
  recorder, e.g. pytest-embedded-wireskein, without this package knowing it)."""
680
834
  c = self.config
681
- n = c.segment_bytes(segment.samples)
682
- data = self.read(segment.position, n, segment.generation or None)
835
+ if c.format == DATA_FORMAT["logic_blocks"]:
836
+ data = self._read_blocks(segment)
837
+ else:
838
+ data = self.read(segment.position, c.segment_bytes(segment.samples), segment.generation or None)
683
839
  if self.host.on_capture:
684
840
  record = CaptureRecord(self.fn, self.name, c, segment, data, self.armed_s, time.monotonic())
685
841
  for callback in list(self.host.on_capture):
686
842
  callback(record)
687
843
  return data
688
844
 
845
+ def _read_blocks(self, segment: Segment) -> bytes:
846
+ """A format 1 segment's bytes: its length is not in its record (§1.3), so it ends where the next segment
847
+ begins, or at write_pos for the last; read at once, then cut by its blocks' headers."""
848
+ after = [s.position for s in self.segments((segment.serial + 1) & 0xFFFFFFFF)
849
+ if s.generation == segment.generation and s.position > segment.position]
850
+ end = after[0] if after else self.status().write_pos
851
+ data = self.read(segment.position, end - segment.position, segment.generation or None)
852
+ return data[:self._blocks_length(data, segment.samples)]
853
+
854
+ def _blocks_length(self, data: bytes, samples: int) -> int:
855
+ """The bytes the blocks of `samples` samples take at the start of `data` (from their headers, §1.3)."""
856
+ c = self.config
857
+ nb = (len(c.event_d) + 7) // 8
858
+ at = 0
859
+ for first in range(0, samples, c.block):
860
+ r = min(c.block, samples - first)
861
+ head = data[at:at + 2 * nb + 4]
862
+ if len(head) < 2 * nb + 4:
863
+ raise h.ProtocolError(f"format 1: the segment's bytes end inside block {first // c.block}")
864
+ fallbacks = bin(int.from_bytes(head[:nb], "little")).count("1")
865
+ n = int.from_bytes(head[2 * nb:], "little")
866
+ at += len(head) + (r * (len(c.raw_roles) + fallbacks) + 7) // 8 + capture_blocks.EVENT_BYTES * n
867
+ if at > len(data):
868
+ raise h.ProtocolError("format 1: the segment's bytes end inside its last block")
869
+ return at
870
+
871
+ def blocks(self, data: bytes, samples: int) -> list[capture_blocks.LogicBlock]:
872
+ """A format 1 segment's bytes -> its blocks (raw channels as bits, events channels as ticks, §1.3)."""
873
+ c = self.config
874
+ return capture_blocks.read_logic_blocks(data, samples, c.raw_roles, c.event_d, c.block)
875
+
876
+ def columns(self, data: bytes, samples: int) -> dict:
877
+ """A format 1 segment's bytes -> role -> its raw bits or its EventColumn over the whole segment, without blocks
878
+ (capture_blocks.segment_columns; needs numpy)."""
879
+ c = self.config
880
+ return capture_blocks.segment_columns(data, samples, c.raw_roles, c.event_d, c.block)
881
+
689
882
  # ---- the §3.0 layout ---------------------------------------------------------------------------------
690
883
  def channel(self, data: bytes, k: int, samples: int | None = None) -> list[int]:
691
884
  """Channel k's values, one per sample (§1.1 rules 1-3: bit i*w + pos[k] of the stream, bit j being bit j mod 8
@@ -811,6 +1004,119 @@ class GroupStatus:
811
1004
  state: int
812
1005
  start_ns: int | None # None before the group's first start
813
1006
  generation: int = 0 # the group's (§4.1; 0 before its first start)
1007
+ trigger: tuple[int, int, int] | None = None # §4.4, once the event happened: (fn judging, 0 force / timer; ns; +-ns)
1008
+
1009
+
1010
+ GROUP_BIND = _GRP.tlv["bind"]
1011
+ COND = _GRP.enum["trigger_cond"]
1012
+ COND_SRC = _GRP.enum["cond_src"]
1013
+
1014
+
1015
+ @dataclass(frozen=True)
1016
+ class Cond:
1017
+ """A condition of the trigger source (§4.4): built with level() / edge() / cross_up() / cross_down() / above() /
1018
+ below() / timer(). `fn` the bound track (0 for timer), `role` its plan role, `kind` enum trigger_cond, `args` the
1019
+ type's arguments packed."""
1020
+ fn: int
1021
+ role: int
1022
+ kind: int
1023
+ args: bytes
1024
+ src: int = COND_SRC["track"]
1025
+
1026
+ @staticmethod
1027
+ def level(track: "LogicCapture", role: int, level: int, when: int = 0) -> "Cond":
1028
+ return Cond(track.fn, role, COND["level"], struct.pack("<BB", level, when))
1029
+
1030
+ @staticmethod
1031
+ def edge(track: "LogicCapture", role: int, edge: int = 0) -> "Cond":
1032
+ """edge 0 rising, 1 falling, 2 both."""
1033
+ return Cond(track.fn, role, COND["edge"], struct.pack("<B", edge))
1034
+
1035
+ @staticmethod
1036
+ def cross_up(track: "LogicCapture", role: int, value: int, hysteresis: int = 0) -> "Cond":
1037
+ return Cond(track.fn, role, COND["cross_up"], struct.pack("<II", value, hysteresis))
1038
+
1039
+ @staticmethod
1040
+ def cross_down(track: "LogicCapture", role: int, value: int, hysteresis: int = 0) -> "Cond":
1041
+ return Cond(track.fn, role, COND["cross_down"], struct.pack("<II", value, hysteresis))
1042
+
1043
+ @staticmethod
1044
+ def above(track: "LogicCapture", role: int, value: int, hysteresis: int = 0, when: int = 0) -> "Cond":
1045
+ return Cond(track.fn, role, COND["above"], struct.pack("<IIB", value, hysteresis, when))
1046
+
1047
+ @staticmethod
1048
+ def below(track: "LogicCapture", role: int, value: int, hysteresis: int = 0, when: int = 0) -> "Cond":
1049
+ return Cond(track.fn, role, COND["below"], struct.pack("<IIB", value, hysteresis, when))
1050
+
1051
+ @staticmethod
1052
+ def timer(ns: int) -> "Cond":
1053
+ """ns after the stage was entered."""
1054
+ return Cond(0, 0, COND["timer"], struct.pack("<Q", ns), COND_SRC["track"])
1055
+
1056
+
1057
+ @dataclass(frozen=True)
1058
+ class Stage:
1059
+ """A stage (§4.4): its terms are OR'd, a term's conditions AND'd; `count` times the OR becomes true; `within_ns`
1060
+ from entering it (0 none); `on_timeout` 0 back to stage 0 when it passes, 1 the "should come" stage (its OR in
1061
+ time goes back to stage 0, the time passing makes it hold). `aborts`: terms that send it back to stage 0."""
1062
+ terms: list[list[Cond]]
1063
+ count: int = 1
1064
+ within_ns: int = 0
1065
+ on_timeout: int = 0
1066
+ aborts: list[list[Cond]] = field(default_factory=list)
1067
+
1068
+
1069
+ @dataclass(frozen=True)
1070
+ class TriggerSource:
1071
+ """The group's trigger source (§4.4): stages in order, the last one holding is the event."""
1072
+ stages: list[Stage]
1073
+ holdoff_ns: int = 0
1074
+ arm: int = 1
1075
+ rearm: int = 0
1076
+
1077
+ def tlvs(self) -> bytes:
1078
+ """The bind TLVs, every one critical (§4.4)."""
1079
+ out = m.tlv(GROUP_BIND["trigger_source"] | CRITICAL,
1080
+ struct.pack("<BQBB", len(self.stages), self.holdoff_ns, self.arm, self.rearm))
1081
+ for k, st in enumerate(self.stages):
1082
+ out += m.tlv(GROUP_BIND["stage"] | CRITICAL, struct.pack("<BIQB", k, st.count, st.within_ns, st.on_timeout))
1083
+ for part, terms in ((0, st.terms), (1, st.aborts)):
1084
+ for term, conds in enumerate(terms):
1085
+ for c in conds:
1086
+ out += m.tlv(GROUP_BIND["cond"] | CRITICAL,
1087
+ struct.pack("<BBBBHBB", k, part, term, c.src, c.fn, c.role, c.kind) + c.args)
1088
+ return out
1089
+
1090
+
1091
+ class TriggerCaps(NamedTuple):
1092
+ """capture-group describe's trigger_caps (§4.4, tag 0x41): what its trigger source takes - a declaration (core §7.3),
1093
+ bind's answer is the probe's word. `types`: the condition types (enum trigger_cond, bit k = type k); `protocols`:
1094
+ serial's (bit k = protocol k); trigger_out_latency_ns None: no trigger_out."""
1095
+ max_stages: int
1096
+ max_terms: int
1097
+ max_conds: int
1098
+ cross_track: bool
1099
+ types: int
1100
+ protocols: int
1101
+ trigger_out_latency_ns: int | None
1102
+
1103
+ FORMAT = "<BBBBIBI"
1104
+
1105
+ @classmethod
1106
+ def unpack(cls, v: bytes) -> "TriggerCaps | None":
1107
+ """-> its fields, or None for a value of another length (a form this host does not know)."""
1108
+ if len(v) != struct.calcsize(cls.FORMAT):
1109
+ return None
1110
+ st, te, co, ct, types, prot, lat = struct.unpack(cls.FORMAT, v)
1111
+ return cls(st, te, co, bool(ct), types, prot, None if lat == 0xFFFFFFFF else lat)
1112
+
1113
+ def takes(self, kind: int) -> bool:
1114
+ """Whether the condition type `kind` (COND[...]) is declared."""
1115
+ return 0 <= kind < 32 and bool(self.types >> kind & 1)
1116
+
1117
+ def kinds(self) -> list[str]:
1118
+ """The declared condition types by name (COND), in number order."""
1119
+ return [name for name, k in sorted(COND.items(), key=lambda kv: kv[1]) if self.takes(k)]
814
1120
 
815
1121
 
816
1122
  class CaptureGroup(Interface):
@@ -820,19 +1126,36 @@ class CaptureGroup(Interface):
820
1126
  uncertainty place it)."""
821
1127
  NAME = "oep.fixture.capture-group"
822
1128
  REVISION = 1
823
- BIND, START, STOP, STATUS = (_GRP.op[k] for k in ("bind", "start", "stop", "status"))
1129
+ BIND, START, STOP, STATUS, FORCE = (_GRP.op[k] for k in ("bind", "start", "stop", "status", "force"))
824
1130
  EVENT_STOPPED = _GRP.event["stopped"]
825
1131
  NO_TIME = 0xFFFFFFFFFFFFFFFF
826
1132
 
827
- def bind(self, tracks: list[LogicCapture]) -> None:
828
- """Bind these (configured, immediate-trigger) tracks; [] unbinds. A track with a trigger of its own is refused
829
- here before sending (ValueError, §4.1); the probe refuses a track not in state 1/4/6 or modes apart
830
- (unavailable cause 6, `.fn`), one in another group (cause 4) and more than it takes together (cause 2)."""
1133
+ def bind(self, tracks: list[LogicCapture], source: TriggerSource | None = None) -> None:
1134
+ """Bind these configured tracks; [] unbinds. Without `source` they start at once (immediate triggers): a track
1135
+ with a trigger of its own is refused here before sending (ValueError, §4.1). With `source` (§4.4, describe
1136
+ features bit 0) every track has the linked trigger (with its own pretrigger) and the group's event starts them.
1137
+ The probe refuses a track not in state 1/4/6 or modes apart (unavailable cause 6, `.fn`), one in another group
1138
+ (cause 4) and more than it takes together (cause 2)."""
831
1139
  for t in tracks:
832
- if t.config is not None and t.config.pretrigger:
1140
+ if source is None and t.config is not None and t.config.pretrigger:
833
1141
  raise ValueError(f"capture-group bind: fn {t.fn} has a trigger of its own - a group starts its tracks "
834
1142
  "at once (oep-if-capture §4.1)")
835
- self._call(self.BIND, struct.pack(f"<B{len(tracks)}H", len(tracks), *(t.fn for t in tracks)))
1143
+ self._call(self.BIND, struct.pack(f"<B{len(tracks)}H", len(tracks), *(t.fn for t in tracks))
1144
+ + (source.tlvs() if source is not None else b""))
1145
+
1146
+ def force(self) -> None:
1147
+ """§4.4: waiting for the event, it happens now (nothing otherwise)."""
1148
+ self._call(self.FORCE)
1149
+
1150
+ TRIGGER_CAPS = _GRP.tlv["describe"]["trigger_caps"]
1151
+
1152
+ def trigger_caps(self) -> TriggerCaps | None:
1153
+ """describe's trigger_caps (§4.4): what the group's trigger source takes; None when the probe declares none (no
1154
+ trigger source, or a form this host does not know). A declaration: bind still has the last word (§4.3)."""
1155
+ for tag, v in describe(self.host, self.fn):
1156
+ if tag == self.TRIGGER_CAPS:
1157
+ return TriggerCaps.unpack(v)
1158
+ return None
836
1159
 
837
1160
  generation: int | None = None # the group's current generation (start / status, §4.1)
838
1161
  stale_events: int = 0 # the group's events of an earlier generation that events() dropped
@@ -869,9 +1192,11 @@ class CaptureGroup(Interface):
869
1192
  def status(self) -> GroupStatus:
870
1193
  rd = m.Reader(self._call(self.STATUS, locked=False).payload)
871
1194
  state, start, generation = rd.take("BQI")
872
- rd.tail()
1195
+ tail = rd.tail()
873
1196
  self.generation = generation
874
- return GroupStatus(state, None if start == self.NO_TIME else start, generation)
1197
+ trig = tail.get(_GRP.tlv["status_answer"]["trigger"])
1198
+ trigger = struct.unpack("<HQI", trig) if trig is not None and len(trig) == 14 else None
1199
+ return GroupStatus(state, None if start == self.NO_TIME else start, generation, trigger)
875
1200
 
876
1201
  def events(self, link) -> list[Event]:
877
1202
  """The group's own events waiting on the link (taken off it; stopped, §4.2), the current group generation's