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,1479 @@
1
+ """A fake probe endpoint that speaks OEP v1, answering whole messages (no hardware).
2
+
3
+ This is the spec side's "working spec": ch32rv, this client and the probe firmware are checked against it. It wraps
4
+ a `fake.FakeProbe` (the static declarations) and does what oep-spec docs/oep-core.ja.md and docs/oep-if-*.ja.md
5
+ define:
6
+
7
+ - confirm with a revision range; `revision=0` makes a v0 probe that answers in the v0 shape and drops role 0x81
8
+ requests unanswered
9
+ - the lock (core §6): a host-chosen session id, extended by every request of its holder and counted from when that
10
+ request completed; when it lapses or ends the last id is remembered and may resume; lease 0 = the probe default,
11
+ 1000-60000 ms taken as asked, longer ones cut to `lease_max_ms`; the open's owner TLV shown by lock_state and by
12
+ rejected locked (never the session id)
13
+ - the resend table (core §5.2): the last session's recent requests with their results (results longer than
14
+ `remember_max` bytes are not kept -> result_lost), corr_reused, result_lost for old requests, emptied by open
15
+ - rejects: no session, locked (+ remaining ms), session required, no connection, unsupported (+ the critical tag),
16
+ malformed (short fixed part, tag 0xFF)
17
+ - request tails: unknown critical -> rejected unsupported, unknown non-critical -> listed in the result's ignored TLV
18
+ (0x7F); `tail=` appends TLVs to every result that may carry them, so hosts can be checked to skip what they do not
19
+ know
20
+ - the plan (plan_apply / plan_release; the session's plan goes when the lease lapses), and simulations of the
21
+ interfaces the profiles offer: oep.wire.rvswd / swio (scan, attach on declared pin pairs, several connections up
22
+ to max_connections, the seat rule, connections), oep.target.riscv-dm on one `FakeTarget` per pin pair,
23
+ oep.target.console streams, oep.fixture.gpio, oep.fixture.uart, and oep.probe.config (plan / label / idle / slot /
24
+ bind items, get / set / save / erase, slot_state and bind_state)
25
+ - the serial ports' raw side (core §3.4, probe.config §1.2): `port_input` / `port_output` carry the bytes outside
26
+ the frames for each serial port by its bind; a port the lock holder's requests came in on is held until the
27
+ session ends, then resumes from the session's last host reset. The byte framing itself is `fake_serial`.
28
+
29
+ Every other non-core fn gets two stand-in operations so the session rules can be exercised - FAKE ONLY, they mean
30
+ nothing on a real probe: 0x01 write(u32) changes state, 0x02 read -> u32 needs no lock.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import re
36
+ import struct
37
+ import zlib
38
+ from collections import OrderedDict
39
+ from dataclasses import dataclass, field
40
+ from typing import Callable
41
+
42
+ from . import catalog, fake, message as m, registry as reg
43
+
44
+ TOY_WRITE, TOY_READ = 0x01, 0x02
45
+ OK, WAIT, LINE, FAULT, TIMEOUT, STATE = (reg.STATUS[k] for k in ("ok", "wait", "line", "fault", "timeout", "state"))
46
+ _RV, _CON, _GPIO, _UART, _CFG = (reg.TARGET_RISCV_DM, reg.TARGET_CONSOLE, reg.FIXTURE_GPIO, reg.FIXTURE_UART,
47
+ reg.PROBE_CONFIG)
48
+ STEP = _RV.enum["dmi_step"]
49
+ STEP_ARGS = {STEP["write"]: "BI", STEP["read"]: "B", STEP["poll_reads"]: "BIIH", STEP["wait_us"]: "I",
50
+ STEP["poll_us"]: "BIII"}
51
+ MARK = _CON.enum["mark_kind"]
52
+ ITEM = _CFG.tlv["item"]
53
+ CFG_DESCRIBE = _CFG.tlv["describe"]
54
+ SLOT_ATTACH = _CFG.enum["slot_attach"]
55
+ SLOT_STATE = _CFG.enum["slot_state"]
56
+ BIND_MODE = _CFG.enum["bind_mode"]
57
+ BIND_STREAM = _CFG.enum["bind_stream"]
58
+ BIND_FLOW = _CFG.enum["bind_flow"]
59
+ WIRES = ("oep.wire.rvswd", "oep.wire.swio")
60
+ OWNER = reg.CORE.tlv["open"]["owner"]
61
+ SLOT_NAME = re.compile(r"[a-z0-9_-]{1,32}")
62
+ NO_SLOT, NEVER = 0xFF, 0xFFFFFFFF
63
+
64
+
65
+ class Reject(Exception):
66
+ def __init__(self, reason: int, payload: bytes = b""):
67
+ self.reason, self.payload = reason, payload
68
+
69
+
70
+ class Take:
71
+ """Reads a request's fixed part; too short -> rejected malformed. `tail(known)` applies the request-tail rule."""
72
+
73
+ def __init__(self, payload: bytes):
74
+ self.data, self.at = payload, 0
75
+
76
+ def take(self, fmt: str):
77
+ size = struct.calcsize("<" + fmt)
78
+ if self.at + size > len(self.data):
79
+ raise Reject(m.MALFORMED)
80
+ v = struct.unpack_from("<" + fmt, self.data, self.at)
81
+ self.at += size
82
+ return v if len(v) > 1 else v[0]
83
+
84
+ def bytes(self, n: int) -> bytes:
85
+ if self.at + n > len(self.data):
86
+ raise Reject(m.MALFORMED)
87
+ out = self.data[self.at:self.at + n]
88
+ self.at += n
89
+ return out
90
+
91
+ def tail(self, known: set[int] = frozenset()) -> tuple[dict[int, bytes], list[int]]:
92
+ """-> (known tags without the critical bit -> value, ignored non-critical tags)."""
93
+ rest, at, got, ignored = self.data[self.at:], 0, {}, []
94
+ self.critical = set() # known tags that came with the critical bit
95
+ while at < len(rest):
96
+ if at + 2 > len(rest) or at + 2 + rest[at + 1] > len(rest):
97
+ raise Reject(m.MALFORMED)
98
+ tag, value = rest[at], rest[at + 2:at + 2 + rest[at + 1]]
99
+ at += 2 + rest[at + 1]
100
+ if tag == m.TAG_INVALID or tag == m.TAG_IGNORED:
101
+ raise Reject(m.MALFORMED)
102
+ if tag & 0x7F in known:
103
+ got[tag & 0x7F] = value
104
+ if tag & m.TAG_CRITICAL:
105
+ self.critical.add(tag & 0x7F)
106
+ elif tag & m.TAG_CRITICAL:
107
+ raise Reject(m.UNSUPPORTED, bytes([tag]))
108
+ else:
109
+ ignored.append(tag)
110
+ self.at = len(self.data)
111
+ return got, ignored
112
+
113
+ def refuse(self, tag: int, got: dict[int, bytes], ignored: list[int]) -> None:
114
+ """A known TLV whose value cannot be honoured: critical -> unsupported with the tag as received, else it is
115
+ dropped and listed as ignored."""
116
+ if tag in self.critical:
117
+ raise Reject(m.UNSUPPORTED, bytes([tag | m.TAG_CRITICAL]))
118
+ got.pop(tag, None)
119
+ ignored.append(tag)
120
+
121
+
122
+ @dataclass
123
+ class FakeTarget:
124
+ """A RISC-V hart behind a debug module, as far as the riscv-dm operations see it."""
125
+ halted: bool = False
126
+ dpc: int = 0x100
127
+ reset_vector: int = 0
128
+ mem: dict = field(default_factory=dict) # word address -> value
129
+ dmi: dict = field(default_factory=dict) # DMI address -> value (what a read returns)
130
+ dmi_reads: dict = field(default_factory=dict) # DMI address -> list of values the next reads return
131
+ fail_write: set = field(default_factory=set) # DMI addresses whose write fails on the line
132
+ fault_at: set = field(default_factory=set) # word addresses a block access faults on
133
+ regs: dict = field(default_factory=dict) # regno -> value
134
+ havereset: bool = True
135
+ resume_misses: int = 0 # resumes that do not take (status state, dpc unchanged)
136
+ present: bool = True # something answers on this pin pair (scan, attach)
137
+ target_id: int | None = None # the wch_dmi_7f target_id attach reports (None: none)
138
+ # run(pc, regs) -> (stopped, dpc, elapsed_us); default: halts 0x10 past the start
139
+ run_hook: Callable | None = None
140
+
141
+ def dmstatus(self) -> int:
142
+ return 0x82 | ((0x300 if self.halted else 0xC00)) | (0xC0000 if self.havereset else 0)
143
+
144
+ def read_dmi(self, address: int) -> int:
145
+ queue = self.dmi_reads.get(address)
146
+ if queue:
147
+ self.dmi[address] = queue.pop(0)
148
+ return self.dmi.get(address, 0)
149
+
150
+
151
+ @dataclass
152
+ class Stream:
153
+ """A position stream (common §1): bytes from position `base`, marks with serials."""
154
+ data: bytearray = field(default_factory=bytearray)
155
+ base: int = 0
156
+ marks: list = field(default_factory=list) # (serial, position, kind, time_ms, detail)
157
+ serial: int = 0
158
+ closed: bool = False
159
+ written: bytearray = field(default_factory=bytearray)
160
+
161
+ @property
162
+ def end(self) -> int:
163
+ return self.base + len(self.data)
164
+
165
+ def add_mark(self, kind: int, time_ms: int, detail: int = 0) -> None:
166
+ self.marks.append((self.serial, self.end, kind, time_ms, detail))
167
+ self.serial += 1
168
+
169
+ def drop_oldest(self, n: int) -> None:
170
+ del self.data[:n]
171
+ self.base += n
172
+
173
+
174
+ @dataclass
175
+ class Connection:
176
+ """A debug connection (common §2): made by a wire's attach, open while anything uses it."""
177
+ fn: int
178
+ pair: tuple[int, int]
179
+ order: int # creation order (the seat rule closes the oldest)
180
+ speed: int = 4_000_000
181
+ tid: int | None = None
182
+ users: set = field(default_factory=set) # "host" and/or ("slot", n)
183
+
184
+
185
+ @dataclass(frozen=True)
186
+ class Slot:
187
+ slot: int
188
+ wire_fn: int
189
+ pair: tuple[int, int]
190
+ attach: int
191
+ retry_s: int
192
+ mechanism: int
193
+ name: str
194
+ lock: tuple[int, bytes, bytes] | None # (scheme, mask, value)
195
+
196
+
197
+ @dataclass(frozen=True)
198
+ class Bind:
199
+ port: int
200
+ mode: int
201
+ selected: int
202
+ streams: tuple[tuple[int, int], ...] # (kind, id)
203
+
204
+
205
+ @dataclass
206
+ class SlotRuntime:
207
+ last_try_ms: int | None = None
208
+ evicted: bool = False # the seat rule closed its connection: no retry until a new cue
209
+ mismatch_tid: int | None = None # what the last automatic attach saw when the lock did not match
210
+
211
+
212
+ @dataclass
213
+ class Flow:
214
+ """One stream as a serial port's bind carries it: the stream id and the port's position in it."""
215
+ sid: object = None
216
+ pos: int = 0
217
+ line: bytearray = field(default_factory=bytearray)
218
+ last_ms: int = 0
219
+
220
+
221
+ class Endpoint:
222
+ MARKS_PER_ANSWER = 3 # small, so hosts must follow `more`
223
+ CHUNK = 64 # raw bytes a serial port takes at a time (probe guide §3.6)
224
+ MIXED_LINE_MAX, MIXED_QUIET_MS = 128, 100
225
+
226
+ def __init__(self, probe: fake.FakeProbe, now_ms: Callable[[], int], boot_id: int = 0x1234ABCD,
227
+ lease_default_ms: int = 3000, lease_max_ms: int = 600000, revision: int = 1, tail: bytes = b"",
228
+ window: int = 1 << 18, max_inflight: int = 4, remember_max: int = 72):
229
+ self.probe = probe
230
+ self.now = now_ms
231
+ self.boot_id = boot_id
232
+ self.lease_default_ms = lease_default_ms
233
+ self.lease_max_ms = lease_max_ms
234
+ self.revision = revision
235
+ self.tail = tail
236
+ self.window, self.max_inflight = window, max_inflight
237
+ self.remember_max = remember_max
238
+ self.names = {o.fn: o.name for o in probe.offered}
239
+ self.fns = {name: fn for fn, name in sorted(self.names.items(), reverse=True)} # first fn of each name
240
+ self.static = {o.fn: o.tlvs for o in probe.offered}
241
+ self.static_labels: dict[int, str] = {}
242
+ self.transports: list[int] = []
243
+ for t in self.static.get(0, ()):
244
+ if t[0] == fake.CORE_LABEL:
245
+ self.static_labels[struct.unpack_from("<H", t, 2)[0]] = t[4:2 + t[1]].decode()
246
+ if t[0] == fake.CORE_TRANSPORT:
247
+ self.transports.append(t[3])
248
+ self.serial_ports = {i for i, k in enumerate(self.transports) if k in fake.SERIAL_KINDS}
249
+ self.pairs: dict[int, list[tuple[int, int]]] = {} # wire fn -> allowed (swdio, swclk), declared order
250
+ self.max_connections: dict[int, int] = {}
251
+ for fn, name in self.names.items():
252
+ if name in WIRES:
253
+ self.pairs[fn] = [self._group_pair(name, t) for t in self.static[fn] if t[0] == catalog.CHANNEL_GROUP]
254
+ self.max_connections[fn] = next((t[2] for t in self.static[fn] if t[0] == fake.MAX_CONNECTIONS), 1)
255
+ self.targets: dict[tuple[int, tuple[int, int]], FakeTarget] = {
256
+ (fn, p): FakeTarget() for fn in sorted(self.pairs) for p in self.pairs[fn]}
257
+ self.target = next(iter(self.targets.values()), FakeTarget())
258
+ self.mechanisms = set()
259
+ for fn, name in self.names.items():
260
+ if name == "oep.target.console":
261
+ self.mechanisms |= {b for t in self.static[fn] if t[0] == fake.MECHANISMS for b in t[2:2 + t[1]]}
262
+ self.block_max = {fn: next((struct.unpack_from("<H", t, 2)[0] for t in self.static[fn]
263
+ if t[0] == catalog.MAX_LENGTH), 1 << 16)
264
+ for fn, name in self.names.items() if name == "oep.target.riscv-dm"}
265
+ cfg_fn = self.fns.get("oep.probe.config")
266
+ cfg = {t[0]: t[2:2 + t[1]] for t in self.static.get(cfg_fn, ())}
267
+ self.slots_max = cfg[CFG_DESCRIBE["slots_max"]][0] if CFG_DESCRIBE["slots_max"] in cfg else 0
268
+ self.bind_modes = cfg[CFG_DESCRIBE["bind_modes"]][0] if CFG_DESCRIBE["bind_modes"] in cfg else 0
269
+ self.items = set(cfg.get(CFG_DESCRIBE["items"], b""))
270
+ self.storage_max = struct.unpack_from("<I", cfg[CFG_DESCRIBE["storage"]])[0] if CFG_DESCRIBE["storage"] in cfg else 0
271
+ self.console_accept = 64
272
+ self.uart_accept = 256
273
+ self._boot()
274
+
275
+ def _boot(self) -> None:
276
+ self.holder: int | None = None
277
+ self.last: int | None = None
278
+ self.owner: bytes | None = None
279
+ self.lease_ms = self.lease_default_ms
280
+ self.expires_ms = 0
281
+ self.values: dict[int, int] = {}
282
+ self.dropped = 0 # requests a v0 endpoint dropped (role 0x81)
283
+ self.requests: list[m.Request] = []
284
+ self.subscribed: set[int] = set()
285
+ self.plan: set[tuple[int, int, int]] = set() # (fn, role, channel), from plan_apply and the config
286
+ self.plan_from_config: set[int] = set() # fns whose plan came from the config (not a session's)
287
+ self.resend: OrderedDict[int, tuple[int, int, int, bytes | None]] = OrderedDict()
288
+ self.newest_corr: int | None = None
289
+ self.conns: dict[int, Connection] = {}
290
+ self._next_conn = 1
291
+ self._order = 0
292
+ self.streams: dict[int, Stream] = {} # console stream id -> stream
293
+ self.stream_keys: dict[tuple[int, int], int] = {} # (connection, mechanism) -> stream id
294
+ self.stream_places: dict[int, tuple[int, tuple[int, int]]] = {} # stream id -> (wire fn, pin pair) it was on
295
+ self._next_stream = 1
296
+ self.gpio_modes: dict[int, int] = {}
297
+ self.gpio_inputs: dict[int, int] = {}
298
+ self.gpio_log: list[tuple[int, int]] = []
299
+ self.uarts: dict[int, Stream] = {} # fn -> stream (configured)
300
+ self.uart_baud: dict[int, tuple[int, int]] = {}
301
+ self.uart_tx: dict[int, bytearray] = {} # what a serial port's raw bytes sent out on a fixture UART
302
+ self.config: dict[tuple[int, int], bytes | list[bytes]] = {} # (item tag, key) -> value (plan: list)
303
+ self.saved: dict | None = getattr(self, "saved", None)
304
+ self.slots: dict[int, Slot] = {}
305
+ self.binds: dict[int, Bind] = {}
306
+ self.slot_rt: dict[int, SlotRuntime] = {}
307
+ self.selected: dict[int, int] = {} # port -> selected index (last-reset / manual)
308
+ self.flows: dict[tuple[int, tuple[int, int]], Flow] = {}
309
+ self.mixed_out: dict[int, bytearray] = {}
310
+ self.held_ports: set[int] = set()
311
+ self.session_resets: dict[tuple[int, int], tuple[object, int]] = {} # stream key -> (sid, position)
312
+ if self.saved is not None:
313
+ self._apply_config(dict(self.saved), boot=True)
314
+
315
+ @property
316
+ def target_id(self) -> int | None:
317
+ return self.target.target_id
318
+
319
+ @target_id.setter
320
+ def target_id(self, value: int | None) -> None:
321
+ self.target.target_id = value
322
+
323
+ @staticmethod
324
+ def _group_pair(name: str, t: bytes) -> tuple[int, int]:
325
+ roles = {t[3 + 3 * i]: struct.unpack_from("<H", t, 4 + 3 * i)[0] for i in range((t[1] - 1) // 3)}
326
+ return roles.get(1, 0xFFFF), roles.get(2, 0xFFFF) if name != "oep.wire.swio" else 0xFFFF
327
+
328
+ # ---- the one entry point: a request message in, a result message out ------------------------
329
+ def handle(self, data: bytes, transport: int = 0) -> bytes | None:
330
+ """A request from transport `transport` (the index in the describe's transport list) -> its result."""
331
+ if self.revision == 0 and data and data[0] & m.ROLE_SESSION:
332
+ self.dropped += 1 # a v0 probe: unknown role, no answer
333
+ return None
334
+ req = m.Request.unpack(data)
335
+ self.requests.append(req)
336
+ remembered = self._resent(req)
337
+ if remembered is not None:
338
+ return remembered
339
+ try:
340
+ res, detail, payload = self._dispatch(req)
341
+ except Reject as r:
342
+ res, detail, payload = m.REJECTED, r.reason, r.payload
343
+ if res != m.REJECTED and not self._closed_tail(req.fn, req.op) and self.revision >= 1:
344
+ payload += self.tail
345
+ if req.session is not None and req.session == self.holder:
346
+ self.expires_ms = self.now() + self.lease_ms # watchdog, counted from completion
347
+ took_lock = req.fn == m.CORE_FN and req.op == m.OP_OPEN and res == m.COMPLETED
348
+ if self.holder is not None and transport in self.serial_ports and (
349
+ took_lock or (req.session is not None and req.session == self.holder)):
350
+ self.held_ports.add(transport) # core §3.4: the raw transfer holds here
351
+ out = m.Result(req.corr, res, detail, payload).pack()
352
+ if req.session is not None and req.session == self.last:
353
+ self._remember(req, out)
354
+ return out
355
+
356
+ def _interface(self, fn: int):
357
+ return reg.INTERFACES.get(self.names.get(fn, ""))
358
+
359
+ def _closed_tail(self, fn: int, op: int) -> bool:
360
+ i = self._interface(fn)
361
+ return bool(i and op in i.closed_tail)
362
+
363
+ def _lock_free(self, fn: int, op: int) -> bool:
364
+ i = self._interface(fn)
365
+ if fn == m.CORE_FN:
366
+ return op in reg.CORE.lock_free
367
+ if i is None or self.names.get(fn) not in SIMS:
368
+ return op == TOY_READ
369
+ return op in i.lock_free
370
+
371
+ @staticmethod
372
+ def _answer(payload: bytes, ignored: list[int], detail: int = m.SUCCESS) -> tuple[int, int, bytes]:
373
+ """A completed result; the request's ignored non-critical tags follow in TLV 0x7F."""
374
+ return m.COMPLETED, detail, payload + (bytes([m.TAG_IGNORED, len(ignored)]) + bytes(ignored) if ignored else b"")
375
+
376
+ def _dispatch(self, req: m.Request) -> tuple[int, int, bytes]:
377
+ self._lapse()
378
+ if req.fn == m.CORE_FN and req.op == m.OP_CONFIRM:
379
+ return self._confirm(req.payload)
380
+ if req.fn == m.CORE_FN and req.op == m.OP_OPEN:
381
+ return self._open(req.payload)
382
+ if req.fn != m.CORE_FN and req.fn not in self.names:
383
+ return m.REJECTED, m.UNKNOWN_FUNCTION, b""
384
+ if not self._lock_free(req.fn, req.op):
385
+ refused = self._check(req.session)
386
+ if refused:
387
+ return refused
388
+ if req.fn == m.CORE_FN:
389
+ return self._core(req)
390
+ sim = SIMS.get(self.names[req.fn])
391
+ if sim is not None:
392
+ return getattr(self, f"_{sim}")(req.fn, req.op, Take(req.payload))
393
+ return self._toy(req)
394
+
395
+ # ---- the resend table (core §5.2) -----------------------------------------------------------
396
+ def _resent(self, req: m.Request) -> bytes | None:
397
+ """A request of the last session seen before: its remembered result, corr_reused or result_lost; None = new."""
398
+ if req.session is None or req.session != self.last:
399
+ return None
400
+ entry = self.resend.get(req.corr)
401
+ if entry is not None:
402
+ fn, op, crc, result = entry
403
+ if (fn, op, crc) != (req.fn, req.op, zlib.crc32(req.payload)):
404
+ return m.Result(req.corr, m.REJECTED, m.CORR_REUSED).pack()
405
+ return result if result is not None else m.Result(req.corr, m.REJECTED, m.RESULT_LOST).pack()
406
+ if self.newest_corr is not None and m.serial_diff(req.corr, self.newest_corr, 16) <= 0:
407
+ return m.Result(req.corr, m.REJECTED, m.RESULT_LOST).pack()
408
+ return None
409
+
410
+ def _remember(self, req: m.Request, result: bytes) -> None:
411
+ self.resend[req.corr] = (req.fn, req.op, zlib.crc32(req.payload),
412
+ result if len(result) <= self.remember_max else None)
413
+ self.resend.move_to_end(req.corr)
414
+ while len(self.resend) > 2 * max(self.max_inflight, 4):
415
+ self.resend.popitem(last=False)
416
+ if self.newest_corr is None or m.serial_diff(req.corr, self.newest_corr, 16) > 0:
417
+ self.newest_corr = req.corr
418
+
419
+ # ---- the stand-in operations ----------------------------------------------------------------
420
+ def _toy(self, req: m.Request) -> tuple[int, int, bytes]:
421
+ t = Take(req.payload)
422
+ if req.op == TOY_READ:
423
+ _, ignored = t.tail()
424
+ return self._answer(struct.pack("<I", self.values.get(req.fn, 0)), ignored)
425
+ if req.op == TOY_WRITE:
426
+ value = t.take("I")
427
+ _, ignored = t.tail()
428
+ self.values[req.fn] = value
429
+ return self._answer(b"", ignored)
430
+ return m.REJECTED, m.UNKNOWN_OPERATION, b""
431
+
432
+ # ---- the lock -------------------------------------------------------------------------------
433
+ def _lapse(self) -> None:
434
+ if self.holder is not None and self.now() >= self.expires_ms:
435
+ self._release_lock(taken=True) # the lock goes, the last id stays
436
+
437
+ def _release_lock(self, taken: bool) -> None:
438
+ """end (taken False) keeps the session's resources for the next open; a lapse or force (taken True) drops
439
+ them (core §9)."""
440
+ self.holder = None
441
+ self.subscribed.clear() # subscriptions end with the lock
442
+ if taken:
443
+ for fn in {a[0] for a in self.plan} - self.plan_from_config:
444
+ self._drop_plan(fn)
445
+ for cid, c in list(self.conns.items()):
446
+ c.users.discard("host")
447
+ if not c.users:
448
+ self._close_conn(cid, MARK["detach"])
449
+ self._refresh()
450
+ self._session_over()
451
+
452
+ def _remaining(self) -> int:
453
+ return max(0, self.expires_ms - self.now()) if self.holder is not None else 0
454
+
455
+ def _locked(self) -> tuple[int, int, bytes]:
456
+ owner = m.tlv(reg.CORE.tlv["locked_payload"]["owner"], self.owner) if self.owner else b""
457
+ return m.REJECTED, m.LOCKED, struct.pack("<I", self._remaining()) + owner
458
+
459
+ def _check(self, session: int | None) -> tuple[int, int, bytes] | None:
460
+ if session is None:
461
+ return m.REJECTED, m.SESSION_REQUIRED, b""
462
+ if self.holder is None:
463
+ if session == self.last:
464
+ self.holder = session # resume: nobody else came in between
465
+ return None
466
+ return m.REJECTED, m.NO_SESSION, b""
467
+ if session == self.holder:
468
+ return None
469
+ return self._locked()
470
+
471
+ def _open(self, payload: bytes) -> tuple[int, int, bytes]:
472
+ t = Take(payload)
473
+ session, lease, force = t.take("IIB")
474
+ got, ignored = t.tail({OWNER})
475
+ owner = got.get(OWNER)
476
+ if owner is not None and not 1 <= len(owner) <= 32:
477
+ t.refuse(OWNER, got, ignored)
478
+ owner = None
479
+ if self.holder is not None and self.holder != session:
480
+ if not force:
481
+ return self._locked()
482
+ self._release_lock(taken=True) # force: the old session is cleaned up first
483
+ resumed = session in (self.holder, self.last)
484
+ if session != self.holder:
485
+ self.subscribed.clear()
486
+ if session != self.last:
487
+ self.owner = None
488
+ if owner is not None:
489
+ self.owner = owner
490
+ self.holder = self.last = session
491
+ self.resend.clear()
492
+ self.newest_corr = None
493
+ if lease == 0:
494
+ self.lease_ms = self.lease_default_ms
495
+ else:
496
+ self.lease_ms = min(lease, max(self.lease_max_ms, 60000))
497
+ self.expires_ms = self.now() + self.lease_ms
498
+ return self._answer(struct.pack("<IIB", self.lease_ms, self.boot_id, int(resumed)), ignored)
499
+
500
+ def reboot(self, boot_id: int) -> None:
501
+ """The probe restarts: lock, last id, connections, streams, the unsaved config and the plan are gone; the
502
+ saved config comes back."""
503
+ self.boot_id = boot_id
504
+ self._boot()
505
+
506
+ def lose_connections(self) -> None:
507
+ """A wire drops every connection; their console streams close with a link-lost mark."""
508
+ for cid in list(self.conns):
509
+ self._close_conn(cid, MARK["link_lost"])
510
+ self._refresh()
511
+
512
+ # ---- core -----------------------------------------------------------------------------------
513
+ def _confirm(self, payload: bytes) -> tuple[int, int, bytes]:
514
+ t = Take(payload)
515
+ magic, lo, hi = t.bytes(4), *t.take("BB")
516
+ if magic != m.CONFIRM_REQUEST:
517
+ return m.REJECTED, m.MALFORMED, b""
518
+ if self.revision == 0: # v0 shape: max_frame(16) window(16) inflight flags
519
+ return m.COMPLETED, m.SUCCESS, struct.pack("<4sBHHBB", m.CONFIRM_RESULT, 0, self.probe.max_frame,
520
+ min(self.window, 0xFFFF), self.max_inflight, 0)
521
+ _, ignored = t.tail()
522
+ if not lo <= self.revision <= hi:
523
+ return m.REJECTED, m.UNSUPPORTED, b""
524
+ return self._answer(struct.pack("<4sBBHIB", m.CONFIRM_RESULT, self.revision, 0, self.probe.max_frame,
525
+ self.window, self.max_inflight), ignored)
526
+
527
+ def _core(self, req: m.Request) -> tuple[int, int, bytes]:
528
+ op, t = req.op, Take(req.payload)
529
+ if op == m.OP_LIST:
530
+ try:
531
+ return m.COMPLETED, m.SUCCESS, self.probe.call(m.CORE_FN, op, req.payload)
532
+ except ValueError:
533
+ return m.REJECTED, m.MALFORMED, b""
534
+ if op == m.OP_DESCRIBE:
535
+ fn, first = t.take("HH")
536
+ if fn not in self.names:
537
+ return m.REJECTED, m.MALFORMED, b""
538
+ return m.COMPLETED, m.SUCCESS, self._page(self._declarations(fn), first)
539
+ if op == m.OP_LOCK_STATE:
540
+ _, ignored = t.tail()
541
+ owner = (m.tlv(reg.CORE.tlv["lock_state_answer"]["owner"], self.owner)
542
+ if self.owner and self.holder is not None else b"")
543
+ return self._answer(struct.pack("<BI", int(self.holder is not None), self._remaining()) + owner, ignored)
544
+ if op == m.OP_LINK_SOURCE:
545
+ n = min(t.take("I"), self.probe.max_frame - m.RESULT_HEADER)
546
+ return m.COMPLETED, m.SUCCESS, bytes(k & 0xFF for k in range(n))
547
+ if op == m.OP_LINK_SINK:
548
+ return m.COMPLETED, m.SUCCESS, struct.pack("<I", len(req.payload))
549
+ if op == m.OP_END:
550
+ t.tail()
551
+ self._release_lock(taken=False)
552
+ return m.COMPLETED, m.SUCCESS, b""
553
+ if op == m.OP_KEEPALIVE:
554
+ _, ignored = t.tail()
555
+ return self._answer(b"", ignored)
556
+ if op == m.OP_SUBSCRIBE:
557
+ fn = t.take("H")
558
+ if t.at < len(t.data):
559
+ t.take("HH")
560
+ t.tail()
561
+ if fn != 0 and fn not in self.names:
562
+ return m.REJECTED, m.UNAVAILABLE, b""
563
+ self.subscribed.add(fn)
564
+ return m.COMPLETED, m.SUCCESS, b""
565
+ if op == m.OP_UNSUBSCRIBE:
566
+ fn = t.take("H")
567
+ t.tail()
568
+ self.subscribed.discard(fn)
569
+ return m.COMPLETED, m.SUCCESS, b""
570
+ if op == m.OP_PLAN_APPLY:
571
+ got = []
572
+ for tag, value in m.split_tlvs(req.payload) if req.payload else []:
573
+ if tag == reg.CORE.tlv["plan_apply"]["role_assignment"] and len(value) >= 5:
574
+ got.append(struct.unpack_from("<HBH", value))
575
+ elif tag & m.TAG_CRITICAL:
576
+ return m.REJECTED, m.UNSUPPORTED, bytes([tag])
577
+ self._check_plan(got)
578
+ named = {fn for fn, _, _ in got}
579
+ self.plan = {a for a in self.plan if a[0] not in named} | set(got)
580
+ self.plan_from_config -= named
581
+ return m.COMPLETED, m.SUCCESS, b""
582
+ if op == m.OP_PLAN_RELEASE: # n(u8) n x fn(u16); n = 0: every fn
583
+ n = t.take("B")
584
+ fns = {t.take("H") for _ in range(n)}
585
+ t.tail()
586
+ for fn in {a[0] for a in self.plan if not fns or a[0] in fns}:
587
+ self._drop_plan(fn)
588
+ return m.COMPLETED, m.SUCCESS, b""
589
+ return m.REJECTED, m.UNKNOWN_OPERATION, b""
590
+
591
+ def _check_plan(self, got: list[tuple[int, int, int]]) -> None:
592
+ """plan_apply's all-or-nothing check: the roles each fn has, and no pin another fn (or a slot) holds."""
593
+ named = {fn for fn, _, _ in got}
594
+ kept = {a for a in self.plan if a[0] not in named}
595
+ slot_pins = {p for s in self.slots.values() for p in s.pair if p != 0xFFFF}
596
+ for fn, role, ch in got:
597
+ roles = {"oep.fixture.gpio": {1}, "oep.fixture.uart": {1, 2}}.get(self.names.get(fn, ""), set())
598
+ if role not in roles or any(k[2] == ch for k in kept) or ch in slot_pins:
599
+ raise Reject(m.UNAVAILABLE)
600
+
601
+ def _drop_plan(self, fn: int) -> None:
602
+ for a in [a for a in self.plan if a[0] == fn]:
603
+ self.gpio_modes.pop(a[2], None)
604
+ self.plan.discard(a)
605
+ self.uarts.pop(fn, None)
606
+ self.plan_from_config.discard(fn)
607
+
608
+ # ---- describe: the static declarations plus the live ones -----------------------------------
609
+ def _declarations(self, fn: int) -> list[bytes]:
610
+ tlvs = list(self.static[fn])
611
+ if fn == m.CORE_FN:
612
+ labels = dict(self.static_labels)
613
+ for (tag, key), value in self.config.items():
614
+ if tag == ITEM["label"]:
615
+ labels[key] = value[2:].decode("utf-8", "replace")
616
+ tlvs = [t for t in tlvs if t[0] != fake.CORE_LABEL]
617
+ tlvs += [catalog.tlv(fake.CORE_LABEL, struct.pack("<H", ch) + name.encode()) for ch, name in sorted(labels.items())]
618
+ elif self.names[fn] == "oep.probe.config":
619
+ tlvs = [t for t in tlvs if t[0] != CFG_DESCRIBE["storage"]]
620
+ saved_hash = self._hash(self.saved) if self.saved is not None else 0
621
+ tlvs.insert(0, catalog.tlv(CFG_DESCRIBE["storage"], struct.pack(
622
+ "<IBII", self.storage_max, 0 if self.saved is None else 1, saved_hash, 20)))
623
+ tlvs += [catalog.tlv(CFG_DESCRIBE["slot_state"], self._slot_state(n)) for n in sorted(self.slots)]
624
+ tlvs += [catalog.tlv(CFG_DESCRIBE["bind_state"], self._bind_state(p)) for p in sorted(self.binds)]
625
+ return tlvs
626
+
627
+ def _page(self, tlvs: list[bytes], first: int) -> bytes:
628
+ budget = self.probe.max_frame - m.RESULT_HEADER - 1
629
+ out, sent = b"", 0
630
+ for t in tlvs[first:]:
631
+ if out and len(out) + len(t) > budget:
632
+ break
633
+ out += t
634
+ sent += 1
635
+ return bytes([1 if first + sent < len(tlvs) else 0]) + out
636
+
637
+ # ---- oep.wire.rvswd / swio ------------------------------------------------------------------
638
+ def _wire(self, fn: int, op: int, t: Take) -> tuple[int, int, bytes]:
639
+ allowed = self.pairs.get(fn, [])
640
+ if op == 0x01: # scan: count(u8) pairs -> tried count found...
641
+ count = t.take("B")
642
+ pairs = [t.take("HH") for _ in range(count)]
643
+ t.tail()
644
+ if any(p not in allowed for p in pairs):
645
+ raise Reject(m.UNAVAILABLE)
646
+ pairs = pairs or allowed
647
+ found = []
648
+ for p in pairs:
649
+ tg = self.targets[(fn, p)]
650
+ if tg.present: # a live connection's pair: read over it, no restart
651
+ found.append(struct.pack("<BHHI", 1, *p, tg.dmstatus()))
652
+ return m.COMPLETED, m.SUCCESS, struct.pack("<BB", len(pairs), len(found)) + b"".join(found)
653
+ if op in (0x02, 0x04): # attach, attach_under_reset
654
+ if op == 0x02:
655
+ method = t.take("B")
656
+ if method > 1:
657
+ raise Reject(m.UNSUPPORTED)
658
+ else:
659
+ channel, _hold = t.take("HH")
660
+ if channel not in (0xFFFF, self._channel_named("NRST")):
661
+ raise Reject(m.UNAVAILABLE)
662
+ got, ignored = t.tail({0x01, 0x03})
663
+ pair = self._pick_pair(fn, got)
664
+ tg = self.targets[(fn, pair)]
665
+ speed = min(4_000_000, struct.unpack("<I", got[0x01])[0]) if 0x01 in got else 4_000_000
666
+ cid = self._conn_at(fn, pair)
667
+ flags = 0
668
+ if cid is None:
669
+ if not tg.present:
670
+ return m.COMPLETED, m.FAILED, bytes([LINE])
671
+ cid = self._seat(fn, pair, tg, speed)
672
+ if tg.havereset and op == 0x02:
673
+ tg.havereset, flags = False, flags | 1
674
+ else:
675
+ flags |= 2
676
+ self.conns[cid].speed = min(self.conns[cid].speed, speed)
677
+ c = self.conns[cid]
678
+ c.users.add("host")
679
+ for n, s in self.slots.items(): # a new connection for an evicted slot: a new cue
680
+ if s.wire_fn == fn and s.pair == pair:
681
+ self.slot_rt[n].evicted = False
682
+ tid = b"" if c.tid is None else m.tlv(0x10, bytes([1]) + struct.pack("<I", c.tid))
683
+ if op == 0x04:
684
+ tg.halted, tg.dpc = True, tg.reset_vector
685
+ self._host_reset(cid)
686
+ self._refresh()
687
+ return self._answer(struct.pack("<HII", cid, tg.dpc, c.speed) + tid, ignored)
688
+ if method == 1:
689
+ tg.halted = True
690
+ self._refresh()
691
+ return self._answer(struct.pack("<HIBI", cid, tg.dmstatus(), flags, c.speed) + tid, ignored)
692
+ if op == 0x03: # detach
693
+ cid = t.take("H")
694
+ got, _ = t.tail({0x01})
695
+ c = self.conns.get(cid)
696
+ if c is None or c.fn != fn:
697
+ raise Reject(m.NO_CONNECTION)
698
+ c.users.discard("host")
699
+ if 0x01 in got or not c.users:
700
+ self._close_conn(cid, MARK["detach"])
701
+ self._refresh()
702
+ return m.COMPLETED, m.SUCCESS, b""
703
+ if op == 0x05: # connections (lock-free)
704
+ t.tail()
705
+ mine = sorted((c.order, cid) for cid, c in self.conns.items() if c.fn == fn)
706
+ out = bytearray([len(mine)])
707
+ for _, cid in mine:
708
+ c = self.conns[cid]
709
+ users = (1 if "host" in c.users else 0) | (2 if any(u != "host" for u in c.users) else 0)
710
+ slot = next((n for n, s in self.slots.items() if s.wire_fn == fn and s.pair == c.pair), NO_SLOT)
711
+ tid = b"" if c.tid is None else struct.pack("<I", c.tid)
712
+ out += struct.pack("<HHHIBBBB", cid, *c.pair, c.speed, users, slot, 1 if tid else 0, len(tid)) + tid
713
+ return m.COMPLETED, m.SUCCESS, bytes(out)
714
+ return m.REJECTED, m.UNKNOWN_OPERATION, b""
715
+
716
+ def _pick_pair(self, fn: int, got: dict[int, bytes]) -> tuple[int, int]:
717
+ allowed = self.pairs.get(fn, [])
718
+ if 0x03 in got:
719
+ if len(got[0x03]) != 4:
720
+ raise Reject(m.MALFORMED)
721
+ pair = struct.unpack("<HH", got[0x03])
722
+ if pair not in allowed:
723
+ raise Reject(m.UNAVAILABLE) # a pair this probe does not allow
724
+ return pair
725
+ if len(allowed) != 1:
726
+ raise Reject(m.UNAVAILABLE) # the host chooses among several
727
+ return allowed[0]
728
+
729
+ def _channel_named(self, name: str) -> int | None:
730
+ labels = dict(self.static_labels)
731
+ for (tag, key), value in self.config.items():
732
+ if tag == ITEM["label"]:
733
+ labels[key] = value[2:].decode("utf-8", "replace")
734
+ return next((ch for ch, n in labels.items() if n == name), None)
735
+
736
+ def _conn_at(self, fn: int, pair: tuple[int, int]) -> int | None:
737
+ return next((cid for cid, c in self.conns.items() if c.fn == fn and c.pair == pair), None)
738
+
739
+ def _seat(self, fn: int, pair: tuple[int, int], tg: FakeTarget, speed: int, evict: bool = True) -> int:
740
+ """A new connection; a full wire gives up its oldest slot-only connection (debug §1), if `evict`."""
741
+ mine = [(c.order, cid) for cid, c in self.conns.items() if c.fn == fn]
742
+ if len(mine) >= self.max_connections.get(fn, 1):
743
+ slot_only = sorted((o, cid) for o, cid in mine if "host" not in self.conns[cid].users)
744
+ if not evict or not slot_only:
745
+ raise Reject(m.UNAVAILABLE)
746
+ gone = self.conns[slot_only[0][1]]
747
+ for u in gone.users:
748
+ if u != "host":
749
+ self.slot_rt[u[1]].evicted = True
750
+ self._close_conn(slot_only[0][1], MARK["link_lost"])
751
+ if self._next_conn > 0xFFFF:
752
+ raise Reject(m.UNAVAILABLE) # every number used this boot (core §9)
753
+ cid, self._next_conn = self._next_conn, self._next_conn + 1
754
+ self._order += 1
755
+ self.conns[cid] = Connection(fn, pair, self._order, speed, tg.target_id)
756
+ return cid
757
+
758
+ def _close_conn(self, cid: int, mark: int) -> None:
759
+ self.conns.pop(cid, None)
760
+ for (c, _), sid in list(self.stream_keys.items()):
761
+ if c == cid and not self.streams[sid].closed:
762
+ self.streams[sid].add_mark(mark, self.now())
763
+ self.streams[sid].closed = True
764
+
765
+ def _target_of(self, cid: int) -> FakeTarget:
766
+ c = self.conns[cid]
767
+ return self.targets[(c.fn, c.pair)]
768
+
769
+ # ---- oep.target.riscv-dm --------------------------------------------------------------------
770
+ @staticmethod
771
+ def _outcome(status: int, done: int) -> int:
772
+ return m.SUCCESS if status == OK else (m.PARTIAL if done else m.FAILED)
773
+
774
+ def _dm(self, fn: int, op: int, t: Take) -> tuple[int, int, bytes]:
775
+ conn = t.take("H")
776
+ if conn not in self.conns:
777
+ raise Reject(m.NO_CONNECTION)
778
+ tg = self._target_of(conn)
779
+ if op == _RV.op["dmi"]:
780
+ n = t.take("H")
781
+ steps = []
782
+ for _ in range(n):
783
+ kind = t.take("B")
784
+ if kind not in STEP_ARGS:
785
+ raise Reject(m.MALFORMED)
786
+ steps.append((kind, t.take(STEP_ARGS[kind])))
787
+ t.tail()
788
+ done, status, values = 0, OK, []
789
+ for kind, args in steps:
790
+ if kind == STEP["write"]:
791
+ address, value = args
792
+ if address in tg.fail_write:
793
+ status = LINE
794
+ break
795
+ tg.dmi[address] = value
796
+ if address == 0x17 and value & 0xFFFF == 0x07B1: # access register dpc: into DATA0, not busy
797
+ tg.dmi[0x04], tg.dmi[0x16] = tg.dpc, 0
798
+ elif kind == STEP["read"]:
799
+ values.append(tg.read_dmi(args))
800
+ elif kind in (STEP["poll_reads"], STEP["poll_us"]):
801
+ address, mask, want, limit = args
802
+ tries = limit if kind == STEP["poll_reads"] else max(1, limit // 100)
803
+ for _ in range(max(1, tries)):
804
+ v = tg.read_dmi(address)
805
+ if v & mask == want:
806
+ break
807
+ values.append(v)
808
+ if v & mask != want:
809
+ status = TIMEOUT
810
+ break
811
+ done += 1
812
+ return m.COMPLETED, self._outcome(status, done), struct.pack(f"<HB{len(values)}I", done, status, *values)
813
+ if op == _RV.op["halt"]:
814
+ t.tail()
815
+ tg.halted = True
816
+ return m.COMPLETED, m.SUCCESS, bytes([OK])
817
+ if op == _RV.op["resume"]:
818
+ t.tail()
819
+ if tg.resume_misses: # a CH32V006 now and then: the request does not take
820
+ tg.resume_misses -= 1
821
+ return m.COMPLETED, m.FAILED, bytes([STATE])
822
+ if tg.halted:
823
+ tg.halted, tg.dpc = False, tg.dpc + 0x40
824
+ return m.COMPLETED, m.SUCCESS, bytes([OK])
825
+ if op == _RV.op["reset"]:
826
+ mode = t.take("B")
827
+ got, ignored = t.tail({_RV.tlv["reset"]["method"]})
828
+ if mode > 2:
829
+ raise Reject(m.UNSUPPORTED)
830
+ method = got.get(_RV.tlv["reset"]["method"])
831
+ if method is not None and (len(method) != 1 or method[0] > 2):
832
+ t.refuse(_RV.tlv["reset"]["method"], got, ignored)
833
+ tg.havereset = True
834
+ tg.halted = mode == 2
835
+ tg.dpc = tg.reset_vector if mode == 2 else tg.reset_vector + 0x200
836
+ # debug §4.3: bit0 reached the mode's state, bit1 confirmed by the pc (mode 1), attempts 1
837
+ flags, pc = (0b01, 0) if mode == 0 else ((0b11, tg.dpc) if mode == 1 else (0b01, tg.dpc))
838
+ self._host_reset(conn)
839
+ return self._answer(struct.pack("<BBBI", OK, flags, 1, pc), ignored)
840
+ if op == _RV.op["step"]:
841
+ t.tail()
842
+ if not tg.halted:
843
+ return m.COMPLETED, m.FAILED, struct.pack("<BBII", STATE, 0, tg.dpc, tg.dpc)
844
+ before = tg.dpc
845
+ tg.dpc += 4
846
+ return m.COMPLETED, m.SUCCESS, struct.pack("<BBII", OK, 1, before, tg.dpc)
847
+ if op == _RV.op["read_block"]:
848
+ address, count = t.take("IH")
849
+ t.tail()
850
+ if 4 * count > self.block_max.get(fn, 1 << 16):
851
+ raise Reject(m.MALFORMED) # past the declared max_length (bytes)
852
+ words, status = [], OK
853
+ for i in range(count):
854
+ if address + 4 * i in tg.fault_at:
855
+ status = FAULT
856
+ break
857
+ words.append(tg.mem.get(address + 4 * i, 0))
858
+ return (m.COMPLETED, self._outcome(status, len(words)),
859
+ struct.pack(f"<HB{len(words)}I", len(words), status, *words))
860
+ if op == _RV.op["write_block"]:
861
+ address, count = t.take("IH")
862
+ if 4 * count > self.block_max.get(fn, 1 << 16):
863
+ raise Reject(m.MALFORMED)
864
+ words = struct.unpack(f"<{count}I", t.bytes(4 * count))
865
+ t.tail()
866
+ done, status = 0, OK
867
+ for i, w in enumerate(words):
868
+ if address + 4 * i in tg.fault_at:
869
+ status = FAULT
870
+ break
871
+ tg.mem[address + 4 * i] = w
872
+ done += 1
873
+ return m.COMPLETED, self._outcome(status, done), struct.pack("<HB", done, status)
874
+ if op == _RV.op["run"]:
875
+ pc, timeout_ms, n = t.take("IIB")
876
+ regs = dict(t.take("HI") for _ in range(n))
877
+ n_out = t.take("B")
878
+ outs = [t.take("H") for _ in range(n_out)]
879
+ t.tail()
880
+ if not tg.halted:
881
+ return m.COMPLETED, m.FAILED, struct.pack(f"<BBII{n_out}I", STATE, 0, tg.dpc, 0, *[0] * n_out)
882
+ tg.regs.update(regs)
883
+ stopped, dpc, us = tg.run_hook(pc, tg.regs) if tg.run_hook else (True, pc + 0x10, 50)
884
+ tg.dpc = dpc
885
+ status = OK if stopped else TIMEOUT
886
+ values = [tg.regs.get(r, 0) for r in outs]
887
+ return (m.COMPLETED, m.SUCCESS if stopped else m.FAILED,
888
+ struct.pack(f"<BBII{n_out}I", status, int(stopped), dpc, us, *values))
889
+ return m.REJECTED, m.UNKNOWN_OPERATION, b""
890
+
891
+ def _host_reset(self, cid: int) -> None:
892
+ """A reset last-reset counts (probe.config §1.2): riscv-dm reset or attach_under_reset on connection `cid`."""
893
+ now = self.now()
894
+ slots = [n for n, s in self.slots.items() if (s.wire_fn, s.pair) == (self.conns[cid].fn, self.conns[cid].pair)]
895
+ for (c, _), sid in self.stream_keys.items():
896
+ if c == cid and not self.streams[sid].closed:
897
+ self.streams[sid].add_mark(MARK["reset"], now)
898
+ for port, b in self.binds.items():
899
+ if b.mode == BIND_MODE["last_reset"]:
900
+ for i, key in enumerate(b.streams):
901
+ if key[0] == BIND_STREAM["slot_console"] and key[1] in slots:
902
+ self.selected[port] = i
903
+ if self.holder is not None:
904
+ for n in slots:
905
+ key = (BIND_STREAM["slot_console"], n)
906
+ sid, s = self._stream_for(key)
907
+ if s is not None:
908
+ self.session_resets[key] = (sid, s.end)
909
+ for fn, s in self.uarts.items():
910
+ self.session_resets[(BIND_STREAM["fixture_uart"], fn)] = (("uart", fn), s.end)
911
+
912
+ # ---- position streams (console, fixture.uart) -----------------------------------------------
913
+ def _stream_op(self, s: Stream, op: int, t: Take, accept: int) -> tuple[int, int, bytes]:
914
+ if op == _CON.op["read"]:
915
+ frm, arg, mx = t.take("BQH")
916
+ t.tail()
917
+ if frm == 0:
918
+ pos = arg
919
+ elif frm == 1:
920
+ pos = s.base
921
+ elif frm == 2:
922
+ pos = s.end
923
+ elif frm == 3:
924
+ hits = [mk for mk in s.marks if arg == 0 or mk[2] == arg]
925
+ pos = hits[-1][1] if hits else s.base
926
+ else:
927
+ raise Reject(m.UNSUPPORTED)
928
+ flags = 0
929
+ if pos < s.base:
930
+ pos, flags = s.base, 2
931
+ data = bytes(s.data[pos - s.base:pos - s.base + mx])
932
+ if pos + len(data) < s.end:
933
+ flags |= 1
934
+ return m.COMPLETED, m.SUCCESS, struct.pack("<QB", pos, flags) + data
935
+ if op == _CON.op["marks"]:
936
+ frm = t.take("I")
937
+ _, ignored = t.tail()
938
+ hits = [mk for mk in s.marks if m.serial_diff(mk[0], frm) >= 0]
939
+ page = hits[:self.MARKS_PER_ANSWER]
940
+ body = struct.pack("<BB", int(len(hits) > len(page)), len(page))
941
+ body += b"".join(struct.pack("<IQBIB", *mk) for mk in page)
942
+ return self._answer(body, ignored)
943
+ if s.closed:
944
+ raise Reject(m.UNAVAILABLE)
945
+ if op == _CON.op["clear"]:
946
+ t.tail()
947
+ s.drop_oldest(len(s.data))
948
+ s.add_mark(MARK["clear"], self.now())
949
+ return m.COMPLETED, m.SUCCESS, b""
950
+ if op == _CON.op["mark"]:
951
+ value = t.take("B")
952
+ t.tail()
953
+ s.add_mark(MARK["host"], self.now(), value)
954
+ return m.COMPLETED, m.SUCCESS, b""
955
+ if op == _CON.op["write"]:
956
+ count = t.take("H")
957
+ data = t.bytes(count)
958
+ _, ignored = t.tail()
959
+ took = min(count, accept)
960
+ s.written += data[:took]
961
+ return self._answer(struct.pack("<H", took), ignored, m.SUCCESS if took == count else m.PARTIAL)
962
+ return m.REJECTED, m.UNKNOWN_OPERATION, b""
963
+
964
+ # ---- oep.target.console ---------------------------------------------------------------------
965
+ def _console(self, fn: int, op: int, t: Take) -> tuple[int, int, bytes]:
966
+ if op == _CON.op["open"]:
967
+ conn, mech = t.take("HB")
968
+ _, ignored = t.tail()
969
+ if conn not in self.conns:
970
+ raise Reject(m.NO_CONNECTION)
971
+ if mech not in self.mechanisms:
972
+ raise Reject(m.UNSUPPORTED)
973
+ sid, existing = self._open_stream(conn, mech)
974
+ return self._answer(struct.pack("<HB", sid, int(existing)), ignored)
975
+ sid = t.take("H")
976
+ s = self.streams.get(sid)
977
+ if s is None:
978
+ raise Reject(m.UNAVAILABLE)
979
+ if op == _CON.op["close"]:
980
+ t.tail()
981
+ s.closed = True
982
+ return m.COMPLETED, m.SUCCESS, b""
983
+ return self._stream_op(s, op, t, self.console_accept)
984
+
985
+ def _open_stream(self, conn: int, mech: int) -> tuple[int, bool]:
986
+ sid = self.stream_keys.get((conn, mech))
987
+ if sid is not None and not self.streams[sid].closed:
988
+ return sid, True
989
+ place = (self.conns[conn].fn, self.conns[conn].pair)
990
+ for key in [k for k, v in self.stream_keys.items() # a closed one of this mechanism on this place goes
991
+ if k[1] == mech and self.streams[v].closed and self.stream_places.get(v) == place]:
992
+ sid = self.stream_keys.pop(key)
993
+ self.streams.pop(sid, None)
994
+ self.stream_places.pop(sid, None)
995
+ sid = self._next_stream
996
+ self._next_stream += 1 # never reused within a boot (core §9)
997
+ self.streams[sid], self.stream_keys[(conn, mech)] = Stream(), sid
998
+ self.stream_places[sid] = place
999
+ self.streams[sid].add_mark(MARK["attach"], self.now())
1000
+ return sid, False
1001
+
1002
+ def emit(self, sid: int, data: bytes) -> None:
1003
+ """The target writes to its console stream `sid`."""
1004
+ self.streams[sid].data += data
1005
+
1006
+ def target_says(self, data: bytes, target: FakeTarget | None = None) -> None:
1007
+ """The target (the first one by default) writes to its console: every open stream on a connection to it."""
1008
+ target = target or self.target
1009
+ for (cid, _), sid in self.stream_keys.items():
1010
+ if cid in self.conns and self._target_of(cid) is target and not self.streams[sid].closed:
1011
+ self.streams[sid].data += data
1012
+
1013
+ # ---- oep.fixture.gpio -----------------------------------------------------------------------
1014
+ def _gpio(self, fn: int, op: int, t: Take) -> tuple[int, int, bytes]:
1015
+ mine = {ch for f, role, ch in self.plan if f == fn and role == 1}
1016
+ if op == _GPIO.op["set"]:
1017
+ n = t.take("B")
1018
+ pairs = [t.take("HB") for _ in range(n)]
1019
+ t.tail()
1020
+ for i, (ch, mode) in enumerate(pairs):
1021
+ if ch not in mine or mode > 7:
1022
+ raise Reject(m.UNAVAILABLE, bytes([i]))
1023
+ for ch, mode in pairs:
1024
+ self.gpio_modes[ch] = mode
1025
+ self.gpio_log.append((ch, mode))
1026
+ return m.COMPLETED, m.SUCCESS, b""
1027
+ if op == _GPIO.op["read"]:
1028
+ n = t.take("B")
1029
+ chans = [t.take("H") for _ in range(n)]
1030
+ _, ignored = t.tail()
1031
+ for i, ch in enumerate(chans):
1032
+ if ch not in mine:
1033
+ raise Reject(m.UNAVAILABLE, bytes([i]))
1034
+ levels = []
1035
+ for ch in chans:
1036
+ mode = self.gpio_modes.get(ch, 0)
1037
+ levels.append({1: 1, 3: 0, 4: 1, 5: 0, 6: 1}.get(mode, self.gpio_inputs.get(ch, 0)))
1038
+ return self._answer(bytes(levels), ignored)
1039
+ return m.REJECTED, m.UNKNOWN_OPERATION, b""
1040
+
1041
+ # ---- oep.fixture.uart -----------------------------------------------------------------------
1042
+ def _uart(self, fn: int, op: int, t: Take) -> tuple[int, int, bytes]:
1043
+ if op == _UART.op["configure"]:
1044
+ baud = t.take("I")
1045
+ got, ignored = t.tail({_UART.tlv["configure"]["format"]})
1046
+ if not any(f == fn for f, _, _ in self.plan) or baud == 0:
1047
+ raise Reject(m.UNAVAILABLE)
1048
+ fmt = got.get(_UART.tlv["configure"]["format"], b"\0")
1049
+ if len(fmt) != 1 or fmt[0] & ~0x1F or fmt[0] & 3 > 1 or (fmt[0] >> 2) & 3 > 2:
1050
+ t.refuse(_UART.tlv["configure"]["format"], got, ignored)
1051
+ fmt = bytes(1)
1052
+ actual = 80_000_000 // (80_000_000 // baud)
1053
+ self.uart_baud[fn] = (actual, fmt[0])
1054
+ self.uarts.setdefault(fn, Stream())
1055
+ return self._answer(struct.pack("<I", actual), ignored)
1056
+ s = self.uarts.get(fn)
1057
+ if s is None:
1058
+ raise Reject(m.UNAVAILABLE)
1059
+ return self._stream_op(s, op, t, self.uart_accept)
1060
+
1061
+ def uart_rx(self, fn: int, data: bytes) -> None:
1062
+ """Bytes arrive on fixture UART `fn`'s RX."""
1063
+ self.uarts[fn].data += data
1064
+
1065
+ # ---- oep.probe.config -----------------------------------------------------------------------
1066
+ def _config_op(self, fn: int, op: int, t: Take) -> tuple[int, int, bytes]:
1067
+ if op == _CFG.op["get"]:
1068
+ first = t.take("H")
1069
+ t.tail()
1070
+ items = self._canonical(self.config)
1071
+ budget = self.probe.max_frame - m.RESULT_HEADER - 5
1072
+ out, sent = b"", 0
1073
+ for item in items[first:]:
1074
+ if out and len(out) + len(item) > budget:
1075
+ break
1076
+ out += item
1077
+ sent += 1
1078
+ more = 1 if first + sent < len(items) else 0
1079
+ return m.COMPLETED, m.SUCCESS, struct.pack("<BI", more, self._hash(self.config)) + out
1080
+ if op == _CFG.op["set"]:
1081
+ new = dict(self.config)
1082
+ seen, plan_roles, plans = set(), set(), {}
1083
+ for tag, value in m.split_tlvs(t.data) if t.data else []:
1084
+ tag &= 0x7F # kept without the critical bit
1085
+ if tag not in self.items:
1086
+ raise Reject(m.UNSUPPORTED, bytes([tag]))
1087
+ key = self._item_key(tag, value)
1088
+ if tag == ITEM["plan"]: # one item per assignment; fn alone = no plan
1089
+ plans.setdefault(key, [])
1090
+ if len(value) != 2:
1091
+ if len(value) != 5 or (key, value[2]) in plan_roles:
1092
+ raise Reject(m.MALFORMED)
1093
+ plan_roles.add((key, value[2]))
1094
+ plans[key].append(value)
1095
+ continue
1096
+ if (tag, key) in seen:
1097
+ raise Reject(m.MALFORMED) # the same key twice in one set
1098
+ seen.add((tag, key))
1099
+ if len(value) == self._key_len(tag):
1100
+ new.pop((tag, key), None) # the key alone removes the item
1101
+ else:
1102
+ new[(tag, key)] = value
1103
+ for fn, values in plans.items():
1104
+ new.pop((ITEM["plan"], fn), None)
1105
+ if values:
1106
+ new[(ITEM["plan"], fn)] = values
1107
+ self._apply_config(new, changed_slots={k for t_, k in seen if t_ == ITEM["slot"]})
1108
+ return m.COMPLETED, m.SUCCESS, struct.pack("<I", self._hash(self.config))
1109
+ if op == _CFG.op["save"]:
1110
+ t.tail()
1111
+ if len(b"".join(self._canonical(self.config))) > self.storage_max:
1112
+ raise Reject(m.UNAVAILABLE)
1113
+ self.saved = dict(self.config)
1114
+ return m.COMPLETED, m.SUCCESS, struct.pack("<I", self._hash(self.config))
1115
+ if op == _CFG.op["erase"]:
1116
+ t.tail()
1117
+ self.saved = None
1118
+ return m.COMPLETED, m.SUCCESS, b""
1119
+ return m.REJECTED, m.UNKNOWN_OPERATION, b""
1120
+
1121
+ @staticmethod
1122
+ def _key_len(tag: int) -> int:
1123
+ return 1 if tag in (ITEM["slot"], ITEM["bind"]) else 2
1124
+
1125
+ def _item_key(self, tag: int, value: bytes) -> int:
1126
+ if len(value) < self._key_len(tag):
1127
+ raise Reject(m.MALFORMED)
1128
+ return value[0] if self._key_len(tag) == 1 else struct.unpack_from("<H", value)[0]
1129
+
1130
+ @staticmethod
1131
+ def _canonical(config: dict) -> list[bytes]:
1132
+ """probe.config §2: tag order, then key order (plan by (fn, role)); TLVs without the critical bit."""
1133
+ rows = []
1134
+ for (tag, key), value in config.items():
1135
+ for v in (value if isinstance(value, list) else [value]):
1136
+ rows.append((tag, key, v[2] if isinstance(value, list) else 0, bytes([tag, len(v)]) + v))
1137
+ return [r[3] for r in sorted(rows)]
1138
+
1139
+ def _hash(self, config: dict | None) -> int:
1140
+ return zlib.crc32(b"".join(self._canonical(config or {})))
1141
+
1142
+ def load_config(self, items: list[bytes], saved: bool = True) -> None:
1143
+ """Put `items` (item TLVs) in as the config, as a set would; with `saved` they are also the saved config
1144
+ (a probe that booted with them)."""
1145
+ new: dict = {}
1146
+ for tag, value in m.split_tlvs(b"".join(items)):
1147
+ key = self._item_key(tag, value)
1148
+ if tag == ITEM["plan"]:
1149
+ new.setdefault((tag, key), []).append(value)
1150
+ else:
1151
+ new[(tag, key)] = value
1152
+ self._apply_config(new, changed_slots=None)
1153
+ if saved:
1154
+ self.saved = dict(self.config)
1155
+
1156
+ def _apply_config(self, new: dict, changed_slots: set[int] | None = None, boot: bool = False) -> None:
1157
+ """Check the whole config, then make it the current one (set is all-or-nothing up to reserving resources);
1158
+ automatic attaches and console opens follow (and are not rolled back)."""
1159
+ slots, binds = {}, {}
1160
+ for (tag, key), value in new.items():
1161
+ if tag == ITEM["slot"]:
1162
+ slots[key] = self._parse_slot(value)
1163
+ names = [s.name for s in slots.values()]
1164
+ if len(set(names)) != len(names):
1165
+ raise Reject(m.MALFORMED)
1166
+ places = [(s.wire_fn, s.pair) for s in slots.values()]
1167
+ if len(set(places)) != len(places):
1168
+ raise Reject(m.UNAVAILABLE)
1169
+ for fn in self.pairs:
1170
+ if sum(1 for s in slots.values() if s.wire_fn == fn and s.attach == SLOT_ATTACH["at_boot"]) > \
1171
+ self.max_connections.get(fn, 1):
1172
+ raise Reject(m.UNAVAILABLE)
1173
+ for (tag, key), value in new.items():
1174
+ if tag == ITEM["bind"]:
1175
+ binds[key] = self._parse_bind(value, slots)
1176
+ plans: dict[int, list[tuple[int, int, int]]] = {}
1177
+ for (tag, key), value in new.items():
1178
+ if tag == ITEM["plan"]:
1179
+ plans[key] = [struct.unpack_from("<HBH", v) for v in value if len(v) >= 5]
1180
+ if any(len(v) != 5 for v in value):
1181
+ raise Reject(m.MALFORMED)
1182
+ elif tag in (ITEM["label"], ITEM["idle"]):
1183
+ if tag == ITEM["idle"] and (len(value) != 3 or value[2] > 2):
1184
+ raise Reject(m.MALFORMED)
1185
+ old_plan_fns = {k[1] for k in self.config if k[0] == ITEM["plan"]}
1186
+ want = [a for fn in plans for a in plans[fn]]
1187
+ self.slots = slots # the pin check below sees the new slots
1188
+ try:
1189
+ self._check_plan(want)
1190
+ except Reject:
1191
+ self.slots = {k: self._parse_slot(v) for (t, k), v in self.config.items() if t == ITEM["slot"]}
1192
+ raise
1193
+ # accepted: make it current
1194
+ for fn in old_plan_fns - set(plans):
1195
+ self._drop_plan(fn)
1196
+ for fn, assigned in plans.items():
1197
+ self.plan = {a for a in self.plan if a[0] != fn} | set(assigned)
1198
+ self.plan_from_config.add(fn)
1199
+ self.config = new
1200
+ for n in list(self.slot_rt):
1201
+ if n not in slots:
1202
+ del self.slot_rt[n]
1203
+ for n in slots:
1204
+ self.slot_rt.setdefault(n, SlotRuntime())
1205
+ for port, b in binds.items():
1206
+ if port not in self.binds or self.binds[port] != b:
1207
+ self.selected[port] = b.selected if b.mode == BIND_MODE["manual"] else 0
1208
+ for key in b.streams:
1209
+ self.flows.pop((port, key), None)
1210
+ self.mixed_out.pop(port, None)
1211
+ self.binds = binds
1212
+ for n, s in slots.items():
1213
+ if s.attach == SLOT_ATTACH["at_boot"] and (boot or changed_slots is None or n in changed_slots):
1214
+ self.slot_rt[n].evicted = False
1215
+ self._auto_attach(n)
1216
+ self._refresh()
1217
+
1218
+ def _parse_slot(self, v: bytes) -> Slot:
1219
+ t = Take(v)
1220
+ n, wire_fn, swdio, swclk, attach, retry_s, mech, name_len = t.take("BHHHBHBB")
1221
+ name = t.bytes(name_len)
1222
+ scheme = t.take("B")
1223
+ rest = v[t.at:]
1224
+ if n >= self.slots_max or attach not in SLOT_ATTACH.values():
1225
+ raise Reject(m.MALFORMED)
1226
+ if retry_s and attach != SLOT_ATTACH["at_boot"]:
1227
+ raise Reject(m.MALFORMED)
1228
+ if not SLOT_NAME.fullmatch(name.decode("ascii", "replace")):
1229
+ raise Reject(m.MALFORMED)
1230
+ if self.names.get(wire_fn) not in WIRES or (swdio, swclk) not in self.pairs.get(wire_fn, []):
1231
+ raise Reject(m.UNAVAILABLE)
1232
+ if mech not in self.mechanisms:
1233
+ raise Reject(m.UNSUPPORTED)
1234
+ lock = None
1235
+ if scheme:
1236
+ if not rest or len(rest) % 2:
1237
+ raise Reject(m.MALFORMED)
1238
+ lock = (scheme, rest[:len(rest) // 2], rest[len(rest) // 2:])
1239
+ elif rest:
1240
+ raise Reject(m.MALFORMED)
1241
+ return Slot(n, wire_fn, (swdio, swclk), attach, retry_s, mech, name.decode(), lock)
1242
+
1243
+ def _parse_bind(self, v: bytes, slots: dict[int, Slot]) -> Bind:
1244
+ t = Take(v)
1245
+ port, mode, selected, n = t.take("BBBB")
1246
+ streams = tuple(t.take("BH") for _ in range(n))
1247
+ if t.at != len(v) or n == 0:
1248
+ raise Reject(m.MALFORMED)
1249
+ if port not in self.serial_ports:
1250
+ raise Reject(m.UNAVAILABLE)
1251
+ if mode not in BIND_MODE.values() or not self.bind_modes & (1 << mode):
1252
+ raise Reject(m.UNSUPPORTED)
1253
+ if mode == BIND_MODE["manual"] and selected >= n:
1254
+ raise Reject(m.MALFORMED)
1255
+ for kind, i in streams:
1256
+ if kind == BIND_STREAM["slot_console"]:
1257
+ if i not in slots:
1258
+ raise Reject(m.UNAVAILABLE)
1259
+ elif kind == BIND_STREAM["fixture_uart"]:
1260
+ if self.names.get(i) != "oep.fixture.uart":
1261
+ raise Reject(m.UNAVAILABLE)
1262
+ else:
1263
+ raise Reject(m.MALFORMED)
1264
+ return Bind(port, mode, selected if mode == BIND_MODE["manual"] else 0, streams)
1265
+
1266
+ # ---- slots: automatic attach, the lock check, what uses a connection ------------------------
1267
+ @staticmethod
1268
+ def _lock_ok(s: Slot, tid: int | None) -> bool | None:
1269
+ """True / False; None = the slot has a lock and there is no target_id to check."""
1270
+ if s.lock is None:
1271
+ return True
1272
+ if tid is None:
1273
+ return None
1274
+ scheme, mask, value = s.lock
1275
+ raw = struct.pack("<I", tid)
1276
+ if scheme != 1 or len(mask) != len(raw):
1277
+ return False
1278
+ return bytes(a & b for a, b in zip(raw, mask)) == value
1279
+
1280
+ def _bound(self, n: int) -> bool:
1281
+ return any((BIND_STREAM["slot_console"], n) in b.streams for b in self.binds.values())
1282
+
1283
+ def _auto_attach(self, n: int) -> None:
1284
+ s = self.slots[n]
1285
+ rt = self.slot_rt[n]
1286
+ rt.last_try_ms = self.now()
1287
+ tg = self.targets[(s.wire_fn, s.pair)]
1288
+ cid = self._conn_at(s.wire_fn, s.pair)
1289
+ if cid is None:
1290
+ if not tg.present:
1291
+ return
1292
+ try:
1293
+ cid = self._seat(s.wire_fn, s.pair, tg, 4_000_000, evict=False) # automatic: never evicts
1294
+ except Reject:
1295
+ return
1296
+ tg.havereset = False
1297
+ c = self.conns[cid]
1298
+ if self._lock_ok(s, c.tid) is True:
1299
+ c.users.add(("slot", n))
1300
+ rt.mismatch_tid = None
1301
+ else:
1302
+ rt.mismatch_tid = c.tid # found, wrong chip: let go of it
1303
+ if not c.users:
1304
+ self._close_conn(cid, MARK["detach"])
1305
+
1306
+ def _refresh(self) -> None:
1307
+ """Make what the slots use match the config: a bound slot rides any connection on its place (lock
1308
+ permitting) with its console open; an at-boot slot keeps its automatic connection; nothing else."""
1309
+ for cid, c in list(self.conns.items()):
1310
+ for u in [u for u in c.users if u != "host"]:
1311
+ s = self.slots.get(u[1])
1312
+ if (s is None or (s.wire_fn, s.pair) != (c.fn, c.pair) or self._lock_ok(s, c.tid) is not True
1313
+ or not (s.attach == SLOT_ATTACH["at_boot"] or self._bound(u[1]))):
1314
+ c.users.discard(u)
1315
+ if not c.users:
1316
+ self._close_conn(cid, MARK["detach"])
1317
+ for n, s in self.slots.items():
1318
+ cid = self._conn_at(s.wire_fn, s.pair)
1319
+ if cid is None or self._lock_ok(s, self.conns[cid].tid) is not True or not self._bound(n):
1320
+ continue
1321
+ self.conns[cid].users.add(("slot", n))
1322
+ sid, existing = self._open_stream(cid, s.mechanism)
1323
+ if not existing:
1324
+ for port, b in self.binds.items():
1325
+ key = (BIND_STREAM["slot_console"], n)
1326
+ if key in b.streams:
1327
+ self.flows[(port, key)] = Flow(sid, 0)
1328
+
1329
+ def tick(self) -> None:
1330
+ """Time passes: the lease, at-boot retries, mixed lines closed by quiet."""
1331
+ self._lapse()
1332
+ now = self.now()
1333
+ for n, s in self.slots.items():
1334
+ rt = self.slot_rt[n]
1335
+ if (s.attach == SLOT_ATTACH["at_boot"] and s.retry_s and not rt.evicted
1336
+ and self._conn_at(s.wire_fn, s.pair) is None
1337
+ and (rt.last_try_ms is None or now - rt.last_try_ms >= 1000 * s.retry_s)):
1338
+ self._auto_attach(n)
1339
+ self._refresh()
1340
+
1341
+ def _slot_state(self, n: int) -> bytes:
1342
+ s, rt = self.slots[n], self.slot_rt[n]
1343
+ cid = self._conn_at(s.wire_fn, s.pair)
1344
+ tid = self.conns[cid].tid if cid is not None else rt.mismatch_tid
1345
+ if cid is not None:
1346
+ ok = self._lock_ok(s, tid)
1347
+ state = SLOT_STATE["connected"] if ok else (SLOT_STATE["no_target_id"] if ok is None
1348
+ else SLOT_STATE["lock_mismatch"])
1349
+ else:
1350
+ state = SLOT_STATE["lock_mismatch"] if rt.mismatch_tid is not None else SLOT_STATE["absent"]
1351
+ age = NEVER if rt.last_try_ms is None else min(NEVER - 1, self.now() - rt.last_try_ms)
1352
+ raw = b"" if tid is None else struct.pack("<I", tid)
1353
+ return struct.pack("<BBHIBB", n, state, cid or 0, age, 1 if raw else 0, len(raw)) + raw
1354
+
1355
+ def _bind_state(self, port: int) -> bytes:
1356
+ b = self.binds[port]
1357
+ mixed = b.mode == BIND_MODE["mixed"]
1358
+ selected = 0xFF if mixed else self.selected.get(port, 0)
1359
+ if port in self.held_ports and self.holder is not None:
1360
+ flow = BIND_FLOW["held"]
1361
+ else:
1362
+ keys = b.streams if mixed else (b.streams[selected],)
1363
+ flow = BIND_FLOW["streaming"] if any(self._stream_for(k)[1] is not None for k in keys) else BIND_FLOW["idle"]
1364
+ return struct.pack("<BBBB", port, b.mode, selected, flow)
1365
+
1366
+ # ---- serial ports: the raw bytes outside the frames (core §3.4, probe.config §1.2) ----------
1367
+ def _stream_for(self, key: tuple[int, int]) -> tuple[object, Stream | None]:
1368
+ kind, i = key
1369
+ if kind == BIND_STREAM["fixture_uart"]:
1370
+ return ("uart", i), self.uarts.get(i)
1371
+ s = self.slots.get(i)
1372
+ if s is None:
1373
+ return None, None
1374
+ cid = self._conn_at(s.wire_fn, s.pair)
1375
+ sid = self.stream_keys.get((cid, s.mechanism)) if cid is not None else None
1376
+ if sid is None or self.streams[sid].closed:
1377
+ return None, None
1378
+ return sid, self.streams[sid]
1379
+
1380
+ def _flow(self, port: int, key: tuple[int, int]) -> tuple[Flow, Stream | None]:
1381
+ sid, s = self._stream_for(key)
1382
+ f = self.flows.get((port, key))
1383
+ if s is None:
1384
+ return f or Flow(), None
1385
+ if f is None or f.sid != sid:
1386
+ f = self.flows[(port, key)] = Flow(sid, s.end, last_ms=self.now()) # first seen: from now
1387
+ if f.pos < s.base:
1388
+ f.pos = s.base # overflowed past the port: the oldest left
1389
+ return f, s
1390
+
1391
+ def port_held(self, port: int) -> bool:
1392
+ return self.holder is not None and port in self.held_ports
1393
+
1394
+ def port_output(self, port: int, room: int | None = None) -> bytes:
1395
+ """The raw bytes serial port `port` sends now by its bind (empty while a session holds it)."""
1396
+ room = self.CHUNK if room is None else room
1397
+ b = self.binds.get(port)
1398
+ if b is None or self.port_held(port) or room <= 0:
1399
+ return b""
1400
+ if b.mode != BIND_MODE["mixed"]:
1401
+ f, s = self._flow(port, b.streams[self.selected.get(port, 0)])
1402
+ if s is None:
1403
+ return b""
1404
+ out = bytes(s.data[f.pos - s.base:f.pos - s.base + room])
1405
+ f.pos += len(out)
1406
+ return out
1407
+ pending = self.mixed_out.setdefault(port, bytearray())
1408
+ now = self.now()
1409
+ for key in b.streams:
1410
+ f, s = self._flow(port, key)
1411
+ if s is None:
1412
+ continue
1413
+ new = bytes(s.data[f.pos - s.base:])
1414
+ f.pos += len(new)
1415
+ if new:
1416
+ f.line += new
1417
+ f.last_ms = now
1418
+ while True:
1419
+ cut = f.line.find(b"\n")
1420
+ if cut < 0 and len(f.line) < self.MIXED_LINE_MAX and not (f.line and now - f.last_ms >= self.MIXED_QUIET_MS):
1421
+ break
1422
+ if not f.line:
1423
+ break
1424
+ end = cut + 1 if cut >= 0 else min(len(f.line), self.MIXED_LINE_MAX)
1425
+ line = bytes(f.line[:end])
1426
+ del f.line[:end]
1427
+ pending += b"[" + self._mixed_name(key).encode() + b"] " + line + (b"" if line.endswith(b"\n") else b"\n")
1428
+ out = bytes(pending[:room])
1429
+ del pending[:room]
1430
+ return out
1431
+
1432
+ def _mixed_name(self, key: tuple[int, int]) -> str:
1433
+ kind, i = key
1434
+ if kind == BIND_STREAM["slot_console"]:
1435
+ return self.slots[i].name
1436
+ rx = next((ch for fn, role, ch in self.plan if fn == i and role == 1), None)
1437
+ label = None
1438
+ if rx is not None:
1439
+ label = self.static_labels.get(rx)
1440
+ v = self.config.get((ITEM["label"], rx))
1441
+ if v is not None:
1442
+ label = v[2:].decode("utf-8", "replace")
1443
+ if not label:
1444
+ return f"uart{i}"
1445
+ return "".join("_" if c == "]" or ord(c) < 0x20 else c for c in label)
1446
+
1447
+ def port_input(self, port: int, data: bytes) -> None:
1448
+ """Raw bytes that came in on serial port `port` outside any frame."""
1449
+ b = self.binds.get(port)
1450
+ if b is None or self.port_held(port) or b.mode == BIND_MODE["mixed"] or not data:
1451
+ return
1452
+ key = b.streams[self.selected.get(port, 0)]
1453
+ sid, s = self._stream_for(key)
1454
+ if s is None:
1455
+ return
1456
+ if key[0] == BIND_STREAM["fixture_uart"]:
1457
+ self.uart_tx.setdefault(key[1], bytearray()).extend(data)
1458
+ else:
1459
+ s.written += data
1460
+
1461
+ def _session_over(self) -> None:
1462
+ """The session ended (end, lapse, force): the ports it held resume from its last host reset (or now)."""
1463
+ for port in self.held_ports:
1464
+ b = self.binds.get(port)
1465
+ if b is None:
1466
+ continue
1467
+ for key in b.streams:
1468
+ sid, s = self._stream_for(key)
1469
+ if s is None:
1470
+ continue
1471
+ rsid, pos = self.session_resets.get(key, (sid, s.end))
1472
+ self.flows[(port, key)] = Flow(sid, pos if rsid == sid else s.end, last_ms=self.now())
1473
+ self.held_ports.clear()
1474
+ self.session_resets.clear()
1475
+
1476
+
1477
+ SIMS = {"oep.wire.rvswd": "wire", "oep.wire.swio": "wire", "oep.target.riscv-dm": "dm",
1478
+ "oep.target.console": "console", "oep.fixture.gpio": "gpio", "oep.fixture.uart": "uart",
1479
+ "oep.probe.config": "config_op"}