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.
@@ -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)
@@ -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