oep-client-python 0.0.1__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 +5 -0
- oep_client/v1/__init__.py +1 -0
- oep_client/v1/__main__.py +40 -0
- oep_client/v1/arm.py +269 -0
- oep_client/v1/capture.py +365 -0
- oep_client/v1/catalog.py +181 -0
- oep_client/v1/ch32_flash.py +254 -0
- oep_client/v1/cobs.py +73 -0
- oep_client/v1/console.py +165 -0
- oep_client/v1/core.py +172 -0
- oep_client/v1/decode.py +65 -0
- oep_client/v1/dump.py +157 -0
- oep_client/v1/endpoint.py +1479 -0
- oep_client/v1/esp32_targets.py +137 -0
- oep_client/v1/fake.py +233 -0
- oep_client/v1/fake_serial.py +105 -0
- oep_client/v1/fake_serve.py +299 -0
- oep_client/v1/fixture.py +121 -0
- oep_client/v1/frames.py +91 -0
- oep_client/v1/hid_stream.py +257 -0
- oep_client/v1/host.py +321 -0
- oep_client/v1/interfaces.py +119 -0
- oep_client/v1/link.py +414 -0
- oep_client/v1/message.py +235 -0
- oep_client/v1/names.py +86 -0
- oep_client/v1/registry.py +59 -0
- oep_client/v1/riscv.py +430 -0
- oep_client/v1/rp2350.py +85 -0
- oep_client/v1/target.py +16 -0
- oep_client/v1/uiapduino.py +121 -0
- oep_client/v1/usb_stream.py +209 -0
- oep_client_python-0.0.1.dist-info/METADATA +106 -0
- oep_client_python-0.0.1.dist-info/RECORD +36 -0
- oep_client_python-0.0.1.dist-info/WHEEL +4 -0
- oep_client_python-0.0.1.dist-info/entry_points.txt +2 -0
- oep_client_python-0.0.1.dist-info/licenses/LICENSE +21 -0
oep_client/v1/capture.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""oep.fixture.capture / oep.fixture.analog revision 1 (oep-spec docs/oep-if-capture.ja.md: §1 layouts, §2 segments,
|
|
2
|
+
§3 operations). Numbers from `registry`.
|
|
3
|
+
|
|
4
|
+
configure: a TLV the probe cannot honour is refused (rejected unsupported, 0x0B, payload = the tag) when the host marked
|
|
5
|
+
it critical, and otherwise ignored and listed in the answer's ignored TLV (0x7F, oep-core §2.3)."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import struct
|
|
10
|
+
import time
|
|
11
|
+
import zipfile
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from fractions import Fraction
|
|
14
|
+
|
|
15
|
+
from . import cobs, host as h, message as m, registry as reg
|
|
16
|
+
from .frames import FramingLost
|
|
17
|
+
from .core import Interface, confirm
|
|
18
|
+
|
|
19
|
+
_CAP = reg.FIXTURE_CAPTURE
|
|
20
|
+
# configure TLVs; bit 7 of a tag = critical (the probe must reject what it cannot do)
|
|
21
|
+
MODE, RATE, SAMPLES, SEGMENTS, TRIGGER, PRETRIGGER, FRONTEND = (
|
|
22
|
+
_CAP.tlv["configure"][k] for k in ("mode", "rate", "samples", "segments", "trigger", "pretrigger", "frontend"))
|
|
23
|
+
ACTUAL_RATE, LAYOUT, ACTUAL_SAMPLES, ACTUAL_SEGMENTS, TIMING, SCALE, BLOCKING = (
|
|
24
|
+
_CAP.tlv["configure_answer"][k] for k in ("actual_rate", "layout", "actual_samples", "actual_segments", "timing",
|
|
25
|
+
"scale", "blocking_ms"))
|
|
26
|
+
IGNORED = m.TAG_IGNORED
|
|
27
|
+
CRITICAL = m.TAG_CRITICAL
|
|
28
|
+
ONE_SHOT, REPEAT, STREAMING = (_CAP.enum["mode"][k] for k in ("one_shot", "repeat", "streaming"))
|
|
29
|
+
IMMEDIATE, LEVEL, EDGE, CROSS_UP, CROSS_DOWN = range(5)
|
|
30
|
+
STATE = _CAP.enum["state"]
|
|
31
|
+
SEGMENT_SLIPPED = _CAP.enum["segment_flag"]["slipped"]
|
|
32
|
+
# events (oep-core §11, role 0x05)
|
|
33
|
+
EVENT_SEGMENT, EVENT_STOPPED, EVENT_TRIGGERED = (_CAP.event[k] for k in ("segment", "stopped", "triggered"))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def tlvs(payload: bytes) -> list[tuple[int, bytes]]:
|
|
37
|
+
return m.split_tlvs(payload)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
SEGMENT_BYTES = 29 # serial u32, position u64, samples u32, start_us u64, trigger_index u32, flags u8
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Segment:
|
|
45
|
+
serial: int
|
|
46
|
+
position: int
|
|
47
|
+
samples: int
|
|
48
|
+
start_us: int
|
|
49
|
+
trigger_index: int | None
|
|
50
|
+
flags: int
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def slipped(self) -> bool:
|
|
54
|
+
"""flags bit2: the time base bent inside the segment (samples later than the timing answer, oep-if-capture §2)."""
|
|
55
|
+
return bool(self.flags & SEGMENT_SLIPPED)
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def unpack(cls, b: bytes) -> "Segment":
|
|
59
|
+
serial, position, samples, start_us, trig, flags = struct.unpack_from("<IQIQIB", b)
|
|
60
|
+
return cls(serial, position, samples, start_us, None if trig == 0xFFFFFFFF else trig, flags)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class Config:
|
|
65
|
+
"""What configure answered: the probe's actual values (logic-capture §5.3)."""
|
|
66
|
+
rate: Fraction = Fraction(0)
|
|
67
|
+
width: int = 0 # logic: bits per sample (w)
|
|
68
|
+
positions: list[int] = field(default_factory=list) # logic: bit of channel k within a sample
|
|
69
|
+
slot: int = 0 # analog: s, o, b, order
|
|
70
|
+
offset: int = 0
|
|
71
|
+
bits: int = 0
|
|
72
|
+
order: list[int] = field(default_factory=list)
|
|
73
|
+
samples: int = 0
|
|
74
|
+
segments: int = 0
|
|
75
|
+
jitter_kind: int = 0
|
|
76
|
+
jitter_ns: int = 0
|
|
77
|
+
skew_ns: list[int] = field(default_factory=list)
|
|
78
|
+
zero: int = 0
|
|
79
|
+
scale_nv: int = 0
|
|
80
|
+
blocking_ms: int = 0
|
|
81
|
+
ignored: list[int] = field(default_factory=list)
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def bytes(self) -> int:
|
|
85
|
+
"""Length of one segment in the stream (§3.0 rule 4)."""
|
|
86
|
+
if self.width:
|
|
87
|
+
return (self.samples * self.width + 7) // 8
|
|
88
|
+
return self.samples * len(self.order) * self.slot // 8
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class CaptureRecord:
|
|
93
|
+
"""One segment as it was read, for whoever records runs (Host.on_capture): the probe's own words - no pin names,
|
|
94
|
+
no target (the recorder's caller knows those). armed_s / read_s: time.monotonic() at start() and at the read."""
|
|
95
|
+
fn: int
|
|
96
|
+
name: str
|
|
97
|
+
config: Config
|
|
98
|
+
segment: Segment
|
|
99
|
+
data: bytes
|
|
100
|
+
armed_s: float | None
|
|
101
|
+
read_s: float
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class Received:
|
|
106
|
+
"""What stream() collected: the bytes in arrival order, where the stream skipped (probe-side drops) and lost frames."""
|
|
107
|
+
data: bytearray = field(default_factory=bytearray)
|
|
108
|
+
start: int | None = None # stream position of data[0]
|
|
109
|
+
gaps: list[tuple[int, int]] = field(default_factory=list) # (index in data where it skipped, bytes skipped)
|
|
110
|
+
seq_lost: int = 0 # push frames missing by seq
|
|
111
|
+
frames: int = 0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def take_pushes(link, fn: int) -> list[tuple[int, int, bytes]]:
|
|
115
|
+
"""Remove this fn's data pushes (role 0x06) from the link: [(seq, position, data)], oldest first."""
|
|
116
|
+
mine, rest = [], []
|
|
117
|
+
for f in link.pushes:
|
|
118
|
+
(mine if struct.unpack_from("<H", f, 1)[0] == fn else rest).append(f)
|
|
119
|
+
link.pushes.clear()
|
|
120
|
+
link.pushes.extend(rest)
|
|
121
|
+
# the core header is role fn seq; the capture's payload is position(u64) then data (standard position stream)
|
|
122
|
+
return [(struct.unpack_from("<H", f, 3)[0], struct.unpack_from("<Q", f, 5)[0], f[13:]) for f in mine]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _config(payload: bytes, analog: bool) -> Config:
|
|
126
|
+
c = Config()
|
|
127
|
+
for tag, v in tlvs(payload):
|
|
128
|
+
if tag == ACTUAL_RATE:
|
|
129
|
+
num, den = struct.unpack("<II", v)
|
|
130
|
+
c.rate = Fraction(num, den)
|
|
131
|
+
elif tag == LAYOUT and not analog:
|
|
132
|
+
c.width, n = v[0], v[1]
|
|
133
|
+
c.positions = list(v[2:2 + n])
|
|
134
|
+
elif tag == LAYOUT and analog:
|
|
135
|
+
c.slot, c.offset, c.bits, n = v[0], v[1], v[2], v[3]
|
|
136
|
+
c.order = list(v[4:4 + n])
|
|
137
|
+
elif tag == ACTUAL_SAMPLES:
|
|
138
|
+
c.samples = struct.unpack("<I", v)[0]
|
|
139
|
+
elif tag == ACTUAL_SEGMENTS:
|
|
140
|
+
c.segments = struct.unpack("<I", v)[0]
|
|
141
|
+
elif tag == TIMING:
|
|
142
|
+
c.jitter_kind, c.jitter_ns = v[0], struct.unpack_from("<I", v, 1)[0]
|
|
143
|
+
c.skew_ns = [struct.unpack_from("<I", v, 5 + 4 * i)[0] for i in range((len(v) - 5) // 4)]
|
|
144
|
+
elif tag == SCALE:
|
|
145
|
+
c.zero, c.scale_nv = struct.unpack("<II", v)
|
|
146
|
+
elif tag == BLOCKING:
|
|
147
|
+
c.blocking_ms = struct.unpack("<I", v)[0]
|
|
148
|
+
elif tag == IGNORED:
|
|
149
|
+
c.ignored = list(v)
|
|
150
|
+
return c
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class LogicCapture(Interface):
|
|
154
|
+
"""Basic logic capture. Channels are the plan's roles 0..C-1."""
|
|
155
|
+
NAME = "oep.fixture.capture"
|
|
156
|
+
REVISION = 1
|
|
157
|
+
ANALOG = False
|
|
158
|
+
CONFIGURE, START, STOP, FORCE, STATUS, READ, SEGMENTS, RELEASE, QUERY_OP = (
|
|
159
|
+
_CAP.op[k] for k in ("configure", "start", "stop", "force", "status", "read", "segments", "release", "query"))
|
|
160
|
+
|
|
161
|
+
def __init__(self, hst: h.Host, fn: int | None = None, name: str | None = None):
|
|
162
|
+
super().__init__(hst, name, fn=fn)
|
|
163
|
+
self.config: Config | None = None
|
|
164
|
+
self.armed_s: float | None = None # time.monotonic() at the last start()
|
|
165
|
+
|
|
166
|
+
def configure(self, *, rate: int, mode: int = ONE_SHOT, samples: int | None = None, segments: int | None = None,
|
|
167
|
+
trigger: tuple[int, int, int] | None = None, pretrigger: int | None = None, query: bool = False,
|
|
168
|
+
critical: set[int] = frozenset()) -> Config:
|
|
169
|
+
"""-> the probe's actual values (Config.ignored: tags the probe ignored). `critical`: tags the probe must honour
|
|
170
|
+
or reject (host.Unsupported, .tag = the one it cannot)."""
|
|
171
|
+
def tlv(tag: int, value: bytes) -> bytes:
|
|
172
|
+
return bytes([tag | (CRITICAL if tag in critical else 0), len(value)]) + value
|
|
173
|
+
body = tlv(MODE, bytes([mode])) + tlv(RATE, struct.pack("<I", rate))
|
|
174
|
+
if samples is not None:
|
|
175
|
+
body += tlv(SAMPLES, struct.pack("<I", samples))
|
|
176
|
+
if segments is not None:
|
|
177
|
+
body += tlv(SEGMENTS, struct.pack("<I", segments))
|
|
178
|
+
if trigger is not None:
|
|
179
|
+
body += tlv(TRIGGER, struct.pack("<BBH", *trigger))
|
|
180
|
+
if pretrigger is not None:
|
|
181
|
+
body += tlv(PRETRIGGER, struct.pack("<I", pretrigger))
|
|
182
|
+
# query is its own operation: the lock is decided per operation, before the payload is looked at
|
|
183
|
+
op = self.QUERY_OP if query else self.CONFIGURE
|
|
184
|
+
c = _config(self._call(op, body, locked=not query).payload, self.ANALOG)
|
|
185
|
+
if not query:
|
|
186
|
+
self.config = c
|
|
187
|
+
return c
|
|
188
|
+
|
|
189
|
+
def subscribe(self, min_bytes: int = 0, max_delay_ms: int = 0) -> None:
|
|
190
|
+
"""Events, and in streaming the data pushes (oep-core §11): send when min_bytes are ready or max_delay_ms after the
|
|
191
|
+
first byte (0, 0: as soon as there is anything)."""
|
|
192
|
+
self.host.subscribe(self.fn, min_bytes, max_delay_ms)
|
|
193
|
+
|
|
194
|
+
def unsubscribe(self) -> None:
|
|
195
|
+
self.host.unsubscribe(self.fn)
|
|
196
|
+
|
|
197
|
+
def stream(self, link, *, seconds: float | None = None, nbytes: int | None = None,
|
|
198
|
+
into: Received | None = None, keepalive_s: float = 1.0) -> Received:
|
|
199
|
+
"""Streaming: collect data pushes until `nbytes` have arrived or `seconds` have passed (at least one is needed).
|
|
200
|
+
A position that does not follow the previous push is a probe-side drop (a gap); a seq that skips is a lost frame.
|
|
201
|
+
The subscription ends with the lock, so the lock is kept alive every `keepalive_s` while collecting (a stream
|
|
202
|
+
longer than the lease otherwise stopped: 35 MB missing at the end of 10 s at 150 MHz with a 10 s lease)."""
|
|
203
|
+
if seconds is None and nbytes is None:
|
|
204
|
+
raise ValueError("stream() needs seconds or nbytes")
|
|
205
|
+
got = into or Received()
|
|
206
|
+
deadline = time.monotonic() + seconds if seconds is not None else None
|
|
207
|
+
kept = time.monotonic()
|
|
208
|
+
expect_seq = getattr(got, "_seq", None)
|
|
209
|
+
event_seqs = getattr(got, "_events", set()) # events share the fn's seq (they stay on the link for the caller)
|
|
210
|
+
while True:
|
|
211
|
+
for e in link.events:
|
|
212
|
+
if struct.unpack_from("<H", e, 1)[0] == self.fn:
|
|
213
|
+
event_seqs.add(struct.unpack_from("<H", e, 3)[0])
|
|
214
|
+
for seq, position, data in take_pushes(link, self.fn):
|
|
215
|
+
while expect_seq is not None and expect_seq != seq:
|
|
216
|
+
if expect_seq in event_seqs:
|
|
217
|
+
event_seqs.discard(expect_seq)
|
|
218
|
+
else:
|
|
219
|
+
got.seq_lost += 1
|
|
220
|
+
expect_seq = (expect_seq + 1) & 0xFFFF
|
|
221
|
+
expect_seq = (seq + 1) & 0xFFFF
|
|
222
|
+
if got.start is None:
|
|
223
|
+
got.start = position
|
|
224
|
+
else:
|
|
225
|
+
expected = got.start + len(got.data) + sum(n for _, n in got.gaps) # u64 positions: no wrap
|
|
226
|
+
skipped = position - expected
|
|
227
|
+
if skipped:
|
|
228
|
+
got.gaps.append((len(got.data), skipped))
|
|
229
|
+
got.data += data
|
|
230
|
+
got.frames += 1
|
|
231
|
+
got._seq, got._events = expect_seq, event_seqs
|
|
232
|
+
if keepalive_s and time.monotonic() - kept >= keepalive_s:
|
|
233
|
+
self.host.keepalive()
|
|
234
|
+
kept = time.monotonic()
|
|
235
|
+
if nbytes is not None and len(got.data) >= nbytes:
|
|
236
|
+
return got
|
|
237
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
238
|
+
return got
|
|
239
|
+
link.pump(0.02, until_one=True)
|
|
240
|
+
|
|
241
|
+
def finish(self, link, got: Received, timeout: float = 5.0) -> Received:
|
|
242
|
+
"""Streaming, after stop(): collect the pushes still to come, up to the last byte captured (status's write
|
|
243
|
+
position), or until `timeout`."""
|
|
244
|
+
end = self.status()[2]
|
|
245
|
+
deadline = time.monotonic() + timeout
|
|
246
|
+
while time.monotonic() < deadline:
|
|
247
|
+
if got.start is not None:
|
|
248
|
+
reached = got.start + len(got.data) + sum(n for _, n in got.gaps)
|
|
249
|
+
if reached == end:
|
|
250
|
+
return got
|
|
251
|
+
self.stream(link, seconds=min(0.1, max(0.0, deadline - time.monotonic())), into=got)
|
|
252
|
+
return got
|
|
253
|
+
|
|
254
|
+
def start(self) -> int:
|
|
255
|
+
"""-> blocking_ms (0: the probe keeps answering while it captures)."""
|
|
256
|
+
blocking = m.Reader(self._call(self.START).payload).u32()
|
|
257
|
+
self.armed_s = time.monotonic()
|
|
258
|
+
return blocking
|
|
259
|
+
|
|
260
|
+
def stop(self) -> None:
|
|
261
|
+
self._call(self.STOP)
|
|
262
|
+
|
|
263
|
+
def status(self) -> tuple[int, int, int, int]:
|
|
264
|
+
"""-> state, segments done, write position, flags."""
|
|
265
|
+
return m.Reader(self._call(self.STATUS, locked=False).payload).take("BIQB")
|
|
266
|
+
|
|
267
|
+
def release(self, serial: int) -> None:
|
|
268
|
+
"""Repeat: segments up to `serial` may be reused."""
|
|
269
|
+
self._call(self.RELEASE, struct.pack("<I", serial))
|
|
270
|
+
|
|
271
|
+
def segments(self, from_serial: int = 0) -> list[Segment]:
|
|
272
|
+
rd = m.Reader(self._call(self.SEGMENTS, struct.pack("<I", from_serial), locked=False).payload)
|
|
273
|
+
out = [Segment.unpack(rd.bytes(SEGMENT_BYTES)) for _ in range(rd.u8())]
|
|
274
|
+
rd.tail()
|
|
275
|
+
return out
|
|
276
|
+
|
|
277
|
+
def wait(self, timeout: float = 5.0) -> list[Segment]:
|
|
278
|
+
"""Poll status until the one-shot is done (or failed). -> its segments."""
|
|
279
|
+
deadline = time.monotonic() + timeout
|
|
280
|
+
while time.monotonic() < deadline:
|
|
281
|
+
state = self.status()[0]
|
|
282
|
+
if state == STATE["done"]:
|
|
283
|
+
return self.segments()
|
|
284
|
+
if state == STATE["error"]:
|
|
285
|
+
raise h.Failed(None, "the capture stopped with an error")
|
|
286
|
+
if state not in STATE.values():
|
|
287
|
+
raise h.ProtocolError(f"capture state {state} is not one this client knows")
|
|
288
|
+
time.sleep(0.002)
|
|
289
|
+
raise TimeoutError("capture did not finish")
|
|
290
|
+
|
|
291
|
+
READ_TRIES = 4 # batches of reads sent again after the link's own repeat failed too
|
|
292
|
+
BATCH = 16 # frame-sized reads per pipeline; a keepalive between batches when a session is open
|
|
293
|
+
|
|
294
|
+
def read(self, position: int, length: int) -> bytes:
|
|
295
|
+
"""Bytes [position, position+length) of the stream, pipelined in frame-sized reads.
|
|
296
|
+
|
|
297
|
+
The reads need no lock and go without the session id, so a repeat after a broken reply is simply run again
|
|
298
|
+
(reads are not deduplicated, oep-core §5.2). They do not extend the lease, though: a long read (64 KB over a
|
|
299
|
+
115200 bps UART probe takes seconds) sends a keepalive between batches, or the lease lapsed mid-read, the plan
|
|
300
|
+
went with it (core §9) and the capture read back nothing (2026-09-26, V003 jig)."""
|
|
301
|
+
chunk = max(1, confirm(self.host)["max_frame"] - 16)
|
|
302
|
+
offsets = list(range(0, length, chunk))
|
|
303
|
+
out = bytearray()
|
|
304
|
+
for at in range(0, len(offsets), self.BATCH):
|
|
305
|
+
if at and self.host.session is not None:
|
|
306
|
+
self.host.keepalive()
|
|
307
|
+
batch = offsets[at:at + self.BATCH]
|
|
308
|
+
for attempt in range(self.READ_TRIES):
|
|
309
|
+
reqs = [self.request(self.READ, struct.pack("<QI", position + off, min(chunk, length - off))) for off in batch]
|
|
310
|
+
try:
|
|
311
|
+
replies = self.host.pipeline_calls(reqs, locked=False)
|
|
312
|
+
break
|
|
313
|
+
except (cobs.CorruptFrame, TimeoutError, FramingLost):
|
|
314
|
+
# the link sent the batch once more already; a read changes nothing, so the whole batch can go
|
|
315
|
+
# again (the V003 jig's CP2102 dropped bytes twice in one 60 KB read, 2026-09-29)
|
|
316
|
+
if attempt == self.READ_TRIES - 1:
|
|
317
|
+
raise
|
|
318
|
+
for off, r in zip(batch, replies):
|
|
319
|
+
data = r.payload[9:] # after position(u64) flags(u8)
|
|
320
|
+
want = min(chunk, length - off)
|
|
321
|
+
while len(data) < want: # a short answer: read on from where it stopped
|
|
322
|
+
more = self._call(self.READ, struct.pack("<QI", position + off + len(data), want - len(data)),
|
|
323
|
+
locked=False).payload[9:]
|
|
324
|
+
if not more:
|
|
325
|
+
raise h.ProtocolError(f"read at {position + off + len(data)} returned nothing")
|
|
326
|
+
data += more
|
|
327
|
+
out += data
|
|
328
|
+
return bytes(out)
|
|
329
|
+
|
|
330
|
+
def read_segment(self, segment: Segment) -> bytes:
|
|
331
|
+
"""The segment's bytes; every Host.on_capture callback gets them as a CaptureRecord (a run recorder, e.g.
|
|
332
|
+
pytest-embedded-wireskein, without this package knowing it)."""
|
|
333
|
+
c = self.config
|
|
334
|
+
data = self.read(segment.position, (segment.samples * c.width + 7) // 8)
|
|
335
|
+
if self.host.on_capture:
|
|
336
|
+
record = CaptureRecord(self.fn, self.name, c, segment, data, self.armed_s, time.monotonic())
|
|
337
|
+
for callback in list(self.host.on_capture):
|
|
338
|
+
callback(record)
|
|
339
|
+
return data
|
|
340
|
+
|
|
341
|
+
# ---- the §3.0 layout ---------------------------------------------------------------------------------
|
|
342
|
+
def channel(self, data: bytes, k: int, samples: int | None = None) -> list[int]:
|
|
343
|
+
"""Channel k's values, one per sample (§3.0 rules 1-3)."""
|
|
344
|
+
c = self.config
|
|
345
|
+
n = samples if samples is not None else len(data) * 8 // c.width
|
|
346
|
+
bit0 = c.positions[k]
|
|
347
|
+
return [(data[(i * c.width + bit0) >> 3] >> ((i * c.width + bit0) & 7)) & 1 for i in range(n)]
|
|
348
|
+
|
|
349
|
+
def to_sr(self, path: str, data: bytes, samples: int, names: list[str] | None = None) -> None:
|
|
350
|
+
"""A sigrok session file: one byte per sample, bit k = channel k (so up to 8 channels here)."""
|
|
351
|
+
c = self.config
|
|
352
|
+
n_ch = len(c.positions)
|
|
353
|
+
if n_ch > 8:
|
|
354
|
+
raise ValueError("to_sr writes one byte per sample; more than 8 channels need unitsize 2")
|
|
355
|
+
names = names or [f"D{k}" for k in range(n_ch)]
|
|
356
|
+
chans = [self.channel(data, k, samples) for k in range(n_ch)]
|
|
357
|
+
out = bytes(sum(chans[k][i] << k for k in range(n_ch)) for i in range(samples))
|
|
358
|
+
rate = c.rate.numerator // c.rate.denominator
|
|
359
|
+
meta = ["[global]", "sigrok version=0.5.2", "", "[device 1]", "capturefile=logic-1",
|
|
360
|
+
f"total probes={n_ch}", f"samplerate={rate} Hz", "total analog=0"]
|
|
361
|
+
meta += [f"probe{k + 1}={nm}" for k, nm in enumerate(names)] + ["unitsize=1", ""]
|
|
362
|
+
with zipfile.ZipFile(path, "w") as z:
|
|
363
|
+
z.writestr("version", "2")
|
|
364
|
+
z.writestr("metadata", "\n".join(meta))
|
|
365
|
+
z.writestr("logic-1-1", out)
|
oep_client/v1/catalog.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Draft wire forms for capability discovery by name (oep-spec docs/capability-declaration-model.ja.md).
|
|
2
|
+
|
|
3
|
+
list request : flags(u8) first(u16) prefix_len(u8) prefix flags bit0 = exact
|
|
4
|
+
list result : total(u16) count(u8) entries [TLV tail] oep.core (fn 0) is the first entry
|
|
5
|
+
list entry : fn(u16) instance(u16) revision(u8) flags(u8) name_len(u8) name
|
|
6
|
+
describe : request fn(u16) first(u16); result more(u8) then TLV bytes (tag u8, len u8, value;
|
|
7
|
+
tag bit 7 = critical). more = 1: TLVs remain after this page, ask again from first + count
|
|
8
|
+
(oep-spec oep-core §7.2; the entry revision decides the interface's payload shapes, §2.7)
|
|
9
|
+
|
|
10
|
+
Common TLV tags 0x01..0x3F mean the same for every interface; 0x40..0x7F belong to the interface.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import struct
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
|
|
18
|
+
from .message import Reader, split_tlvs
|
|
19
|
+
|
|
20
|
+
LIST_EXACT = 0x01
|
|
21
|
+
|
|
22
|
+
# Common describe tags.
|
|
23
|
+
ROLE_CHANNELS = 0x01 # role(u8) base(u16) bitmap(bytes): channels usable for that role
|
|
24
|
+
MAX_CLOCK_HZ = 0x02 # u32
|
|
25
|
+
MAX_LENGTH = 0x03 # u16
|
|
26
|
+
MIN_CLOCK_HZ = 0x05 # u32
|
|
27
|
+
FEATURES = 0x06 # u32, bits defined by the interface
|
|
28
|
+
IMPLEMENTATION = 0x07 # u8: 0 unspecified, 1 software, 2 peripheral, 3 peripheral + DMA/PIO
|
|
29
|
+
CHANNEL_GROUP = 0x08 # group(u8) then (role(u8), channel(u16)) repeated: a fixed pin set
|
|
30
|
+
CRITICAL = 0x80
|
|
31
|
+
INTERFACE_TAG_FIRST = 0x40
|
|
32
|
+
|
|
33
|
+
IMPLEMENTATIONS = {0: "unspecified", 1: "software (bit-bang)", 2: "peripheral", 3: "peripheral + DMA/PIO"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---- list ----------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class ListEntry:
|
|
40
|
+
fn: int
|
|
41
|
+
instance: int
|
|
42
|
+
revision: int
|
|
43
|
+
flags: int
|
|
44
|
+
name: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def pack_list_request(prefix: str = "", exact: bool = False, first: int = 0) -> bytes:
|
|
48
|
+
raw = prefix.encode("ascii")
|
|
49
|
+
return struct.pack("<BHB", LIST_EXACT if exact else 0, first, len(raw)) + raw
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def unpack_list_request(payload: bytes) -> tuple[str, bool, int, bytes]:
|
|
53
|
+
"""-> (prefix, exact, first, the request's TLV tail)."""
|
|
54
|
+
if len(payload) < 4:
|
|
55
|
+
raise ValueError("list request shorter than its fixed part")
|
|
56
|
+
flags, first, n = struct.unpack_from("<BHB", payload)
|
|
57
|
+
if len(payload) < 4 + n:
|
|
58
|
+
raise ValueError("list request: prefix length does not match")
|
|
59
|
+
return payload[4:4 + n].decode("ascii"), bool(flags & LIST_EXACT), first, payload[4 + n:]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def pack_entry(e: ListEntry) -> bytes:
|
|
63
|
+
raw = e.name.encode("ascii")
|
|
64
|
+
return struct.pack("<HHBBB", e.fn, e.instance, e.revision, e.flags, len(raw)) + raw
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def pack_list_result(total: int, entries: list[ListEntry]) -> bytes:
|
|
68
|
+
return struct.pack("<HB", total, len(entries)) + b"".join(pack_entry(e) for e in entries)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def unpack_list_result(payload: bytes) -> tuple[int, list[ListEntry]]:
|
|
72
|
+
"""-> (total, this page's entries). What follows the counted entries is a §0 TLV tail: skipped."""
|
|
73
|
+
rd = Reader(payload)
|
|
74
|
+
total, count = rd.take("HB")
|
|
75
|
+
out = []
|
|
76
|
+
for _ in range(count):
|
|
77
|
+
fn, instance, revision, flags, n = rd.take("HHBBB")
|
|
78
|
+
out.append(ListEntry(fn, instance, revision, flags, rd.bytes(n).decode("ascii", "replace")))
|
|
79
|
+
rd.tail()
|
|
80
|
+
return total, out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def pack_describe_request(fn: int, first: int) -> bytes:
|
|
84
|
+
return struct.pack("<HH", fn, first)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---- describe TLVs -------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
def tlv(tag: int, value: bytes) -> bytes:
|
|
90
|
+
if len(value) > 255:
|
|
91
|
+
raise ValueError(f"TLV 0x{tag:02x}: value of {len(value)} bytes does not fit")
|
|
92
|
+
return bytes((tag, len(value))) + value
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def split_tlv(data: bytes) -> list[tuple[int, bytes]]:
|
|
96
|
+
return split_tlvs(data)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def channels_to_bitmap(channels) -> tuple[int, bytes]:
|
|
100
|
+
chans = sorted(set(channels))
|
|
101
|
+
if not chans:
|
|
102
|
+
return 0, b""
|
|
103
|
+
base = chans[0]
|
|
104
|
+
bits = bytearray((chans[-1] - base) // 8 + 1)
|
|
105
|
+
for c in chans:
|
|
106
|
+
bits[(c - base) // 8] |= 1 << ((c - base) % 8)
|
|
107
|
+
return base, bytes(bits)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def bitmap_to_channels(base: int, bitmap: bytes) -> list[int]:
|
|
111
|
+
return [base + i * 8 + b for i, byte in enumerate(bitmap) for b in range(8) if byte >> b & 1]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def role_channels(role: int, channels) -> bytes:
|
|
115
|
+
base, bits = channels_to_bitmap(channels)
|
|
116
|
+
return tlv(ROLE_CHANNELS, struct.pack("<BH", role, base) + bits)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def channel_group(group: int, pins: list[tuple[int, int]]) -> bytes:
|
|
120
|
+
return tlv(CHANNEL_GROUP, bytes([group]) + b"".join(struct.pack("<BH", r, c) for r, c in pins))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def u8(tag: int, v: int) -> bytes:
|
|
124
|
+
return tlv(tag, struct.pack("<B", v))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def u16(tag: int, v: int) -> bytes:
|
|
128
|
+
return tlv(tag, struct.pack("<H", v))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def u32(tag: int, v: int) -> bytes:
|
|
132
|
+
return tlv(tag, struct.pack("<I", v))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def text(tag: int, s: str) -> bytes:
|
|
136
|
+
return tlv(tag, s.encode("ascii"))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass
|
|
140
|
+
class Description:
|
|
141
|
+
"""Common tags decoded; interface-specific and unknown tags kept raw, in order."""
|
|
142
|
+
|
|
143
|
+
roles: dict[int, list[int]] = field(default_factory=dict)
|
|
144
|
+
groups: dict[int, list[tuple[int, int]]] = field(default_factory=dict)
|
|
145
|
+
max_clock_hz: int | None = None
|
|
146
|
+
min_clock_hz: int | None = None
|
|
147
|
+
max_length: int | None = None
|
|
148
|
+
features: int | None = None
|
|
149
|
+
implementation: int | None = None
|
|
150
|
+
specific: list[tuple[int, bytes]] = field(default_factory=list)
|
|
151
|
+
unknown_critical: list[int] = field(default_factory=list)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def decode_description(data: bytes) -> Description:
|
|
155
|
+
d = Description()
|
|
156
|
+
for tag, value in split_tlv(data):
|
|
157
|
+
t = tag & ~CRITICAL
|
|
158
|
+
if t == ROLE_CHANNELS:
|
|
159
|
+
role, base = struct.unpack_from("<BH", value)
|
|
160
|
+
d.roles.setdefault(role, []).extend(bitmap_to_channels(base, value[3:]))
|
|
161
|
+
elif t == CHANNEL_GROUP:
|
|
162
|
+
group = value[0]
|
|
163
|
+
d.groups[group] = [struct.unpack_from("<BH", value, 1 + 3 * i) for i in range((len(value) - 1) // 3)]
|
|
164
|
+
elif t == MAX_CLOCK_HZ:
|
|
165
|
+
d.max_clock_hz = struct.unpack("<I", value)[0]
|
|
166
|
+
elif t == MIN_CLOCK_HZ:
|
|
167
|
+
d.min_clock_hz = struct.unpack("<I", value)[0]
|
|
168
|
+
elif t == MAX_LENGTH:
|
|
169
|
+
d.max_length = struct.unpack("<H", value)[0]
|
|
170
|
+
elif t == FEATURES:
|
|
171
|
+
d.features = struct.unpack("<I", value)[0]
|
|
172
|
+
elif t == IMPLEMENTATION:
|
|
173
|
+
d.implementation = value[0]
|
|
174
|
+
elif t >= INTERFACE_TAG_FIRST:
|
|
175
|
+
d.specific.append((tag, value))
|
|
176
|
+
elif tag & CRITICAL:
|
|
177
|
+
d.unknown_critical.append(tag)
|
|
178
|
+
# an unknown non-critical common tag is skipped
|
|
179
|
+
for role in d.roles:
|
|
180
|
+
d.roles[role] = sorted(set(d.roles[role]))
|
|
181
|
+
return d
|