oep-client-python 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- oep_client/__init__.py +5 -0
- oep_client/v1/__init__.py +1 -0
- oep_client/v1/__main__.py +40 -0
- oep_client/v1/arm.py +269 -0
- oep_client/v1/capture.py +365 -0
- oep_client/v1/catalog.py +181 -0
- oep_client/v1/ch32_flash.py +254 -0
- oep_client/v1/cobs.py +73 -0
- oep_client/v1/console.py +165 -0
- oep_client/v1/core.py +172 -0
- oep_client/v1/decode.py +65 -0
- oep_client/v1/dump.py +157 -0
- oep_client/v1/endpoint.py +1479 -0
- oep_client/v1/esp32_targets.py +137 -0
- oep_client/v1/fake.py +233 -0
- oep_client/v1/fake_serial.py +105 -0
- oep_client/v1/fake_serve.py +299 -0
- oep_client/v1/fixture.py +121 -0
- oep_client/v1/frames.py +91 -0
- oep_client/v1/hid_stream.py +257 -0
- oep_client/v1/host.py +321 -0
- oep_client/v1/interfaces.py +119 -0
- oep_client/v1/link.py +414 -0
- oep_client/v1/message.py +235 -0
- oep_client/v1/names.py +86 -0
- oep_client/v1/registry.py +59 -0
- oep_client/v1/riscv.py +430 -0
- oep_client/v1/rp2350.py +85 -0
- oep_client/v1/target.py +16 -0
- oep_client/v1/uiapduino.py +121 -0
- oep_client/v1/usb_stream.py +209 -0
- oep_client_python-0.0.1.dist-info/METADATA +106 -0
- oep_client_python-0.0.1.dist-info/RECORD +36 -0
- oep_client_python-0.0.1.dist-info/WHEEL +4 -0
- oep_client_python-0.0.1.dist-info/entry_points.txt +2 -0
- oep_client_python-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""OEP over a vendor-defined USB HID interface (oep-spec oep-core §3): the way in when vendor bulk is not
|
|
2
|
+
usable (no WinUSB / udev permission for raw USB), before a CDC port, which a probe may give to serial forwarding.
|
|
3
|
+
|
|
4
|
+
Framing: every report carries count (u16 LE) and then that many bytes of the length-prefixed frame stream, the rest of
|
|
5
|
+
the report zero. Report IDs are the HID transport's business, not OEP's: when the report descriptor declares one, every
|
|
6
|
+
report on the wire starts with it - output reports too (EspUsbDevice takes a first byte equal to its report ID as the
|
|
7
|
+
ID, so a report sent without it lost a byte whenever count's low byte matched).
|
|
8
|
+
|
|
9
|
+
Backends, same shape as UsbBulkStream (read with a timeout, in_waiting, write, reset_input_buffer, close):
|
|
10
|
+
hidapi (`hid`, cython-hidapi): the OS HID driver - Windows, macOS, Linux hidraw. Needs no driver change on Windows.
|
|
11
|
+
libusb1 (`usb1`): detaches the kernel's HID driver and talks to the endpoints (Linux, raw USB permission).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
|
|
19
|
+
VENDOR_USAGE_PAGE_MIN = 0xFF00
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_report_descriptor(desc: bytes) -> list[dict]:
|
|
23
|
+
"""The reports of a HID report descriptor: [{usage_page, report_id (0 = none), input, output}] with input/output in
|
|
24
|
+
bytes (the report ID not counted). Short items only (long items are skipped); enough for a vendor interface."""
|
|
25
|
+
reports: dict[tuple[int, int], dict] = {}
|
|
26
|
+
usage_page = report_id = size = count = 0
|
|
27
|
+
stack = []
|
|
28
|
+
i = 0
|
|
29
|
+
while i < len(desc):
|
|
30
|
+
prefix = desc[i]
|
|
31
|
+
if prefix == 0xFE: # long item: size in the next byte
|
|
32
|
+
i += 3 + (desc[i + 1] if i + 1 < len(desc) else 0)
|
|
33
|
+
continue
|
|
34
|
+
n = (0, 1, 2, 4)[prefix & 3]
|
|
35
|
+
value = int.from_bytes(desc[i + 1:i + 1 + n], "little")
|
|
36
|
+
tag, kind = prefix & 0xF0, prefix & 0x0C
|
|
37
|
+
i += 1 + n
|
|
38
|
+
if kind == 0x04: # global
|
|
39
|
+
if tag == 0x00:
|
|
40
|
+
usage_page = value
|
|
41
|
+
elif tag == 0x70:
|
|
42
|
+
size = value
|
|
43
|
+
elif tag == 0x80:
|
|
44
|
+
report_id = value
|
|
45
|
+
elif tag == 0x90:
|
|
46
|
+
count = value
|
|
47
|
+
elif tag == 0xA0:
|
|
48
|
+
stack.append((usage_page, report_id, size, count))
|
|
49
|
+
elif tag == 0xB0 and stack:
|
|
50
|
+
usage_page, report_id, size, count = stack.pop()
|
|
51
|
+
elif kind == 0x00 and tag in (0x80, 0x90): # main: Input / Output
|
|
52
|
+
r = reports.setdefault((usage_page, report_id),
|
|
53
|
+
{"usage_page": usage_page, "report_id": report_id, "input": 0, "output": 0})
|
|
54
|
+
r["input" if tag == 0x80 else "output"] += (size * count + 7) // 8
|
|
55
|
+
return list(reports.values())
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def vendor_report(desc: bytes) -> dict | None:
|
|
59
|
+
"""The vendor-page report carrying both directions, if the descriptor has one."""
|
|
60
|
+
for r in parse_report_descriptor(desc):
|
|
61
|
+
if r["usage_page"] >= VENDOR_USAGE_PAGE_MIN and r["input"] >= 3 and r["output"] >= 3:
|
|
62
|
+
return r
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class HidReportStream:
|
|
67
|
+
"""count(u16) + frame bytes per report over a backend that moves whole reports (payload without the report ID)."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, backend, input_size: int, output_size: int):
|
|
70
|
+
self.backend = backend
|
|
71
|
+
self.input_size, self.output_size = input_size, output_size
|
|
72
|
+
self.timeout = 0.05
|
|
73
|
+
self._buffer = bytearray()
|
|
74
|
+
self._cond = threading.Condition()
|
|
75
|
+
self._closed = False
|
|
76
|
+
self._reader = threading.Thread(target=self._drain, daemon=True)
|
|
77
|
+
self._reader.start()
|
|
78
|
+
|
|
79
|
+
def _drain(self) -> None:
|
|
80
|
+
while not self._closed:
|
|
81
|
+
try:
|
|
82
|
+
report = self.backend.read_report(0.1)
|
|
83
|
+
except OSError:
|
|
84
|
+
if self._closed:
|
|
85
|
+
return
|
|
86
|
+
time.sleep(0.01)
|
|
87
|
+
continue
|
|
88
|
+
if len(report) < 2:
|
|
89
|
+
continue
|
|
90
|
+
n = min(report[0] | report[1] << 8, len(report) - 2)
|
|
91
|
+
if n:
|
|
92
|
+
with self._cond:
|
|
93
|
+
self._buffer += report[2:2 + n]
|
|
94
|
+
self._cond.notify_all()
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def in_waiting(self) -> int:
|
|
98
|
+
with self._cond:
|
|
99
|
+
return len(self._buffer)
|
|
100
|
+
|
|
101
|
+
def read(self, n: int = 1) -> bytes:
|
|
102
|
+
deadline = time.monotonic() + (self.timeout or 0)
|
|
103
|
+
with self._cond:
|
|
104
|
+
while not self._buffer:
|
|
105
|
+
left = deadline - time.monotonic()
|
|
106
|
+
if left <= 0:
|
|
107
|
+
return b""
|
|
108
|
+
self._cond.wait(left)
|
|
109
|
+
out = bytes(self._buffer[:n])
|
|
110
|
+
del self._buffer[:n]
|
|
111
|
+
return out
|
|
112
|
+
|
|
113
|
+
def write(self, data: bytes) -> int:
|
|
114
|
+
room = self.output_size - 2
|
|
115
|
+
for i in range(0, len(data), room):
|
|
116
|
+
part = data[i:i + room]
|
|
117
|
+
self.backend.write_report(len(part).to_bytes(2, "little") + part + bytes(room - len(part)))
|
|
118
|
+
return len(data)
|
|
119
|
+
|
|
120
|
+
def reset_input_buffer(self) -> None:
|
|
121
|
+
with self._cond:
|
|
122
|
+
self._buffer.clear()
|
|
123
|
+
|
|
124
|
+
def close(self) -> None:
|
|
125
|
+
self._closed = True
|
|
126
|
+
self._reader.join(timeout=0.5)
|
|
127
|
+
self.backend.close()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class HidapiBackend:
|
|
131
|
+
def __init__(self, device, report_id: int):
|
|
132
|
+
self.device, self.report_id = device, report_id
|
|
133
|
+
|
|
134
|
+
def read_report(self, timeout: float) -> bytes:
|
|
135
|
+
data = bytes(self.device.read(65536, int(timeout * 1000)))
|
|
136
|
+
if data and self.report_id:
|
|
137
|
+
if data[0] != self.report_id:
|
|
138
|
+
return b""
|
|
139
|
+
data = data[1:]
|
|
140
|
+
return data
|
|
141
|
+
|
|
142
|
+
def write_report(self, payload: bytes) -> None:
|
|
143
|
+
# hidapi always takes the report ID first; 0 when the descriptor declares none (not sent on the wire)
|
|
144
|
+
if self.device.write(bytes([self.report_id]) + payload) < 0:
|
|
145
|
+
raise OSError("HID write failed")
|
|
146
|
+
|
|
147
|
+
def close(self) -> None:
|
|
148
|
+
self.device.close()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class Usb1HidBackend:
|
|
152
|
+
def __init__(self, context, handle, interface: int, ep_in: int, ep_out: int | None, report_id: int, input_size: int):
|
|
153
|
+
import usb1
|
|
154
|
+
self._usb1 = usb1
|
|
155
|
+
self.context, self.handle, self.interface = context, handle, interface
|
|
156
|
+
self.ep_in, self.ep_out, self.report_id = ep_in, ep_out, report_id
|
|
157
|
+
self.in_len = input_size + (1 if report_id else 0)
|
|
158
|
+
|
|
159
|
+
def read_report(self, timeout: float) -> bytes:
|
|
160
|
+
try:
|
|
161
|
+
data = bytes(self.handle.interruptRead(self.ep_in, self.in_len, timeout=int(timeout * 1000)))
|
|
162
|
+
except self._usb1.USBErrorTimeout:
|
|
163
|
+
return b""
|
|
164
|
+
except self._usb1.USBError as e:
|
|
165
|
+
raise OSError(str(e)) from e
|
|
166
|
+
if data and self.report_id:
|
|
167
|
+
if data[0] != self.report_id:
|
|
168
|
+
return b""
|
|
169
|
+
data = data[1:]
|
|
170
|
+
return data
|
|
171
|
+
|
|
172
|
+
def write_report(self, payload: bytes) -> None:
|
|
173
|
+
wire = (bytes([self.report_id]) if self.report_id else b"") + payload
|
|
174
|
+
if self.ep_out is not None:
|
|
175
|
+
self.handle.interruptWrite(self.ep_out, wire, timeout=1000)
|
|
176
|
+
else: # SET_REPORT (output) on the control pipe
|
|
177
|
+
self.handle.controlWrite(0x21, 0x09, 0x0200 | self.report_id, self.interface, wire, timeout=1000)
|
|
178
|
+
|
|
179
|
+
def close(self) -> None:
|
|
180
|
+
try:
|
|
181
|
+
self.handle.releaseInterface(self.interface)
|
|
182
|
+
finally:
|
|
183
|
+
self.handle.close()
|
|
184
|
+
self.context.close()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _open_hidapi(vid: int, pid: int, serial: str | None) -> HidReportStream:
|
|
188
|
+
import hid
|
|
189
|
+
for info in hid.enumerate(vid, pid):
|
|
190
|
+
if info.get("usage_page", 0) and info["usage_page"] < VENDOR_USAGE_PAGE_MIN:
|
|
191
|
+
continue
|
|
192
|
+
if serial and (info.get("serial_number") or "").lower() != serial.lower():
|
|
193
|
+
continue
|
|
194
|
+
dev = hid.device()
|
|
195
|
+
dev.open_path(info["path"])
|
|
196
|
+
try:
|
|
197
|
+
desc = bytes(dev.get_report_descriptor())
|
|
198
|
+
except (AttributeError, OSError):
|
|
199
|
+
dev.close()
|
|
200
|
+
continue
|
|
201
|
+
r = vendor_report(desc)
|
|
202
|
+
if r is None:
|
|
203
|
+
dev.close()
|
|
204
|
+
continue
|
|
205
|
+
return HidReportStream(HidapiBackend(dev, r["report_id"]), r["input"], r["output"])
|
|
206
|
+
raise FileNotFoundError(f"no vendor HID interface on {vid:04x}:{pid:04x}" + (f" serial {serial}" if serial else ""))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _open_usb1(vid: int, pid: int, serial: str | None) -> HidReportStream:
|
|
210
|
+
import usb1
|
|
211
|
+
context = usb1.USBContext()
|
|
212
|
+
context.open()
|
|
213
|
+
for dev in context.getDeviceIterator(skip_on_error=True):
|
|
214
|
+
if dev.getVendorID() != vid or dev.getProductID() != pid:
|
|
215
|
+
continue
|
|
216
|
+
handle = dev.open()
|
|
217
|
+
if serial and (handle.getSerialNumber() or "").lower() != serial.lower():
|
|
218
|
+
handle.close()
|
|
219
|
+
continue
|
|
220
|
+
for setting in dev.iterSettings():
|
|
221
|
+
if setting.getClass() != 3:
|
|
222
|
+
continue
|
|
223
|
+
number = setting.getNumber()
|
|
224
|
+
eps = [(e.getAddress(), e.getAttributes()) for e in setting]
|
|
225
|
+
ins = [a for a, attr in eps if attr & 3 == 3 and a & 0x80]
|
|
226
|
+
outs = [a for a, attr in eps if attr & 3 == 3 and not a & 0x80]
|
|
227
|
+
if not ins:
|
|
228
|
+
continue
|
|
229
|
+
# GET_DESCRIPTOR (report) on the interface
|
|
230
|
+
desc = bytes(handle.controlRead(0x81, 0x06, 0x2200, number, 4096, timeout=1000))
|
|
231
|
+
r = vendor_report(desc)
|
|
232
|
+
if r is None:
|
|
233
|
+
continue
|
|
234
|
+
if handle.kernelDriverActive(number):
|
|
235
|
+
handle.detachKernelDriver(number)
|
|
236
|
+
handle.claimInterface(number)
|
|
237
|
+
backend = Usb1HidBackend(context, handle, number, ins[0], outs[0] if outs else None, r["report_id"], r["input"])
|
|
238
|
+
return HidReportStream(backend, r["input"], r["output"])
|
|
239
|
+
handle.close()
|
|
240
|
+
context.close()
|
|
241
|
+
raise FileNotFoundError(f"no vendor HID interface on {vid:04x}:{pid:04x}" + (f" serial {serial}" if serial else ""))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def open_hid(vid: int, pid: int, serial: str | None = None) -> HidReportStream:
|
|
245
|
+
"""The probe's vendor HID interface: hidapi first (the OS driver, no permission beyond the HID node), then
|
|
246
|
+
libusb1."""
|
|
247
|
+
errors = []
|
|
248
|
+
for opener in (_open_hidapi, _open_usb1):
|
|
249
|
+
try:
|
|
250
|
+
return opener(vid, pid, serial)
|
|
251
|
+
except ImportError as e:
|
|
252
|
+
errors.append(f"{opener.__name__}: {e}")
|
|
253
|
+
except (OSError, FileNotFoundError) as e:
|
|
254
|
+
errors.append(f"{opener.__name__}: {e}")
|
|
255
|
+
except Exception as e: # usb1.USBError (access denied, busy) is not an OSError
|
|
256
|
+
errors.append(f"{opener.__name__}: {type(e).__name__}: {e}")
|
|
257
|
+
raise FileNotFoundError("; ".join(errors))
|
oep_client/v1/host.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Host side of the v1 session rules, over any `send(request bytes) -> result bytes` transport.
|
|
2
|
+
|
|
3
|
+
The host picks a random u32 session id for every open (never a counter: after a probe reboot a counter would
|
|
4
|
+
start again and match an old process's id). A one-shot CLI keeps the id between commands; a request that goes
|
|
5
|
+
through with the same id proves nobody else operated the probe in between (oep-spec session-and-exclusivity).
|
|
6
|
+
|
|
7
|
+
oep-core §4.1: role 0x81 (a session id in the header) goes only to a probe whose confirm answered revision 1 or more; a
|
|
8
|
+
v0 probe drops the unknown role without an answer. The host confirms before its first open or session request.
|
|
9
|
+
§3: when the probe's boot_id changes (open, heartbeat), or a probe with boot_id 0 answers no session, every
|
|
10
|
+
connection and the plan are gone: `epoch` counts those losses, so a client holding a connection can tell.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import random
|
|
16
|
+
import struct
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Callable
|
|
19
|
+
|
|
20
|
+
from . import message as m
|
|
21
|
+
from .message import OepError, ProtocolError, ShortPayload # noqa: F401 (re-exported: callers use host.*)
|
|
22
|
+
|
|
23
|
+
MIN_REVISION = MAX_REVISION = 1 # the v1 shapes this client speaks
|
|
24
|
+
OWNER = 0x01 # open's owner TLV, and the same tag after lock_state / rejected locked
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Rejected(OepError):
|
|
28
|
+
"""The probe refused the request (resolution rejected): unknown fn / op, malformed, unavailable, locked..."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, result: m.Result):
|
|
31
|
+
super().__init__(result.describe())
|
|
32
|
+
self.result = result
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Failed(OepError):
|
|
36
|
+
"""The probe ran the request and it did not work (completed, outcome failed or partial), or answered with a
|
|
37
|
+
resolution or outcome this host does not know (a failure too, oep-core §2.4)."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, result: m.Result | None, why: str = ""):
|
|
40
|
+
super().__init__(why or (result.describe() if result is not None else "failed"))
|
|
41
|
+
self.result = result
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class NotV1(OepError):
|
|
45
|
+
"""The probe does not speak v1 (confirm answered revision 0, or refused the ranged confirm as malformed)."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Locked(Rejected):
|
|
49
|
+
@property
|
|
50
|
+
def remaining_ms(self) -> int:
|
|
51
|
+
return struct.unpack("<I", self.result.payload[:4])[0]
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def owner(self) -> str | None:
|
|
55
|
+
"""The holder's owner text, when its open gave one (oep-core §6.4)."""
|
|
56
|
+
value = m.Tail.parse(self.result.payload[4:]).get(OWNER)
|
|
57
|
+
return value.decode("utf-8", "replace") if value is not None else None
|
|
58
|
+
|
|
59
|
+
def __str__(self) -> str:
|
|
60
|
+
who = f" by {self.owner}" if self.owner else ""
|
|
61
|
+
return f"locked{who} ({self.remaining_ms} ms of its lease left)"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class InUse(OepError):
|
|
65
|
+
"""The lock stayed with another session: its holder kept its lease going (named when it gave an owner)."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class NoSession(Rejected):
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Busy(Rejected):
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class NoConnection(Rejected):
|
|
77
|
+
"""The probe does not know the connection (never attached, probe restarted, lost to a wire or target reset):
|
|
78
|
+
attach again."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Unsupported(Rejected):
|
|
82
|
+
"""A critical TLV (`tag`) or a fixed-part value (tag None) the probe cannot handle."""
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def tag(self) -> int | None:
|
|
86
|
+
return self.result.payload[0] if self.result.payload else None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
_REJECTS = {m.LOCKED: Locked, m.NO_SESSION: NoSession, m.BUSY: Busy, m.NO_CONNECTION: NoConnection,
|
|
90
|
+
m.UNSUPPORTED: Unsupported}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def rejection(result: m.Result) -> Rejected:
|
|
94
|
+
return _REJECTS.get(result.detail, Rejected)(result)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class Opened:
|
|
99
|
+
lease_ms: int
|
|
100
|
+
boot_id: int
|
|
101
|
+
resumed: bool
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class Host:
|
|
106
|
+
send: Callable[[bytes], bytes]
|
|
107
|
+
rng: random.Random = field(default_factory=random.SystemRandom)
|
|
108
|
+
session: int | None = None
|
|
109
|
+
# The link's pipelining (SerialLink.exchange bound to the probe's in-flight / window limits); None: one at a time.
|
|
110
|
+
exchange: Callable[[list[bytes]], list[bytes]] | None = None
|
|
111
|
+
revision: int | None = None # confirm's answer; None until asked
|
|
112
|
+
limits: dict | None = None # confirm's answer as a dict
|
|
113
|
+
epoch: int = 0 # +1 whenever every connection and the plan are lost (§3)
|
|
114
|
+
subscriptions: set = field(default_factory=set) # fns subscribed in this session (a resync stops them blind)
|
|
115
|
+
# Called with every capture segment read (capture.CaptureRecord): the hook a run recorder hangs on.
|
|
116
|
+
on_capture: list = field(default_factory=list)
|
|
117
|
+
_corr: int = 0
|
|
118
|
+
_fns: dict = field(default_factory=dict) # interface name -> fn, valid until the probe reboots (boot_id)
|
|
119
|
+
_revisions: dict = field(default_factory=dict) # fn -> interface revision from list
|
|
120
|
+
_boot_id: int | None = None
|
|
121
|
+
|
|
122
|
+
def next_corr(self) -> int:
|
|
123
|
+
self._corr = self._corr % 0xFFFF + 1
|
|
124
|
+
return self._corr
|
|
125
|
+
|
|
126
|
+
def call(self, fn: int, op: int, payload: bytes = b"", *, locked: bool = True) -> m.Result:
|
|
127
|
+
"""request() that also raises Failed unless the probe says it worked: what every operation wants."""
|
|
128
|
+
r = self.request(fn, op, payload, locked=locked)
|
|
129
|
+
if not r.succeeded:
|
|
130
|
+
raise Failed(r)
|
|
131
|
+
return r
|
|
132
|
+
|
|
133
|
+
def _session_for(self, locked: bool) -> int | None:
|
|
134
|
+
if not locked or self.session is None:
|
|
135
|
+
return None
|
|
136
|
+
self.require_v1()
|
|
137
|
+
return self.session
|
|
138
|
+
|
|
139
|
+
def request(self, fn: int, op: int, payload: bytes = b"", *, locked: bool = True) -> m.Result:
|
|
140
|
+
"""locked=True sends the session id (role 0x81) once a session is open; lock-free requests may leave it off.
|
|
141
|
+
Rejections raise; any other answer is returned (Result.succeeded / .ran say what it was)."""
|
|
142
|
+
req = m.Request(self.next_corr(), fn, op, payload, self._session_for(locked))
|
|
143
|
+
result = m.Result.unpack(self.send(req.pack()))
|
|
144
|
+
if result.corr != req.corr:
|
|
145
|
+
raise ProtocolError(f"result for correlation {result.corr}, expected {req.corr}")
|
|
146
|
+
if result.resolution == m.REJECTED:
|
|
147
|
+
self._rejected(result)
|
|
148
|
+
raise rejection(result)
|
|
149
|
+
return result
|
|
150
|
+
|
|
151
|
+
def _rejected(self, result: m.Result) -> None:
|
|
152
|
+
if result.detail == m.NO_SESSION:
|
|
153
|
+
self.subscriptions.clear() # the lock is gone, and the subscriptions with it
|
|
154
|
+
if self._boot_id == 0:
|
|
155
|
+
self._lost() # a probe that cannot tell its boots: assume it restarted
|
|
156
|
+
|
|
157
|
+
def _lost(self) -> None:
|
|
158
|
+
self.epoch += 1
|
|
159
|
+
self._fns.clear() # a rebooted probe may number its interfaces differently
|
|
160
|
+
self._revisions.clear()
|
|
161
|
+
self.subscriptions.clear()
|
|
162
|
+
|
|
163
|
+
def boot_id_seen(self, boot_id: int) -> None:
|
|
164
|
+
"""A boot_id from an open result or a heartbeat: a change means the probe restarted (§3)."""
|
|
165
|
+
if self._boot_id is not None and boot_id != self._boot_id:
|
|
166
|
+
self._lost()
|
|
167
|
+
self._boot_id = boot_id
|
|
168
|
+
|
|
169
|
+
def pipeline(self, requests: list[tuple[int, int, bytes]], exchange: Callable[[list[bytes]], list[bytes]] | None = None,
|
|
170
|
+
*, locked: bool = True) -> list[m.Result]:
|
|
171
|
+
"""Several requests in flight (`exchange` keeps the probe's in-flight and window limits); results in
|
|
172
|
+
order, rejects NOT raised - the caller looks at each result. Without `exchange`, one at a time."""
|
|
173
|
+
session = self._session_for(locked)
|
|
174
|
+
reqs = [m.Request(self.next_corr(), fn, op, payload, session) for fn, op, payload in requests]
|
|
175
|
+
packed = [r.pack() for r in reqs]
|
|
176
|
+
exchange = exchange or self.exchange
|
|
177
|
+
replies = exchange(packed) if exchange else [self.send(p) for p in packed]
|
|
178
|
+
results = [m.Result.unpack(r) for r in replies]
|
|
179
|
+
for req, res in zip(reqs, results):
|
|
180
|
+
if res.corr != req.corr:
|
|
181
|
+
raise ProtocolError(f"result for correlation {res.corr}, expected {req.corr}")
|
|
182
|
+
if res.resolution == m.REJECTED:
|
|
183
|
+
self._rejected(res)
|
|
184
|
+
return results
|
|
185
|
+
|
|
186
|
+
def pipeline_calls(self, requests: list[tuple[int, int, bytes]], *, locked: bool = True) -> list[m.Result]:
|
|
187
|
+
"""pipeline() for operations that must all work: raises at the first result that was not a success (the
|
|
188
|
+
probe ran every request in order anyway)."""
|
|
189
|
+
results = self.pipeline(requests, locked=locked)
|
|
190
|
+
for r in results:
|
|
191
|
+
if r.resolution == m.REJECTED:
|
|
192
|
+
raise rejection(r)
|
|
193
|
+
if not r.succeeded:
|
|
194
|
+
raise Failed(r)
|
|
195
|
+
return results
|
|
196
|
+
|
|
197
|
+
# ---- confirm (§5, §2) -----------------------------------------------------------------------
|
|
198
|
+
def confirm(self, min_rev: int = MIN_REVISION, max_rev: int = MAX_REVISION) -> dict:
|
|
199
|
+
"""Ask for a revision in [min_rev, max_rev]. -> {"revision", "flags", "max_frame", "window", "max_inflight"}.
|
|
200
|
+
A v0 probe answers revision 0 in the v0 shape; one that refuses the ranged confirm as malformed is not v1
|
|
201
|
+
either (revision 0 is recorded and the rejection raised). No revision in the range: rejected unsupported."""
|
|
202
|
+
try:
|
|
203
|
+
r = self.request(m.CORE_FN, m.OP_CONFIRM, m.CONFIRM_REQUEST + bytes([min_rev, max_rev]), locked=False)
|
|
204
|
+
except Rejected as e:
|
|
205
|
+
if e.result.detail == m.MALFORMED:
|
|
206
|
+
self.revision = 0
|
|
207
|
+
raise
|
|
208
|
+
if not r.succeeded:
|
|
209
|
+
raise Failed(r)
|
|
210
|
+
rd = m.Reader(r.payload)
|
|
211
|
+
magic, revision = rd.bytes(4), rd.u8()
|
|
212
|
+
if magic != m.CONFIRM_RESULT:
|
|
213
|
+
raise ProtocolError(f"confirm answered magic {magic!r}")
|
|
214
|
+
if revision == 0: # v0: max_frame(16) window(16) max_inflight(8) flags(8)
|
|
215
|
+
max_frame, window, inflight = rd.take("HHB")
|
|
216
|
+
flags = rd.u8() if rd.at < len(rd.data) else 0
|
|
217
|
+
tail = m.Tail()
|
|
218
|
+
else:
|
|
219
|
+
if not min_rev <= revision <= max_rev:
|
|
220
|
+
raise ProtocolError(f"confirm answered revision {revision}, outside the {min_rev}..{max_rev} asked")
|
|
221
|
+
flags, max_frame, window, inflight = rd.take("BHIB")
|
|
222
|
+
tail = rd.tail()
|
|
223
|
+
self.revision = revision
|
|
224
|
+
self.limits = {"magic": magic, "revision": revision, "flags": flags, "max_frame": max_frame, "window": window,
|
|
225
|
+
"max_inflight": inflight, "tail": tail}
|
|
226
|
+
return self.limits
|
|
227
|
+
|
|
228
|
+
def confirmed(self) -> dict:
|
|
229
|
+
"""confirm()'s answer, asked once per host."""
|
|
230
|
+
return self.limits if self.limits is not None else self.confirm()
|
|
231
|
+
|
|
232
|
+
def require_v1(self) -> None:
|
|
233
|
+
"""Before anything in the v1 shapes (role 0x81, open): the probe must have confirmed revision >= 1."""
|
|
234
|
+
if self.revision is None:
|
|
235
|
+
try:
|
|
236
|
+
self.confirm()
|
|
237
|
+
except Rejected as e:
|
|
238
|
+
if self.revision == 0:
|
|
239
|
+
raise NotV1("the probe refused the ranged confirm (malformed): not a v1 probe") from e
|
|
240
|
+
raise
|
|
241
|
+
if self.revision < 1:
|
|
242
|
+
raise NotV1(f"the probe speaks OEP revision {self.revision}; session requests need revision 1 or more")
|
|
243
|
+
|
|
244
|
+
# ---- session --------------------------------------------------------------------------------
|
|
245
|
+
def open(self, lease_ms: int = 0, *, force: bool = False, session: int | None = None,
|
|
246
|
+
owner: str | None = None) -> Opened:
|
|
247
|
+
"""A new random id unless `session` is given (a one-shot CLI resuming its saved id). lease_ms 0 = the probe's
|
|
248
|
+
default; 1000..60000 are taken as asked. owner: who holds the lock (1-32 bytes), shown to other hosts."""
|
|
249
|
+
self.require_v1()
|
|
250
|
+
sid = session if session is not None else self.rng.randrange(1, 1 << 32)
|
|
251
|
+
tail = m.tlv(OWNER, owner.encode()[:32]) if owner else b""
|
|
252
|
+
r = self.request(m.CORE_FN, m.OP_OPEN, struct.pack("<IIB", sid, lease_ms, int(force)) + tail, locked=False)
|
|
253
|
+
if sid != self.session:
|
|
254
|
+
self.subscriptions.clear()
|
|
255
|
+
self.session = sid
|
|
256
|
+
lease, boot_id, resumed = m.Reader(r.payload).take("IIB")
|
|
257
|
+
self.boot_id_seen(boot_id)
|
|
258
|
+
if not resumed:
|
|
259
|
+
self.subscriptions.clear()
|
|
260
|
+
return Opened(lease, boot_id, bool(resumed))
|
|
261
|
+
|
|
262
|
+
def end(self) -> None:
|
|
263
|
+
self.request(m.CORE_FN, m.OP_END)
|
|
264
|
+
self.subscriptions.clear()
|
|
265
|
+
|
|
266
|
+
def keepalive(self) -> None:
|
|
267
|
+
self.request(m.CORE_FN, m.OP_KEEPALIVE)
|
|
268
|
+
|
|
269
|
+
def lock_state(self) -> tuple[bool, int]:
|
|
270
|
+
locked, remaining = m.Reader(self.request(m.CORE_FN, m.OP_LOCK_STATE, locked=False).payload).take("BI")
|
|
271
|
+
return bool(locked), remaining
|
|
272
|
+
|
|
273
|
+
def lock_owner(self) -> tuple[bool, int, str | None]:
|
|
274
|
+
"""lock_state with the holder's owner text (None: it gave none)."""
|
|
275
|
+
p = self.request(m.CORE_FN, m.OP_LOCK_STATE, locked=False).payload
|
|
276
|
+
locked, remaining = m.Reader(p).take("BI")
|
|
277
|
+
value = m.Tail.parse(p[5:]).get(OWNER)
|
|
278
|
+
return bool(locked), remaining, value.decode("utf-8", "replace") if value is not None else None
|
|
279
|
+
|
|
280
|
+
def take(self, lease_ms: int = 3000, *, owner: str | None = None, only_way_in: bool = False,
|
|
281
|
+
wait_s: float = 5.0, force: bool = False) -> Opened:
|
|
282
|
+
"""open() the way host guide §2 takes the lock. only_way_in: this link is the probe's only transport and a
|
|
283
|
+
serial port opened exclusively - whoever held the lock cannot be there any more, so it is taken by force at
|
|
284
|
+
once. Otherwise the holder's lease is waited out (up to wait_s); a holder that keeps it going raises InUse,
|
|
285
|
+
naming it. force: take it anyway (the user said so)."""
|
|
286
|
+
import time
|
|
287
|
+
if force or only_way_in:
|
|
288
|
+
return self.open(lease_ms, force=True, owner=owner)
|
|
289
|
+
deadline = time.monotonic() + wait_s
|
|
290
|
+
while True:
|
|
291
|
+
try:
|
|
292
|
+
return self.open(lease_ms, owner=owner)
|
|
293
|
+
except Locked as e:
|
|
294
|
+
left = deadline - time.monotonic()
|
|
295
|
+
if left <= 0 or e.remaining_ms / 1000 > left:
|
|
296
|
+
who = e.owner or "another session"
|
|
297
|
+
raise InUse(f"the probe is in use by {who} (lease {e.remaining_ms} ms left, kept going)") from e
|
|
298
|
+
time.sleep(min(left, e.remaining_ms / 1000 + 0.05))
|
|
299
|
+
|
|
300
|
+
# ---- notifications (§4.5) -------------------------------------------------------------------
|
|
301
|
+
def subscribe(self, fn: int, min_bytes: int = 0, max_delay_ms: int = 0) -> None:
|
|
302
|
+
"""Events and data pushes from `fn` (fn 0: heartbeats every max_delay_ms, 0 = 1000 ms). Send when min_bytes are
|
|
303
|
+
ready or max_delay_ms after the first byte (0, 0: as soon as there is anything). Ends with the lock."""
|
|
304
|
+
self.call(m.CORE_FN, m.OP_SUBSCRIBE, struct.pack("<HHH", fn, min_bytes, max_delay_ms))
|
|
305
|
+
self.subscriptions.add(fn)
|
|
306
|
+
|
|
307
|
+
def unsubscribe(self, fn: int) -> None:
|
|
308
|
+
self.call(m.CORE_FN, m.OP_UNSUBSCRIBE, struct.pack("<H", fn))
|
|
309
|
+
self.subscriptions.discard(fn)
|
|
310
|
+
|
|
311
|
+
def blind_stop(self) -> list[bytes]:
|
|
312
|
+
"""The requests a resync may send without confirming (§1): unsubscribe every subscription and end the session
|
|
313
|
+
- both harmless when run twice - for when pushes keep the input from going quiet."""
|
|
314
|
+
if self.session is None or not self.revision:
|
|
315
|
+
return []
|
|
316
|
+
out = [m.Request(self.next_corr(), m.CORE_FN, m.OP_UNSUBSCRIBE, struct.pack("<H", fn), self.session).pack()
|
|
317
|
+
for fn in sorted(self.subscriptions)]
|
|
318
|
+
out.append(m.Request(self.next_corr(), m.CORE_FN, m.OP_END, b"", self.session).pack())
|
|
319
|
+
self.subscriptions.clear()
|
|
320
|
+
return out
|
|
321
|
+
|