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,137 @@
1
+ """The ESP32 I2C / SPI slave tools of oep-probe-arduino, custom interfaces revision 1:
2
+ io.github.ch32-riscv-ug.esp32.i2c-target and io.github.ch32-riscv-ug.esp32.spi-target (OepP4I2cTarget.h /
3
+ OepP4SpiTarget.h). A DUT's I2C / SPI controller talks to them; the host arms what they answer and reads what they got.
4
+ Variable byte lists carry a count(u16) in front (oep-core §2.3)."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import struct
9
+ from dataclasses import dataclass
10
+
11
+ from . import host as h, message as m
12
+ from .core import Interface
13
+
14
+ NS = "io.github.ch32-riscv-ug.esp32"
15
+
16
+
17
+ @dataclass
18
+ class I2cStatus:
19
+ flags: int # bit0 started, bits 1-2 mode, bit4 armed, bits 5-7 frames queued
20
+ rx_frames: int
21
+ tx_slots: int
22
+ errors: int
23
+
24
+
25
+ @dataclass
26
+ class I2cRegisters:
27
+ sr: int
28
+ int_raw: int
29
+ fifo_st: int
30
+ ctr: int
31
+ slave_addr: int
32
+ filter_cfg: int
33
+ scl_stretch_conf: int
34
+
35
+
36
+ class I2cTarget(Interface):
37
+ """The I2C target (E147-E150 contract): fixed rx (arm_rx with the exact length), framed rx (a 1-byte length header
38
+ transaction, then the payload), preloaded tx (slots the controller reads)."""
39
+ NAME = f"{NS}.i2c-target"
40
+ REVISION = 1
41
+ CONFIGURE, ARM_RX, READ_RX, PRELOAD_TX, STATUS, RESET, READ_HW, SET_STRETCH = 1, 2, 3, 4, 5, 6, 0x10, 0x11
42
+ MODE_FIXED_RX, MODE_FRAMED_RX, MODE_PRELOADED_TX = 1, 2, 3
43
+ ROLE_SDA, ROLE_SCL = 1, 2
44
+
45
+ def __init__(self, hst: h.Host, fn: int | None = None, name: str | None = None):
46
+ super().__init__(hst, name, fn=fn)
47
+
48
+ def assignments(self, sda: int, scl: int) -> list[tuple[int, int, int]]:
49
+ return [(self.fn, self.ROLE_SDA, sda), (self.fn, self.ROLE_SCL, scl)]
50
+
51
+ def configure(self, address: int, mode: int) -> None:
52
+ self._call(self.CONFIGURE, struct.pack("<BB", address, mode))
53
+
54
+ def arm_rx(self, length: int) -> None:
55
+ self._call(self.ARM_RX, struct.pack("<H", length))
56
+
57
+ def read_rx(self) -> tuple[int, bytes]:
58
+ """-> (frames still queued after this one, the oldest frame or b"")."""
59
+ rd = m.Reader(self._call(self.READ_RX).payload)
60
+ pending, count = rd.take("BH")
61
+ data = rd.bytes(count)
62
+ rd.tail()
63
+ return pending, data
64
+
65
+ def preload_tx(self, data: bytes) -> int:
66
+ """-> the slots preloaded so far."""
67
+ rd = m.Reader(self._call(self.PRELOAD_TX, struct.pack("<H", len(data)) + data).payload)
68
+ slots = rd.u8()
69
+ rd.tail()
70
+ return slots
71
+
72
+ def status(self) -> I2cStatus:
73
+ rd = m.Reader(self._call(self.STATUS, locked=False).payload)
74
+ s = I2cStatus(*rd.take("BIBH"))
75
+ rd.tail()
76
+ return s
77
+
78
+ def reset(self) -> None:
79
+ self._call(self.RESET)
80
+
81
+ def set_stretch(self, stretch_us: int) -> None:
82
+ """Hold every hardware SCL stretch for stretch_us (0 = off); takes effect at the next configure()."""
83
+ self._call(self.SET_STRETCH, struct.pack("<I", stretch_us))
84
+
85
+ def read_hw(self) -> I2cRegisters:
86
+ """Debug view of the P4 I2C0 block (zeros on other chips)."""
87
+ rd = m.Reader(self._call(self.READ_HW, locked=False).payload)
88
+ r = I2cRegisters(*rd.take("7I"))
89
+ rd.tail()
90
+ return r
91
+
92
+
93
+ @dataclass
94
+ class SpiStatus:
95
+ flags: int # bit0 started, bit1 armed, bits 2-3 mode, bit4 LSB first, bits 5-7 queued
96
+ transactions: int
97
+ errors: int
98
+
99
+
100
+ class SpiTarget(Interface):
101
+ """The SPI target (SPI2_HOST, no DMA, <= 64 bytes): one CS-framed transaction at a time - arm() with the MISO
102
+ bytes, then read_rx() after the controller raised CS."""
103
+ NAME = f"{NS}.spi-target"
104
+ REVISION = 1
105
+ CONFIGURE, ARM, READ_RX, STATUS, RESET = 1, 2, 3, 4, 5
106
+ ROLE_SCK, ROLE_MOSI, ROLE_MISO, ROLE_CS = 1, 2, 3, 4
107
+ MSB_FIRST, LSB_FIRST = 0, 1
108
+
109
+ def __init__(self, hst: h.Host, fn: int | None = None, name: str | None = None):
110
+ super().__init__(hst, name, fn=fn)
111
+
112
+ def assignments(self, sck: int, mosi: int, miso: int, cs: int) -> list[tuple[int, int, int]]:
113
+ return [(self.fn, self.ROLE_SCK, sck), (self.fn, self.ROLE_MOSI, mosi), (self.fn, self.ROLE_MISO, miso),
114
+ (self.fn, self.ROLE_CS, cs)]
115
+
116
+ def configure(self, mode: int = 0, bit_order: int = 0) -> None:
117
+ self._call(self.CONFIGURE, struct.pack("<BB", mode, bit_order))
118
+
119
+ def arm(self, length: int, tx: bytes = b"") -> None:
120
+ self._call(self.ARM, struct.pack("<HH", length, len(tx)) + tx)
121
+
122
+ def read_rx(self) -> tuple[int, int, bytes]:
123
+ """-> (transactions still queued, bits clocked, the MOSI bytes) of the oldest finished transaction."""
124
+ rd = m.Reader(self._call(self.READ_RX).payload)
125
+ pending, bits, count = rd.take("BIH")
126
+ data = rd.bytes(count)
127
+ rd.tail()
128
+ return pending, bits, data
129
+
130
+ def status(self) -> SpiStatus:
131
+ rd = m.Reader(self._call(self.STATUS, locked=False).payload)
132
+ s = SpiStatus(*rd.take("BIH"))
133
+ rd.tail()
134
+ return s
135
+
136
+ def reset(self) -> None:
137
+ self._call(self.RESET)
oep_client/v1/fake.py ADDED
@@ -0,0 +1,233 @@
1
+ """In-process fake probes that declare capabilities in the draft wire forms (no hardware).
2
+
3
+ Each profile is a list of offered interfaces with their describe TLVs. The fake answers the core
4
+ operations - confirm, list, describe - by encoding real payloads and paging them to its max_frame,
5
+ so what `dump` shows is what a host would decode from a probe of that shape.
6
+
7
+ The profiles are EXAMPLES of declarations, not a decision about which capabilities are standard.
8
+ Wire forms: oep-core §7 (confirm with a revision range, list first / total u16 with oep.core as the first
9
+ entry, describe first u16).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import struct
15
+ from dataclasses import dataclass
16
+
17
+ from . import catalog, message as m, names, registry as reg
18
+ from .catalog import (CHANNEL_GROUP, FEATURES, IMPLEMENTATION, MAX_CLOCK_HZ, MAX_LENGTH,
19
+ MIN_CLOCK_HZ, ListEntry)
20
+
21
+ CORE_FN = 0
22
+ OP_CONFIRM, OP_LIST, OP_DESCRIBE = 0x01, 0x02, 0x03
23
+ RESULT_HEADER = 5 # role(1) correlation(2) resolution(1) detail(1) in front of every payload
24
+ REVISION = 1 # the protocol revision confirm reports
25
+ WINDOW, MAX_INFLIGHT = 4096, 4
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Offered:
30
+ fn: int
31
+ instance: int
32
+ name: str
33
+ tlvs: tuple[bytes, ...] = ()
34
+ revision: int = 1
35
+ flags: int = 0
36
+
37
+
38
+ class FakeProbe:
39
+ def __init__(self, label: str, max_frame: int, offered: list[Offered]):
40
+ self.label = label
41
+ self.max_frame = max_frame
42
+ self.offered = sorted(offered, key=lambda o: o.fn)
43
+ self.requests = 0
44
+ for o in self.offered:
45
+ names.validate(o.name)
46
+
47
+ # The one entry point a transport would call: (fn, op, payload) -> result payload.
48
+ def call(self, fn: int, op: int, payload: bytes = b"") -> bytes:
49
+ self.requests += 1
50
+ if fn != CORE_FN:
51
+ raise ValueError(f"fake: fn {fn} has no operations here")
52
+ if op == OP_CONFIRM:
53
+ if len(payload) < 6 or payload[:4] != m.CONFIRM_REQUEST:
54
+ raise ValueError("fake: confirm needs \"OEP?\" min_rev max_rev")
55
+ if not payload[4] <= REVISION <= payload[5]:
56
+ raise LookupError(f"fake: no revision in {payload[4]}..{payload[5]}")
57
+ return struct.pack("<4sBBHIB", m.CONFIRM_RESULT, REVISION, 0, self.max_frame, WINDOW, MAX_INFLIGHT)
58
+ if op == OP_LIST:
59
+ return self._list(*catalog.unpack_list_request(payload)[:3])
60
+ if op == OP_DESCRIBE:
61
+ if len(payload) < 4:
62
+ raise ValueError("fake: describe needs fn(u16) first(u16)")
63
+ target, first = struct.unpack_from("<HH", payload)
64
+ return self._describe(target, first)
65
+ raise ValueError(f"fake: core op 0x{op:02x} unknown")
66
+
67
+ def _list(self, prefix: str, exact: bool, first: int) -> bytes:
68
+ hits = [o for o in self.offered if names.matches(o.name, prefix, exact)]
69
+ budget = self.max_frame - RESULT_HEADER - 3
70
+ page, used = [], 0
71
+ for o in hits[first:]:
72
+ size = len(catalog.pack_entry(self._entry(o)))
73
+ if page and used + size > budget:
74
+ break
75
+ page.append(self._entry(o))
76
+ used += size
77
+ return catalog.pack_list_result(len(hits), page)
78
+
79
+ def _describe(self, fn: int, first: int) -> bytes:
80
+ o = next((x for x in self.offered if x.fn == fn), None)
81
+ if o is None:
82
+ raise ValueError(f"fake: fn {fn} not offered")
83
+ budget = self.max_frame - RESULT_HEADER - 1
84
+ out, sent = b"", 0
85
+ for t in o.tlvs[first:]:
86
+ if out and len(out) + len(t) > budget:
87
+ break
88
+ out += t
89
+ sent += 1
90
+ more = 1 if first + sent < len(o.tlvs) else 0
91
+ return bytes([more]) + out
92
+
93
+ @staticmethod
94
+ def _entry(o: Offered) -> ListEntry:
95
+ return ListEntry(o.fn, o.instance, o.revision, o.flags, o.name)
96
+
97
+
98
+ # ---- example profiles ----------------------------------------------------
99
+ # Names follow oep-spec docs/capability-name-hierarchy.ja.md (provisional, 2026-09-24): probe-wide
100
+ # declarations in oep.core's describe, oep.wire.<link> to scan and attach, oep.target.riscv-dm and
101
+ # oep.target.console on the connection, fixtures gpio / uart / capture, the ESP-IDF I2C and SPI
102
+ # targets under the project's own name. Probe-wide tags (oep.core, interface-specific 0x40..):
103
+ _CORE_TAGS = reg.CORE.tlv["describe"]
104
+ CORE_FIRMWARE, CORE_MODEL, CORE_UNIT_ID, CORE_CHANNELS = (_CORE_TAGS[k] for k in ("firmware", "model", "unit_id", "channels"))
105
+ CORE_RESERVED, CORE_PROFILE, CORE_LABEL = _CORE_TAGS["reserved"], _CORE_TAGS["profile"], _CORE_TAGS["label"]
106
+ CORE_RESETS_ON_OPEN, CORE_TRANSPORT, CORE_OEP_PID = _CORE_TAGS["resets_on_open"], _CORE_TAGS["transport"], _CORE_TAGS["oep_pid"]
107
+ TRANSPORT = reg.CORE.enum["transport_kind"]
108
+ SERIAL_KINDS = {TRANSPORT["uart_bridge"], TRANSPORT["usb_cdc"], TRANSPORT["usb_serial_jtag"]}
109
+ NS = "io.github.ch32-riscv-ug"
110
+ MECHANISMS = reg.TARGET_CONSOLE.tlv["describe"]["mechanisms"]
111
+ MAX_CONNECTIONS = reg.WIRE_RVSWD.tlv["describe"]["max_connections"]
112
+ _CFG = reg.PROBE_CONFIG.tlv["describe"]
113
+
114
+
115
+ def _transports(kinds: list[tuple[int, int]]) -> tuple[bytes, ...]:
116
+ """(kind, USB interface or 0xFF) per transport, index = position (core §7.5)."""
117
+ return tuple(catalog.tlv(CORE_TRANSPORT, bytes([i, k, itf])) for i, (k, itf) in enumerate(kinds))
118
+
119
+
120
+ def _config(fn: int, instance: int, slots_max: int, modes: int = 0b111, storage: int = 4096) -> Offered:
121
+ """oep.probe.config's static declarations; slot_state / bind_state are added live by the endpoint."""
122
+ return Offered(fn, instance, "oep.probe.config", (
123
+ catalog.tlv(_CFG["storage"], struct.pack("<IBII", storage, 0, 0, 20)),
124
+ catalog.tlv(_CFG["items"], bytes(reg.PROBE_CONFIG.tlv["item"].values())),
125
+ catalog.u8(_CFG["slots_max"], slots_max), catalog.u8(_CFG["bind_modes"], modes)))
126
+
127
+
128
+ def _roles(assign: dict[int, list[int]]) -> tuple[bytes, ...]:
129
+ return tuple(catalog.role_channels(r, ch) for r, ch in assign.items())
130
+
131
+
132
+ def _label(channel: int, name: str) -> bytes:
133
+ return catalog.tlv(CORE_LABEL, struct.pack("<H", channel) + name.encode("ascii"))
134
+
135
+
136
+ def _core(firmware: str, model: str, unit_id: bytes, channels: int, reserved: list[int], profile: str,
137
+ labels: dict[int, str], extra: tuple[bytes, ...] = ()) -> Offered:
138
+ base, bits = catalog.channels_to_bitmap(reserved)
139
+ return Offered(0, 0, "oep.core", (
140
+ catalog.text(CORE_FIRMWARE, firmware), catalog.text(CORE_MODEL, model), catalog.tlv(CORE_UNIT_ID, unit_id),
141
+ catalog.u16(CORE_CHANNELS, channels), catalog.tlv(CORE_RESERVED, struct.pack("<H", base) + bits),
142
+ catalog.text(CORE_PROFILE, profile)) + tuple(_label(c, n) for c, n in labels.items()) + extra)
143
+
144
+
145
+ def p4_x035() -> FakeProbe:
146
+ """ESP32-P4 development probe on the CH32X035F8U6 jig (as wired on 2026-09-24), in the recommended USB shape
147
+ (probe guide §3.8): USB-Serial/JTAG (serial port 0), and on the HS port vendor bulk, HID and a CDC (serial port 3)
148
+ under the OEP VID:PID."""
149
+ reserved = [2, 24, 25, 54] # RVSWD SWDIO/SWCLK, USB-Serial/JTAG
150
+ pins = [p for p in range(55) if p not in reserved]
151
+ return FakeProbe("p4-x035", 1024, [
152
+ _core("3.0.0", "esp32-p4-devkit", bytes.fromhex("30eda0e31108"), 55, reserved, f"{NS}.p4-x035",
153
+ {2: "SWDIO", 54: "SWCLK", 51: "LED"},
154
+ _transports([(TRANSPORT["usb_serial_jtag"], 0xFF), (TRANSPORT["vendor_bulk"], 0),
155
+ (TRANSPORT["hid"], 1), (TRANSPORT["usb_cdc"], 2)]) + (catalog.u8(CORE_OEP_PID, 1),)),
156
+ Offered(1, 1, "oep.wire.rvswd", (
157
+ catalog.channel_group(1, [(1, 2), (2, 54)]), catalog.u32(MAX_CLOCK_HZ, 5_000_000), catalog.u8(IMPLEMENTATION, 1),
158
+ catalog.u8(MAX_CONNECTIONS, 1))),
159
+ Offered(2, 1, "oep.target.riscv-dm", (catalog.u32(FEATURES, 0b1111), catalog.u8(IMPLEMENTATION, 1),
160
+ catalog.u16(MAX_LENGTH, 1000))),
161
+ Offered(3, 1, "oep.target.console", (catalog.tlv(MECHANISMS, bytes([0, 1, 2])),)),
162
+ Offered(4, 2, "oep.fixture.gpio", _roles({1: pins})),
163
+ Offered(5, 3, "oep.fixture.uart", _roles({1: pins, 2: pins}) + (
164
+ catalog.u32(MAX_CLOCK_HZ, 3_000_000), catalog.u8(IMPLEMENTATION, 2))),
165
+ Offered(6, 4, "oep.fixture.uart", _roles({1: pins, 2: pins}) + (
166
+ catalog.u32(MAX_CLOCK_HZ, 3_000_000), catalog.u8(IMPLEMENTATION, 2))),
167
+ Offered(7, 5, "oep.fixture.capture", _roles({k: pins for k in range(8)}) + (
168
+ catalog.u32(MAX_CLOCK_HZ, 20_000_000), catalog.u32(MIN_CLOCK_HZ, 1_000),
169
+ catalog.u16(MAX_LENGTH, 65000), catalog.u8(IMPLEMENTATION, 3))),
170
+ Offered(8, 6, f"{NS}.esp32.i2c-target", _roles({1: pins, 2: pins}) + (
171
+ catalog.u16(MAX_LENGTH, 128), catalog.u32(MAX_CLOCK_HZ, 1_000_000),
172
+ catalog.u32(FEATURES, 0b11), catalog.u8(IMPLEMENTATION, 2))),
173
+ Offered(9, 7, f"{NS}.esp32.spi-target", _roles({1: pins, 2: pins, 3: pins, 4: pins}) + (
174
+ catalog.u16(MAX_LENGTH, 64), catalog.u32(MAX_CLOCK_HZ, 3_000_000), catalog.u8(IMPLEMENTATION, 2))),
175
+ _config(10, 8, slots_max=1),
176
+ ])
177
+
178
+
179
+ def esp32_v003() -> FakeProbe:
180
+ """A small probe with 64-byte frames over a 115200 bps UART bridge (its only transport, serial port 0):
181
+ classic ESP32 on a CH32V003 (SWIO) jig."""
182
+ reserved = [0, 1, 2, 3, 6, 7, 8, 9, 10, 11, 12, 15, 16]
183
+ wired = [4, 5, 13, 14, 17, 18, 19, 21, 22, 25, 26, 27, 32, 33]
184
+ return FakeProbe("esp32-v003", 64, [
185
+ _core("3.0.0", "esp32-d0wd", bytes.fromhex("0070070d9394"), 40, reserved, f"{NS}.esp32-v003",
186
+ {16: "SWIO", 23: "NRST", 22: "DUT TX", 21: "DUT RX"},
187
+ _transports([(TRANSPORT["uart_bridge"], 0xFF)])),
188
+ Offered(1, 1, "oep.wire.swio", (catalog.channel_group(1, [(1, 16)]), catalog.u8(IMPLEMENTATION, 1),
189
+ catalog.u8(MAX_CONNECTIONS, 1))),
190
+ Offered(2, 1, "oep.target.riscv-dm", (catalog.u32(FEATURES, 0b0111), catalog.u8(IMPLEMENTATION, 1),
191
+ catalog.u16(MAX_LENGTH, 40))),
192
+ Offered(3, 1, "oep.target.console", (catalog.tlv(MECHANISMS, bytes([0, 1, 2])),)),
193
+ Offered(4, 2, "oep.fixture.gpio", _roles({1: wired + [23]})),
194
+ Offered(5, 3, "oep.fixture.uart", _roles({1: wired, 2: wired}) + (
195
+ catalog.u32(MAX_CLOCK_HZ, 115_200), catalog.u8(IMPLEMENTATION, 2))),
196
+ Offered(6, 4, "oep.fixture.capture", _roles({k: wired for k in range(4)}) + (
197
+ catalog.u32(MAX_CLOCK_HZ, 2_000_000), catalog.u32(MIN_CLOCK_HZ, 400_000), catalog.u8(IMPLEMENTATION, 1))),
198
+ Offered(7, 5, f"{NS}.esp32.i2c-target", _roles({1: wired, 2: wired}) + (
199
+ catalog.u16(MAX_LENGTH, 16), catalog.u32(MAX_CLOCK_HZ, 100_000), catalog.u8(IMPLEMENTATION, 2))),
200
+ # Two fixed pin sets (an example of channel_group; GPIO23 is the DUT's NRST on this jig).
201
+ Offered(8, 6, f"{NS}.esp32.spi-target", (
202
+ catalog.channel_group(1, [(1, 18), (2, 19), (3, 5), (4, 4)]),
203
+ catalog.channel_group(2, [(1, 14), (2, 13), (3, 27), (4, 26)]),
204
+ catalog.u16(MAX_LENGTH, 32), catalog.u32(MAX_CLOCK_HZ, 3_000_000), catalog.u8(IMPLEMENTATION, 2))),
205
+ _config(9, 7, slots_max=1, modes=0b011, storage=1024),
206
+ ])
207
+
208
+
209
+ def p4_bench() -> FakeProbe:
210
+ """A made-up bench probe with three RVSWD places and two seats (slots, the seat rule and the bind modes can be
211
+ exercised): USB-Serial/JTAG (serial port 0), vendor bulk, HID and a CDC (serial port 3) under the OEP VID:PID."""
212
+ reserved = [2, 3, 4, 5, 6, 7, 24, 25]
213
+ pins = [p for p in range(55) if p not in reserved]
214
+ return FakeProbe("p4-bench", 1024, [
215
+ _core("3.0.0", "esp32-p4-devkit", bytes.fromhex("30eda0e3b001"), 55, reserved, f"{NS}.p4-bench",
216
+ {2: "A SWDIO", 3: "A SWCLK", 4: "B SWDIO", 5: "B SWCLK", 6: "C SWDIO", 7: "C SWCLK"},
217
+ _transports([(TRANSPORT["usb_serial_jtag"], 0xFF), (TRANSPORT["vendor_bulk"], 0),
218
+ (TRANSPORT["hid"], 1), (TRANSPORT["usb_cdc"], 2)]) + (catalog.u8(CORE_OEP_PID, 1),)),
219
+ Offered(1, 1, "oep.wire.rvswd", (
220
+ catalog.channel_group(1, [(1, 2), (2, 3)]), catalog.channel_group(2, [(1, 4), (2, 5)]),
221
+ catalog.channel_group(3, [(1, 6), (2, 7)]), catalog.u32(MAX_CLOCK_HZ, 5_000_000),
222
+ catalog.u8(IMPLEMENTATION, 1), catalog.u8(MAX_CONNECTIONS, 2))),
223
+ Offered(2, 1, "oep.target.riscv-dm", (catalog.u32(FEATURES, 0b1111), catalog.u8(IMPLEMENTATION, 1),
224
+ catalog.u16(MAX_LENGTH, 1000))),
225
+ Offered(3, 1, "oep.target.console", (catalog.tlv(MECHANISMS, bytes([0, 1, 2])),)),
226
+ Offered(4, 2, "oep.fixture.gpio", _roles({1: pins})),
227
+ Offered(5, 3, "oep.fixture.uart", _roles({1: pins, 2: pins}) + (
228
+ catalog.u32(MAX_CLOCK_HZ, 3_000_000), catalog.u8(IMPLEMENTATION, 2))),
229
+ _config(6, 4, slots_max=4),
230
+ ])
231
+
232
+
233
+ PROFILES = {"p4-x035": p4_x035, "esp32-v003": esp32_v003, "p4-bench": p4_bench}
@@ -0,0 +1,105 @@
1
+ """The byte side of a fake probe's serial port (oep-core §3.1, §3.4): COBS frames and raw bytes on one port.
2
+
3
+ `FakeSerialPort(endpoint, index)` is serial port `index` of an `endpoint.Endpoint`. Bytes from the host go to
4
+ `feed`; what the probe sends comes from `output`. It does what a probe does:
5
+
6
+ - a candidate runs from a 0x00 to the next 0x00; if it decodes and its CRC matches it is a request, otherwise it
7
+ (with its leading 0x00) is raw bytes, and the closing 0x00 starts the next candidate; bytes outside a candidate
8
+ are raw at once; a candidate that stops for 200 ms (`tick`) is raw; an empty candidate (0x00 0x00) is nothing
9
+ - raw bytes go to the endpoint's bind for this port (`Endpoint.port_input`)
10
+ - answers go out as 0x00 <COBS> 0x00, ahead of raw chunks (`Endpoint.port_output`); one writer, never a raw byte
11
+ inside a frame
12
+
13
+ `answer_filter(n, message) -> bytes | None` may change how the n-th answer (1-based) goes on the wire: the framed
14
+ bytes to send, or None to send nothing (fault injection for tests).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Callable
20
+
21
+ from . import cobs, registry as reg
22
+
23
+ GAP_MS = reg.TIMING["probe_frame_gap_ms"]
24
+
25
+
26
+ class FakeSerialPort:
27
+ def __init__(self, ep, index: int, answer_filter: Callable[[int, bytes], bytes | None] | None = None):
28
+ self.ep, self.index = ep, index
29
+ self.answer_filter = answer_filter
30
+ self.cand: bytearray | None = None # the candidate so far, its leading 0x00 included
31
+ self.last_ms = 0
32
+ self.limit = 2 * ep.probe.max_frame + 16 # longer than any COBS frame of max_frame: raw
33
+ self.frames: list[bytes] = [] # framed answers waiting to go out
34
+ self.answers = 0
35
+
36
+ def feed(self, data: bytes) -> None:
37
+ now = self.ep.now()
38
+ self._gap(now)
39
+ raw = bytearray()
40
+ for b in data:
41
+ if b == 0:
42
+ if self.cand is not None:
43
+ self._close(raw)
44
+ self.cand = bytearray(b"\x00")
45
+ elif self.cand is not None:
46
+ self.cand.append(b)
47
+ if len(self.cand) > self.limit:
48
+ raw += self.cand
49
+ self.cand = None
50
+ else:
51
+ raw.append(b)
52
+ self.last_ms = now
53
+ self._raw(raw)
54
+
55
+ def _close(self, raw: bytearray) -> None:
56
+ body = bytes(self.cand[1:])
57
+ self.cand = None
58
+ if not body:
59
+ return # 0x00 0x00: an empty frame
60
+ try:
61
+ msg = cobs.unframe(body)
62
+ except cobs.CorruptFrame:
63
+ raw += b"\x00" + body # not a frame: raw, the leading 0x00 too
64
+ return
65
+ self._raw(raw) # the raw bytes before the frame go first
66
+ raw.clear()
67
+ try:
68
+ result = self.ep.handle(msg, self.index)
69
+ except ValueError:
70
+ return # not a request (a role the probe does not take): dropped
71
+ if result is None:
72
+ return
73
+ self.answers += 1
74
+ wire = cobs.frame(result)
75
+ if self.answer_filter is not None:
76
+ wire = self.answer_filter(self.answers, result)
77
+ if wire:
78
+ self.frames.append(wire)
79
+
80
+ def _gap(self, now: int) -> None:
81
+ if self.cand is not None and now - self.last_ms >= GAP_MS:
82
+ raw, self.cand = bytes(self.cand), None
83
+ self._raw(bytearray(raw))
84
+
85
+ def _raw(self, raw: bytearray) -> None:
86
+ if raw:
87
+ self.ep.port_input(self.index, bytes(raw))
88
+
89
+ def tick(self) -> None:
90
+ self._gap(self.ep.now())
91
+ self.ep.tick()
92
+
93
+ def output(self, room: int = 4096) -> bytes:
94
+ """What the probe sends now: waiting answers first, then raw bytes by the bind (up to `room` in all)."""
95
+ out = bytearray()
96
+ while self.frames and len(out) + len(self.frames[0]) <= max(room, len(self.frames[0])):
97
+ out += self.frames.pop(0)
98
+ if len(out) >= room:
99
+ return bytes(out)
100
+ while len(out) < room:
101
+ chunk = self.ep.port_output(self.index, min(self.ep.CHUNK, room - len(out)))
102
+ if not chunk:
103
+ break
104
+ out += chunk
105
+ return bytes(out)