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,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
+