bdo-toolkit 1.0.0__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.
- bdo_toolkit/__init__.py +87 -0
- bdo_toolkit/_async_sessions.py +651 -0
- bdo_toolkit/_capture_backend.py +194 -0
- bdo_toolkit/_capture_options.py +68 -0
- bdo_toolkit/_capture_runtime.py +626 -0
- bdo_toolkit/_deposit_origin.py +1599 -0
- bdo_toolkit/_engine.py +327 -0
- bdo_toolkit/_framing.py +904 -0
- bdo_toolkit/_profile_runtime.py +157 -0
- bdo_toolkit/_protocol.py +386 -0
- bdo_toolkit/_reassembly.py +654 -0
- bdo_toolkit/_specs.py +285 -0
- bdo_toolkit/_storage_destination_validation.py +167 -0
- bdo_toolkit/_storage_hydration.py +241 -0
- bdo_toolkit/_version.py +3 -0
- bdo_toolkit/calibration.py +3223 -0
- bdo_toolkit/capture.py +1713 -0
- bdo_toolkit/character_state.py +3506 -0
- bdo_toolkit/cli.py +948 -0
- bdo_toolkit/diagnostics.py +51 -0
- bdo_toolkit/events.py +214 -0
- bdo_toolkit/filters.py +105 -0
- bdo_toolkit/item_state.py +48 -0
- bdo_toolkit/origin_learning.py +779 -0
- bdo_toolkit/profiles.py +370 -0
- bdo_toolkit/py.typed +1 -0
- bdo_toolkit/remote_profiles.py +358 -0
- bdo_toolkit/solare/__init__.py +50 -0
- bdo_toolkit/solare/_constants.py +94 -0
- bdo_toolkit/solare/_detail_learning.py +1437 -0
- bdo_toolkit/solare/_details.py +796 -0
- bdo_toolkit/solare/_discovery.py +1212 -0
- bdo_toolkit/solare/_live_tracker.py +472 -0
- bdo_toolkit/solare/_replay_capture.py +182 -0
- bdo_toolkit/solare/_result.py +441 -0
- bdo_toolkit/solare/_scanner.py +203 -0
- bdo_toolkit/solare/_validation.py +11 -0
- bdo_toolkit/solare/async_session.py +444 -0
- bdo_toolkit/solare/models.py +806 -0
- bdo_toolkit/solare/replay.py +62 -0
- bdo_toolkit/solare/session.py +1051 -0
- bdo_toolkit/writers.py +30 -0
- bdo_toolkit-1.0.0.dist-info/METADATA +143 -0
- bdo_toolkit-1.0.0.dist-info/RECORD +48 -0
- bdo_toolkit-1.0.0.dist-info/WHEEL +5 -0
- bdo_toolkit-1.0.0.dist-info/entry_points.txt +2 -0
- bdo_toolkit-1.0.0.dist-info/licenses/LICENSE +21 -0
- bdo_toolkit-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
"""Asyncio facades for the synchronous live session APIs.
|
|
2
|
+
|
|
3
|
+
The packet capture, decoding, buffering, calibration, and shutdown logic stays
|
|
4
|
+
in :class:`LiveCaptureSession` and :class:`CalibrationSession`. These wrappers
|
|
5
|
+
only move their blocking lifecycle operations off the asyncio event-loop
|
|
6
|
+
thread and make cancellation deterministic.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
from collections import deque
|
|
13
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
14
|
+
from functools import partial
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from types import TracebackType
|
|
17
|
+
from typing import AsyncIterator, Callable, Optional, TypeVar
|
|
18
|
+
|
|
19
|
+
from ._capture_runtime import CaptureEndpoint, _attach_cleanup_owner
|
|
20
|
+
from ._capture_options import LiveCaptureOptions, PacketCaptureOptions
|
|
21
|
+
from .calibration import (
|
|
22
|
+
DEFAULT_CALIBRATION_MAX_RETAINED_BYTES,
|
|
23
|
+
DEFAULT_CALIBRATION_MAX_RETAINED_FRAMES,
|
|
24
|
+
CalibrationResult,
|
|
25
|
+
CalibrationRetention,
|
|
26
|
+
CalibrationSession,
|
|
27
|
+
)
|
|
28
|
+
from .capture import LiveCaptureHealth, LiveCaptureSession, _validate_poll_timeout
|
|
29
|
+
from .diagnostics import DecoderDiagnostic, DecoderHealth
|
|
30
|
+
from .events import BDOEvent
|
|
31
|
+
from .filters import EventFilter
|
|
32
|
+
from .origin_learning import CompanionObservation
|
|
33
|
+
from .profiles import OpcodeProfile
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
T = TypeVar("T")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def _wait_ignoring_cancellation(future: asyncio.Future[T]) -> T:
|
|
40
|
+
"""Wait for an already-started operation, even after caller cancellation."""
|
|
41
|
+
|
|
42
|
+
while not future.done():
|
|
43
|
+
try:
|
|
44
|
+
# asyncio.wait() never propagates cancellation into ``future`` and
|
|
45
|
+
# does not create a cancelled shield wrapper that may later log an
|
|
46
|
+
# otherwise-retrieved worker exception on Python 3.14.
|
|
47
|
+
await asyncio.wait((future,))
|
|
48
|
+
except asyncio.CancelledError:
|
|
49
|
+
# Cleanup must settle before the original cancellation escapes.
|
|
50
|
+
continue
|
|
51
|
+
return future.result()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def _await_preserving_future(future: asyncio.Future[T]) -> T:
|
|
55
|
+
"""Await without cancelling or wrapping the submitted worker future."""
|
|
56
|
+
|
|
57
|
+
await asyncio.wait((future,))
|
|
58
|
+
return future.result()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _thread_task(function: Callable[[], T]) -> asyncio.Task[T]:
|
|
62
|
+
return asyncio.create_task(asyncio.to_thread(function))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class AsyncLiveCaptureSession:
|
|
66
|
+
"""Awaitable facade over :class:`LiveCaptureSession`.
|
|
67
|
+
|
|
68
|
+
The underlying synchronous session remains the sole owner of capture,
|
|
69
|
+
event ordering, bounded buffering, finalization, and background errors.
|
|
70
|
+
A session is single-use and supports one event consumer.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
*,
|
|
76
|
+
opcode_profile: str | Path | OpcodeProfile,
|
|
77
|
+
live_options: Optional[LiveCaptureOptions] = None,
|
|
78
|
+
event_filter: Optional[EventFilter] = None,
|
|
79
|
+
origin_observer: Optional[Callable[[CompanionObservation], object]] = None,
|
|
80
|
+
on_diagnostic: Optional[Callable[[DecoderDiagnostic], object]] = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
self._session = LiveCaptureSession(
|
|
83
|
+
opcode_profile=opcode_profile,
|
|
84
|
+
live_options=live_options,
|
|
85
|
+
event_filter=event_filter,
|
|
86
|
+
origin_observer=origin_observer,
|
|
87
|
+
on_diagnostic=on_diagnostic,
|
|
88
|
+
)
|
|
89
|
+
# A pending blocking poll needs a second worker so stop() can wake it,
|
|
90
|
+
# even when the host app configured a one-thread default executor.
|
|
91
|
+
self._executor = ThreadPoolExecutor(
|
|
92
|
+
max_workers=2,
|
|
93
|
+
thread_name_prefix="bdo-toolkit-live",
|
|
94
|
+
)
|
|
95
|
+
self._executor_closed = False
|
|
96
|
+
self._stop_future: asyncio.Future[None] | None = None
|
|
97
|
+
self._poll_active = False
|
|
98
|
+
# A cancelled worker poll may already have removed one event before it
|
|
99
|
+
# settles. Keep that event session-owned so the next consumer can
|
|
100
|
+
# retrieve it. Single-consumer enforcement bounds this deque to one.
|
|
101
|
+
self._pending_events: deque[BDOEvent] = deque()
|
|
102
|
+
self._start_attempted = False
|
|
103
|
+
self._start_complete = False
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def running(self) -> bool:
|
|
107
|
+
return self._session.running
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def stopped(self) -> bool:
|
|
111
|
+
return self._session.stopped
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def cleanup_incomplete(self) -> bool:
|
|
115
|
+
"""Whether synchronous capture cleanup remains retryable."""
|
|
116
|
+
|
|
117
|
+
return self._session.cleanup_incomplete
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def stop_reason(self) -> Optional[str]:
|
|
121
|
+
return self._session.stop_reason
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def error(self) -> Optional[BaseException]:
|
|
125
|
+
return self._session.error
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def endpoint(self) -> Optional[CaptureEndpoint]:
|
|
129
|
+
return self._session.endpoint
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def health(self) -> LiveCaptureHealth:
|
|
133
|
+
"""Return the underlying observational health snapshot synchronously."""
|
|
134
|
+
|
|
135
|
+
return self._session.health
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def decoder_health(self) -> DecoderHealth:
|
|
139
|
+
return self._session.decoder_health
|
|
140
|
+
|
|
141
|
+
def raise_if_failed(self) -> None:
|
|
142
|
+
"""Re-raise a retained background capture failure."""
|
|
143
|
+
|
|
144
|
+
self._session.raise_if_failed()
|
|
145
|
+
|
|
146
|
+
def _submit(self, function: Callable[[], T]) -> asyncio.Future[T]:
|
|
147
|
+
if self._executor_closed:
|
|
148
|
+
raise RuntimeError("async live capture session is already closed")
|
|
149
|
+
loop = asyncio.get_running_loop()
|
|
150
|
+
return loop.run_in_executor(self._executor, function)
|
|
151
|
+
|
|
152
|
+
def _shutdown_executor(self) -> None:
|
|
153
|
+
if self._executor_closed:
|
|
154
|
+
return
|
|
155
|
+
self._executor.shutdown(wait=False, cancel_futures=False)
|
|
156
|
+
self._executor_closed = True
|
|
157
|
+
|
|
158
|
+
def _ensure_stop_future(self) -> asyncio.Future[None]:
|
|
159
|
+
if self._stop_future is None:
|
|
160
|
+
self._stop_future = self._submit(self._session.stop)
|
|
161
|
+
return self._stop_future
|
|
162
|
+
|
|
163
|
+
def _inside_origin_observer(self) -> bool:
|
|
164
|
+
predicate = getattr(self._session, "_inside_origin_observer", None)
|
|
165
|
+
return bool(predicate is not None and predicate())
|
|
166
|
+
|
|
167
|
+
async def start(self) -> None:
|
|
168
|
+
"""Start capture without blocking the asyncio event loop."""
|
|
169
|
+
|
|
170
|
+
# Reject repeat/concurrent starts before submitting work. In
|
|
171
|
+
# particular, a rejected second start must not close the executor that
|
|
172
|
+
# still owns a running first session and its eventual cleanup.
|
|
173
|
+
if self._start_attempted:
|
|
174
|
+
raise RuntimeError("async live capture session was already started")
|
|
175
|
+
self._start_attempted = True
|
|
176
|
+
start_future = self._submit(self._session.start)
|
|
177
|
+
try:
|
|
178
|
+
await _await_preserving_future(start_future)
|
|
179
|
+
except asyncio.CancelledError as exc:
|
|
180
|
+
started = False
|
|
181
|
+
stop_future: asyncio.Future[None] | None = None
|
|
182
|
+
try:
|
|
183
|
+
await _wait_ignoring_cancellation(start_future)
|
|
184
|
+
started = True
|
|
185
|
+
except BaseException:
|
|
186
|
+
# Cancellation remains the caller-visible outcome.
|
|
187
|
+
pass
|
|
188
|
+
if started or self._session.cleanup_incomplete:
|
|
189
|
+
self._start_complete = True
|
|
190
|
+
try:
|
|
191
|
+
stop_future = self._ensure_stop_future()
|
|
192
|
+
await _wait_ignoring_cancellation(stop_future)
|
|
193
|
+
except BaseException:
|
|
194
|
+
pass
|
|
195
|
+
if not self._session.cleanup_incomplete:
|
|
196
|
+
self._shutdown_executor()
|
|
197
|
+
else:
|
|
198
|
+
if stop_future is not None and self._stop_future is stop_future:
|
|
199
|
+
self._stop_future = None
|
|
200
|
+
_attach_cleanup_owner(
|
|
201
|
+
exc,
|
|
202
|
+
self,
|
|
203
|
+
context="async live item capture startup",
|
|
204
|
+
)
|
|
205
|
+
raise
|
|
206
|
+
except BaseException as exc:
|
|
207
|
+
# A failed startup never enters an async context, so close the
|
|
208
|
+
# wrapper-owned executor unless the sync session retained a live
|
|
209
|
+
# backend specifically so cleanup can be retried.
|
|
210
|
+
if self._session.cleanup_incomplete:
|
|
211
|
+
self._start_complete = True
|
|
212
|
+
_attach_cleanup_owner(
|
|
213
|
+
exc,
|
|
214
|
+
self,
|
|
215
|
+
context="async live item capture startup",
|
|
216
|
+
)
|
|
217
|
+
else:
|
|
218
|
+
self._shutdown_executor()
|
|
219
|
+
raise
|
|
220
|
+
else:
|
|
221
|
+
self._start_complete = True
|
|
222
|
+
|
|
223
|
+
async def stop(self) -> None:
|
|
224
|
+
"""Gracefully stop capture and finish decoder/origin state."""
|
|
225
|
+
|
|
226
|
+
if self._inside_origin_observer():
|
|
227
|
+
raise RuntimeError(
|
|
228
|
+
"stop() cannot block inside the live decoder or origin "
|
|
229
|
+
"observer; use request_stop() instead"
|
|
230
|
+
)
|
|
231
|
+
if self._session.stopped:
|
|
232
|
+
self._shutdown_executor()
|
|
233
|
+
return
|
|
234
|
+
if not self._start_complete:
|
|
235
|
+
raise RuntimeError("live capture session was not started")
|
|
236
|
+
|
|
237
|
+
stop_future = self._ensure_stop_future()
|
|
238
|
+
try:
|
|
239
|
+
await _await_preserving_future(stop_future)
|
|
240
|
+
except asyncio.CancelledError:
|
|
241
|
+
try:
|
|
242
|
+
try:
|
|
243
|
+
await _wait_ignoring_cancellation(stop_future)
|
|
244
|
+
except BaseException:
|
|
245
|
+
# Cancellation remains the caller-visible outcome.
|
|
246
|
+
pass
|
|
247
|
+
finally:
|
|
248
|
+
if not self._session.cleanup_incomplete:
|
|
249
|
+
self._shutdown_executor()
|
|
250
|
+
else:
|
|
251
|
+
if self._stop_future is stop_future:
|
|
252
|
+
self._stop_future = None
|
|
253
|
+
raise
|
|
254
|
+
except BaseException:
|
|
255
|
+
if self._session.stopped:
|
|
256
|
+
self._shutdown_executor()
|
|
257
|
+
else:
|
|
258
|
+
if self._stop_future is stop_future:
|
|
259
|
+
self._stop_future = None
|
|
260
|
+
raise
|
|
261
|
+
else:
|
|
262
|
+
self._shutdown_executor()
|
|
263
|
+
|
|
264
|
+
def request_stop(self) -> None:
|
|
265
|
+
"""Request callback-safe shutdown without blocking its worker thread."""
|
|
266
|
+
|
|
267
|
+
self._session.request_stop()
|
|
268
|
+
|
|
269
|
+
async def poll(self, timeout: Optional[float] = None) -> Optional[BDOEvent]:
|
|
270
|
+
"""Await one event, a timeout, or the fully drained stopped session.
|
|
271
|
+
|
|
272
|
+
Cancelling a pending poll is terminal for this single-consumer session:
|
|
273
|
+
capture is stopped before ``CancelledError`` is re-raised.
|
|
274
|
+
After completed stop, ``timeout`` is ignored while buffered data drains.
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
if self._inside_origin_observer():
|
|
278
|
+
raise RuntimeError(
|
|
279
|
+
"poll() cannot consume events inside origin_observer; "
|
|
280
|
+
"consume them after the callback returns"
|
|
281
|
+
)
|
|
282
|
+
if self._poll_active:
|
|
283
|
+
raise RuntimeError("async live capture session supports one consumer")
|
|
284
|
+
stopped_at_entry = self._session.stopped
|
|
285
|
+
if not self._start_complete and not stopped_at_entry:
|
|
286
|
+
raise RuntimeError("live capture session was not started")
|
|
287
|
+
# A cancellation-preserved event must not bypass the active timeout
|
|
288
|
+
# contract. Preserve the historical stopped fast path: it cannot wait
|
|
289
|
+
# and ignores the caller's timeout while draining.
|
|
290
|
+
if not stopped_at_entry:
|
|
291
|
+
_validate_poll_timeout(timeout)
|
|
292
|
+
|
|
293
|
+
self._poll_active = True
|
|
294
|
+
try:
|
|
295
|
+
if self._session.stopped:
|
|
296
|
+
self._shutdown_executor()
|
|
297
|
+
if self._pending_events:
|
|
298
|
+
return self._pending_events.popleft()
|
|
299
|
+
|
|
300
|
+
if self._session.stopped:
|
|
301
|
+
# After stop(), finalization is complete and a zero-time poll
|
|
302
|
+
# only drains already-buffered events; it cannot block.
|
|
303
|
+
return self._session.poll(timeout=0)
|
|
304
|
+
|
|
305
|
+
poll_future = self._submit(partial(self._session.poll, timeout))
|
|
306
|
+
try:
|
|
307
|
+
event = await _await_preserving_future(poll_future)
|
|
308
|
+
except asyncio.CancelledError:
|
|
309
|
+
stop_future: asyncio.Future[None] | None = None
|
|
310
|
+
try:
|
|
311
|
+
stop_future = self._ensure_stop_future()
|
|
312
|
+
await _wait_ignoring_cancellation(stop_future)
|
|
313
|
+
except BaseException:
|
|
314
|
+
pass
|
|
315
|
+
try:
|
|
316
|
+
event = await _wait_ignoring_cancellation(poll_future)
|
|
317
|
+
except BaseException:
|
|
318
|
+
pass
|
|
319
|
+
else:
|
|
320
|
+
if event is not None:
|
|
321
|
+
self._pending_events.append(event)
|
|
322
|
+
if not self._session.cleanup_incomplete:
|
|
323
|
+
self._shutdown_executor()
|
|
324
|
+
else:
|
|
325
|
+
if (
|
|
326
|
+
stop_future is not None
|
|
327
|
+
and self._stop_future is stop_future
|
|
328
|
+
):
|
|
329
|
+
self._stop_future = None
|
|
330
|
+
raise
|
|
331
|
+
except BaseException:
|
|
332
|
+
if self._session.stopped:
|
|
333
|
+
self._shutdown_executor()
|
|
334
|
+
raise
|
|
335
|
+
|
|
336
|
+
if self._session.stopped:
|
|
337
|
+
self._shutdown_executor()
|
|
338
|
+
return event
|
|
339
|
+
finally:
|
|
340
|
+
self._poll_active = False
|
|
341
|
+
|
|
342
|
+
async def events(self) -> AsyncIterator[BDOEvent]:
|
|
343
|
+
"""Yield events in order until capture stops and final events drain."""
|
|
344
|
+
|
|
345
|
+
if self._inside_origin_observer():
|
|
346
|
+
raise RuntimeError(
|
|
347
|
+
"events() cannot consume events inside origin_observer; "
|
|
348
|
+
"consume them after the callback returns"
|
|
349
|
+
)
|
|
350
|
+
while True:
|
|
351
|
+
event = await self.poll(timeout=None)
|
|
352
|
+
if event is None:
|
|
353
|
+
return
|
|
354
|
+
yield event
|
|
355
|
+
|
|
356
|
+
def __aiter__(self) -> AsyncIterator[BDOEvent]:
|
|
357
|
+
return self.events()
|
|
358
|
+
|
|
359
|
+
async def __aenter__(self) -> "AsyncLiveCaptureSession":
|
|
360
|
+
await self.start()
|
|
361
|
+
return self
|
|
362
|
+
|
|
363
|
+
async def __aexit__(
|
|
364
|
+
self,
|
|
365
|
+
exc_type: type[BaseException] | None,
|
|
366
|
+
exc_value: BaseException | None,
|
|
367
|
+
traceback: TracebackType | None,
|
|
368
|
+
) -> None:
|
|
369
|
+
try:
|
|
370
|
+
if not self._session.stopped:
|
|
371
|
+
await self.stop()
|
|
372
|
+
else:
|
|
373
|
+
self._shutdown_executor()
|
|
374
|
+
except BaseException as cleanup_error:
|
|
375
|
+
if exc_value is None:
|
|
376
|
+
raise
|
|
377
|
+
if self.cleanup_incomplete:
|
|
378
|
+
_attach_cleanup_owner(
|
|
379
|
+
exc_value,
|
|
380
|
+
self,
|
|
381
|
+
context="async live item capture context",
|
|
382
|
+
)
|
|
383
|
+
if hasattr(exc_value, "add_note"):
|
|
384
|
+
exc_value.add_note(
|
|
385
|
+
"async live item capture context cleanup also failed: "
|
|
386
|
+
f"{cleanup_error!r}"
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
class AsyncCalibrationSession:
|
|
391
|
+
"""Awaitable facade over :class:`CalibrationSession`.
|
|
392
|
+
|
|
393
|
+
``stop()`` returns the same :class:`CalibrationResult` as the synchronous
|
|
394
|
+
session. Exiting the async context before ``stop()`` calls ``abort()`` and
|
|
395
|
+
discards the unfinished calibration, matching the synchronous context
|
|
396
|
+
manager's safety behavior.
|
|
397
|
+
"""
|
|
398
|
+
|
|
399
|
+
def __init__(
|
|
400
|
+
self,
|
|
401
|
+
*,
|
|
402
|
+
item_id: int,
|
|
403
|
+
quantity: Optional[int] = None,
|
|
404
|
+
action: str = "auto",
|
|
405
|
+
capture_options: Optional[PacketCaptureOptions] = None,
|
|
406
|
+
context_frames: int = 5,
|
|
407
|
+
min_confidence: float = 0.80,
|
|
408
|
+
max_retained_frames: int = DEFAULT_CALIBRATION_MAX_RETAINED_FRAMES,
|
|
409
|
+
max_retained_bytes: int = DEFAULT_CALIBRATION_MAX_RETAINED_BYTES,
|
|
410
|
+
) -> None:
|
|
411
|
+
self._session = CalibrationSession(
|
|
412
|
+
item_id=item_id,
|
|
413
|
+
quantity=quantity,
|
|
414
|
+
action=action,
|
|
415
|
+
capture_options=capture_options,
|
|
416
|
+
context_frames=context_frames,
|
|
417
|
+
min_confidence=min_confidence,
|
|
418
|
+
max_retained_frames=max_retained_frames,
|
|
419
|
+
max_retained_bytes=max_retained_bytes,
|
|
420
|
+
)
|
|
421
|
+
self._active = False
|
|
422
|
+
self._terminal_action: str | None = None
|
|
423
|
+
self._stop_task: asyncio.Task[CalibrationResult] | None = None
|
|
424
|
+
self._abort_task: asyncio.Task[None] | None = None
|
|
425
|
+
self._result: CalibrationResult | None = None
|
|
426
|
+
|
|
427
|
+
@property
|
|
428
|
+
def running(self) -> bool:
|
|
429
|
+
return self._session.running
|
|
430
|
+
|
|
431
|
+
@property
|
|
432
|
+
def cleanup_incomplete(self) -> bool:
|
|
433
|
+
"""Whether calibration cleanup remains owned for another attempt."""
|
|
434
|
+
|
|
435
|
+
return self._session.cleanup_incomplete
|
|
436
|
+
|
|
437
|
+
@property
|
|
438
|
+
def frames_collected(self) -> int:
|
|
439
|
+
return self._session.frames_collected
|
|
440
|
+
|
|
441
|
+
@property
|
|
442
|
+
def frames_observed(self) -> int:
|
|
443
|
+
return self._session.frames_observed
|
|
444
|
+
|
|
445
|
+
@property
|
|
446
|
+
def frames_retained(self) -> int:
|
|
447
|
+
return self._session.frames_retained
|
|
448
|
+
|
|
449
|
+
@property
|
|
450
|
+
def frames_discarded(self) -> int:
|
|
451
|
+
return self._session.frames_discarded
|
|
452
|
+
|
|
453
|
+
@property
|
|
454
|
+
def bytes_observed(self) -> int:
|
|
455
|
+
return self._session.bytes_observed
|
|
456
|
+
|
|
457
|
+
@property
|
|
458
|
+
def bytes_retained(self) -> int:
|
|
459
|
+
return self._session.bytes_retained
|
|
460
|
+
|
|
461
|
+
@property
|
|
462
|
+
def bytes_discarded(self) -> int:
|
|
463
|
+
return self._session.bytes_discarded
|
|
464
|
+
|
|
465
|
+
@property
|
|
466
|
+
def retention_truncated(self) -> bool:
|
|
467
|
+
return self._session.retention_truncated
|
|
468
|
+
|
|
469
|
+
@property
|
|
470
|
+
def retention(self) -> CalibrationRetention:
|
|
471
|
+
return self._session.retention
|
|
472
|
+
|
|
473
|
+
@property
|
|
474
|
+
def result(self) -> CalibrationResult | None:
|
|
475
|
+
"""Completed result, including one preserved across cancellation."""
|
|
476
|
+
|
|
477
|
+
return self._result
|
|
478
|
+
|
|
479
|
+
async def start(self) -> None:
|
|
480
|
+
"""Begin calibration capture without blocking the event loop."""
|
|
481
|
+
|
|
482
|
+
if self._active:
|
|
483
|
+
raise RuntimeError("calibration session is already running")
|
|
484
|
+
|
|
485
|
+
start_task = _thread_task(self._session.start)
|
|
486
|
+
try:
|
|
487
|
+
await _await_preserving_future(start_task)
|
|
488
|
+
except asyncio.CancelledError as exc:
|
|
489
|
+
started = False
|
|
490
|
+
try:
|
|
491
|
+
await _wait_ignoring_cancellation(start_task)
|
|
492
|
+
started = True
|
|
493
|
+
except BaseException:
|
|
494
|
+
pass
|
|
495
|
+
if started or self._session.cleanup_incomplete:
|
|
496
|
+
# A late successful start belongs to a new calibration run.
|
|
497
|
+
# Clear every terminal artifact from the prior run before
|
|
498
|
+
# attempting cancellation cleanup, so an exposed cleanup
|
|
499
|
+
# owner cannot return a stale result or reuse an old task.
|
|
500
|
+
self._terminal_action = None
|
|
501
|
+
self._stop_task = None
|
|
502
|
+
self._abort_task = None
|
|
503
|
+
self._result = None
|
|
504
|
+
abort_task = _thread_task(
|
|
505
|
+
partial(self._session.__exit__, None, None, None)
|
|
506
|
+
)
|
|
507
|
+
try:
|
|
508
|
+
await _wait_ignoring_cancellation(abort_task)
|
|
509
|
+
except BaseException:
|
|
510
|
+
pass
|
|
511
|
+
if self._session.cleanup_incomplete:
|
|
512
|
+
self._active = True
|
|
513
|
+
_attach_cleanup_owner(
|
|
514
|
+
exc,
|
|
515
|
+
self,
|
|
516
|
+
context="async live calibration startup",
|
|
517
|
+
)
|
|
518
|
+
raise
|
|
519
|
+
except BaseException as exc:
|
|
520
|
+
if self._session.cleanup_incomplete:
|
|
521
|
+
self._active = True
|
|
522
|
+
self._terminal_action = None
|
|
523
|
+
self._stop_task = None
|
|
524
|
+
self._abort_task = None
|
|
525
|
+
self._result = None
|
|
526
|
+
_attach_cleanup_owner(
|
|
527
|
+
exc,
|
|
528
|
+
self,
|
|
529
|
+
context="async live calibration startup",
|
|
530
|
+
)
|
|
531
|
+
raise
|
|
532
|
+
|
|
533
|
+
self._active = True
|
|
534
|
+
self._terminal_action = None
|
|
535
|
+
self._stop_task = None
|
|
536
|
+
self._abort_task = None
|
|
537
|
+
self._result = None
|
|
538
|
+
|
|
539
|
+
async def _finish(self) -> CalibrationResult:
|
|
540
|
+
try:
|
|
541
|
+
result = await asyncio.to_thread(self._session.stop)
|
|
542
|
+
except BaseException:
|
|
543
|
+
self._active = self._session.cleanup_incomplete
|
|
544
|
+
raise
|
|
545
|
+
self._result = result
|
|
546
|
+
self._active = False
|
|
547
|
+
return result
|
|
548
|
+
|
|
549
|
+
async def stop(self) -> CalibrationResult:
|
|
550
|
+
"""Stop capture, run calibration, and return its result."""
|
|
551
|
+
|
|
552
|
+
if self._terminal_action == "abort":
|
|
553
|
+
raise RuntimeError("calibration session was aborted")
|
|
554
|
+
if not self._active and self._stop_task is None:
|
|
555
|
+
raise RuntimeError("calibration session was not started")
|
|
556
|
+
|
|
557
|
+
if self._stop_task is None:
|
|
558
|
+
self._terminal_action = "stop"
|
|
559
|
+
self._stop_task = asyncio.create_task(self._finish())
|
|
560
|
+
stop_task = self._stop_task
|
|
561
|
+
|
|
562
|
+
try:
|
|
563
|
+
return await _await_preserving_future(stop_task)
|
|
564
|
+
except asyncio.CancelledError:
|
|
565
|
+
try:
|
|
566
|
+
await _wait_ignoring_cancellation(stop_task)
|
|
567
|
+
except BaseException:
|
|
568
|
+
pass
|
|
569
|
+
if self._session.cleanup_incomplete:
|
|
570
|
+
if self._stop_task is stop_task:
|
|
571
|
+
self._stop_task = None
|
|
572
|
+
self._active = True
|
|
573
|
+
raise
|
|
574
|
+
except BaseException:
|
|
575
|
+
if self._session.cleanup_incomplete:
|
|
576
|
+
if self._stop_task is stop_task:
|
|
577
|
+
self._stop_task = None
|
|
578
|
+
self._active = True
|
|
579
|
+
raise
|
|
580
|
+
|
|
581
|
+
async def _discard(self) -> None:
|
|
582
|
+
try:
|
|
583
|
+
await asyncio.to_thread(self._session.__exit__, None, None, None)
|
|
584
|
+
except BaseException:
|
|
585
|
+
self._active = self._session.cleanup_incomplete
|
|
586
|
+
raise
|
|
587
|
+
else:
|
|
588
|
+
self._active = False
|
|
589
|
+
|
|
590
|
+
async def abort(self) -> None:
|
|
591
|
+
"""Stop capture and discard it without running calibration."""
|
|
592
|
+
|
|
593
|
+
if self._terminal_action == "stop":
|
|
594
|
+
await self.stop()
|
|
595
|
+
return
|
|
596
|
+
if not self._active and self._abort_task is None:
|
|
597
|
+
return
|
|
598
|
+
|
|
599
|
+
if self._abort_task is None:
|
|
600
|
+
self._terminal_action = "abort"
|
|
601
|
+
self._abort_task = asyncio.create_task(self._discard())
|
|
602
|
+
abort_task = self._abort_task
|
|
603
|
+
|
|
604
|
+
try:
|
|
605
|
+
await _await_preserving_future(abort_task)
|
|
606
|
+
except asyncio.CancelledError:
|
|
607
|
+
try:
|
|
608
|
+
await _wait_ignoring_cancellation(abort_task)
|
|
609
|
+
except BaseException:
|
|
610
|
+
pass
|
|
611
|
+
if self._session.cleanup_incomplete:
|
|
612
|
+
if self._abort_task is abort_task:
|
|
613
|
+
self._abort_task = None
|
|
614
|
+
self._active = True
|
|
615
|
+
raise
|
|
616
|
+
except BaseException:
|
|
617
|
+
if self._session.cleanup_incomplete:
|
|
618
|
+
if self._abort_task is abort_task:
|
|
619
|
+
self._abort_task = None
|
|
620
|
+
self._active = True
|
|
621
|
+
raise
|
|
622
|
+
|
|
623
|
+
async def __aenter__(self) -> "AsyncCalibrationSession":
|
|
624
|
+
await self.start()
|
|
625
|
+
return self
|
|
626
|
+
|
|
627
|
+
async def __aexit__(
|
|
628
|
+
self,
|
|
629
|
+
exc_type: type[BaseException] | None,
|
|
630
|
+
exc_value: BaseException | None,
|
|
631
|
+
traceback: TracebackType | None,
|
|
632
|
+
) -> None:
|
|
633
|
+
try:
|
|
634
|
+
if self._terminal_action == "stop":
|
|
635
|
+
await self.stop()
|
|
636
|
+
elif self._active:
|
|
637
|
+
await self.abort()
|
|
638
|
+
except BaseException as cleanup_error:
|
|
639
|
+
if exc_value is None:
|
|
640
|
+
raise
|
|
641
|
+
if self.cleanup_incomplete:
|
|
642
|
+
_attach_cleanup_owner(
|
|
643
|
+
exc_value,
|
|
644
|
+
self,
|
|
645
|
+
context="async live calibration context",
|
|
646
|
+
)
|
|
647
|
+
if hasattr(exc_value, "add_note"):
|
|
648
|
+
exc_value.add_note(
|
|
649
|
+
"async live calibration context cleanup also failed: "
|
|
650
|
+
f"{cleanup_error!r}"
|
|
651
|
+
)
|