syndesi 0.4.2__py3-none-any.whl → 0.5.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.
- syndesi/__init__.py +22 -2
- syndesi/adapters/adapter.py +332 -489
- syndesi/adapters/adapter_worker.py +820 -0
- syndesi/adapters/auto.py +58 -25
- syndesi/adapters/descriptors.py +38 -0
- syndesi/adapters/ip.py +203 -71
- syndesi/adapters/serialport.py +154 -25
- syndesi/adapters/stop_conditions.py +354 -0
- syndesi/adapters/timeout.py +58 -21
- syndesi/adapters/visa.py +236 -11
- syndesi/cli/console.py +51 -16
- syndesi/cli/shell.py +95 -47
- syndesi/cli/terminal_tools.py +8 -8
- syndesi/component.py +315 -0
- syndesi/protocols/delimited.py +92 -107
- syndesi/protocols/modbus.py +2368 -868
- syndesi/protocols/protocol.py +186 -33
- syndesi/protocols/raw.py +45 -62
- syndesi/protocols/scpi.py +65 -102
- syndesi/remote/remote.py +188 -0
- syndesi/scripts/syndesi.py +12 -2
- syndesi/tools/errors.py +49 -31
- syndesi/tools/log_settings.py +21 -8
- syndesi/tools/{log.py → logmanager.py} +24 -13
- syndesi/tools/types.py +9 -7
- syndesi/version.py +5 -1
- {syndesi-0.4.2.dist-info → syndesi-0.5.0.dist-info}/METADATA +1 -1
- syndesi-0.5.0.dist-info/RECORD +41 -0
- syndesi/adapters/backend/__init__.py +0 -0
- syndesi/adapters/backend/adapter_backend.py +0 -438
- syndesi/adapters/backend/adapter_manager.py +0 -48
- syndesi/adapters/backend/adapter_session.py +0 -346
- syndesi/adapters/backend/backend.py +0 -438
- syndesi/adapters/backend/backend_status.py +0 -0
- syndesi/adapters/backend/backend_tools.py +0 -66
- syndesi/adapters/backend/descriptors.py +0 -153
- syndesi/adapters/backend/ip_backend.py +0 -149
- syndesi/adapters/backend/serialport_backend.py +0 -241
- syndesi/adapters/backend/stop_condition_backend.py +0 -219
- syndesi/adapters/backend/timed_queue.py +0 -39
- syndesi/adapters/backend/timeout.py +0 -252
- syndesi/adapters/backend/visa_backend.py +0 -197
- syndesi/adapters/ip_server.py +0 -102
- syndesi/adapters/stop_condition.py +0 -90
- syndesi/cli/backend_console.py +0 -96
- syndesi/cli/backend_status.py +0 -274
- syndesi/cli/backend_wrapper.py +0 -61
- syndesi/scripts/syndesi_backend.py +0 -37
- syndesi/tools/backend_api.py +0 -175
- syndesi/tools/backend_logger.py +0 -64
- syndesi/tools/exceptions.py +0 -16
- syndesi/tools/internal.py +0 -0
- syndesi-0.4.2.dist-info/RECORD +0 -60
- {syndesi-0.4.2.dist-info → syndesi-0.5.0.dist-info}/WHEEL +0 -0
- {syndesi-0.4.2.dist-info → syndesi-0.5.0.dist-info}/entry_points.txt +0 -0
- {syndesi-0.4.2.dist-info → syndesi-0.5.0.dist-info}/licenses/LICENSE +0 -0
- {syndesi-0.4.2.dist-info → syndesi-0.5.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,820 @@
|
|
|
1
|
+
# File : adapter_worker.py
|
|
2
|
+
# Author : Sébastien Deriaz
|
|
3
|
+
# License : GPL
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
Adapter worker mixin and worker command types.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import queue
|
|
11
|
+
import socket
|
|
12
|
+
import time
|
|
13
|
+
from abc import abstractmethod
|
|
14
|
+
from collections import deque
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from select import select
|
|
18
|
+
from types import EllipsisType
|
|
19
|
+
from typing import Any, Protocol
|
|
20
|
+
|
|
21
|
+
from syndesi.tools.log_settings import LoggerAlias
|
|
22
|
+
|
|
23
|
+
from ..component import AdapterFrame, Descriptor, Event, ReadScope, ThreadCommand
|
|
24
|
+
from ..tools.errors import (
|
|
25
|
+
AdapterDisconnected,
|
|
26
|
+
AdapterError,
|
|
27
|
+
AdapterOpenError,
|
|
28
|
+
AdapterReadError,
|
|
29
|
+
AdapterTimeoutError,
|
|
30
|
+
AdapterWriteError,
|
|
31
|
+
WorkerThreadError,
|
|
32
|
+
)
|
|
33
|
+
from .stop_conditions import (
|
|
34
|
+
Continuation,
|
|
35
|
+
Fragment,
|
|
36
|
+
StopCondition,
|
|
37
|
+
StopConditionType,
|
|
38
|
+
Total,
|
|
39
|
+
)
|
|
40
|
+
from .timeout import Timeout, TimeoutAction, any_to_timeout
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def nmin(a: float | None, b: float | None) -> float | None:
|
|
44
|
+
"""
|
|
45
|
+
Return min of a and b, ignoring None values
|
|
46
|
+
|
|
47
|
+
If both a and b are None, return None
|
|
48
|
+
"""
|
|
49
|
+
if a is None and b is None:
|
|
50
|
+
return None
|
|
51
|
+
if a is None:
|
|
52
|
+
return b
|
|
53
|
+
if b is None:
|
|
54
|
+
return a
|
|
55
|
+
return min(a, b)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class HasFileno(Protocol):
|
|
59
|
+
"""
|
|
60
|
+
A class to annotate objects that have a fileno function
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def fileno(self) -> int:
|
|
64
|
+
"""
|
|
65
|
+
Return file number
|
|
66
|
+
"""
|
|
67
|
+
return -1
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ┌────────────────┐
|
|
71
|
+
# │ Adapter events │
|
|
72
|
+
# └────────────────┘
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class AdapterEvent(Event):
|
|
76
|
+
"""Adapter event"""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class AdapterDisconnectedEvent(AdapterEvent):
|
|
80
|
+
"""Adapter disconnected event"""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class AdapterFrameEvent(AdapterEvent):
|
|
85
|
+
"""Adapter frame event, emitted when new data is available"""
|
|
86
|
+
|
|
87
|
+
frame: AdapterFrame
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class FirstFragmentEvent(AdapterEvent):
|
|
92
|
+
"""Adapter first fragment event"""
|
|
93
|
+
|
|
94
|
+
timestamp: float
|
|
95
|
+
next_timeout_timestamp: float | None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ┌───────────────────────────────┐
|
|
99
|
+
# │ Worker commands (composition) │
|
|
100
|
+
# └───────────────────────────────┘
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class OpenCommand(ThreadCommand[None]):
|
|
104
|
+
"""Open the adapter"""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class CloseCommand(ThreadCommand[None]):
|
|
108
|
+
"""Close the adapter"""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class StopThreadCommand(ThreadCommand[None]):
|
|
112
|
+
"""Stop the worker thread"""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class FlushReadCommand(ThreadCommand[None]):
|
|
116
|
+
"""Clear buffered frames and reset worker read state"""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class SetEventCallbackCommand(ThreadCommand[None]):
|
|
120
|
+
"""Configure the callback event"""
|
|
121
|
+
|
|
122
|
+
def __init__(self, callback: Callable[[AdapterEvent], None] | None) -> None:
|
|
123
|
+
super().__init__()
|
|
124
|
+
self.event_callback = callback
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class WriteCommand(ThreadCommand[None]):
|
|
128
|
+
"""Write data to the adapter"""
|
|
129
|
+
|
|
130
|
+
def __init__(self, data: bytes) -> None:
|
|
131
|
+
super().__init__()
|
|
132
|
+
self.data = data
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class SetStopConditionsCommand(ThreadCommand[None]):
|
|
136
|
+
"""Configure adapter stop conditions"""
|
|
137
|
+
|
|
138
|
+
def __init__(self, stop_conditions: list[StopCondition]) -> None:
|
|
139
|
+
super().__init__()
|
|
140
|
+
self.stop_conditions = stop_conditions
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class SetTimeoutCommand(ThreadCommand[None]):
|
|
144
|
+
"""Configure adapter timeout"""
|
|
145
|
+
|
|
146
|
+
def __init__(self, timeout: Timeout) -> None:
|
|
147
|
+
super().__init__()
|
|
148
|
+
self.timeout = timeout
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class IsOpenCommand(ThreadCommand[bool]):
|
|
152
|
+
"""Return True if the adapter is opened"""
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class ReadCommand(ThreadCommand[AdapterFrame]):
|
|
156
|
+
"""
|
|
157
|
+
Read a frame (detailed) from the adapter.
|
|
158
|
+
|
|
159
|
+
timeout:
|
|
160
|
+
- ... => use adapter default timeout
|
|
161
|
+
- None => wait indefinitely for first fragment (response timeout disabled)
|
|
162
|
+
- Timeout => as provided
|
|
163
|
+
|
|
164
|
+
stop_conditions:
|
|
165
|
+
- ... => use current worker stop conditions
|
|
166
|
+
- StopCondition/list => override for the *next* frame that satisfies this read
|
|
167
|
+
(applied at frame boundary; not mid-frame)
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
def __init__(
|
|
171
|
+
self,
|
|
172
|
+
timeout: Timeout | EllipsisType | None,
|
|
173
|
+
stop_conditions: StopCondition | EllipsisType | list[StopCondition],
|
|
174
|
+
scope: ReadScope,
|
|
175
|
+
) -> None:
|
|
176
|
+
super().__init__()
|
|
177
|
+
self.timeout = timeout
|
|
178
|
+
self.stop_conditions = stop_conditions
|
|
179
|
+
self.scope = scope
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class SetDescriptorCommand(ThreadCommand[None]):
|
|
183
|
+
"""
|
|
184
|
+
Command to configure the worker descriptor (sync with the adapter subclass descriptor)
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
def __init__(self, descriptor: Descriptor) -> None:
|
|
188
|
+
super().__init__()
|
|
189
|
+
self.descriptor = descriptor
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# pylint: disable=too-many-instance-attributes
|
|
193
|
+
class _PendingRead:
|
|
194
|
+
"""
|
|
195
|
+
Worker-thread state for one outstanding read.
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
__slots__ = (
|
|
199
|
+
"cmd",
|
|
200
|
+
"start_time",
|
|
201
|
+
"response_deadline",
|
|
202
|
+
"first_fragment_seen",
|
|
203
|
+
"scope",
|
|
204
|
+
"stop_override",
|
|
205
|
+
"stop_override_applied",
|
|
206
|
+
"prev_stop_conditions",
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def __init__(
|
|
210
|
+
self,
|
|
211
|
+
*,
|
|
212
|
+
cmd: ReadCommand,
|
|
213
|
+
start_time: float,
|
|
214
|
+
response_deadline: float | None,
|
|
215
|
+
scope: ReadScope,
|
|
216
|
+
stop_override: list[StopCondition] | None,
|
|
217
|
+
) -> None:
|
|
218
|
+
self.cmd = cmd
|
|
219
|
+
self.start_time = start_time
|
|
220
|
+
self.response_deadline = (
|
|
221
|
+
response_deadline # only used before qualifying first fragment
|
|
222
|
+
)
|
|
223
|
+
self.first_fragment_seen = False
|
|
224
|
+
self.scope = scope
|
|
225
|
+
|
|
226
|
+
self.stop_override = stop_override
|
|
227
|
+
self.stop_override_applied = False
|
|
228
|
+
self.prev_stop_conditions: list[StopCondition] | None = None
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# pylint: disable=too-many-instance-attributes
|
|
232
|
+
class AdapterWorker:
|
|
233
|
+
"""
|
|
234
|
+
Adapter worker
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
# How many completed frames we keep for BUFFERED reads
|
|
238
|
+
_FRAME_BUFFER_MAX = 256
|
|
239
|
+
_COMMAND_READY = b"\x00"
|
|
240
|
+
|
|
241
|
+
def __init__(self) -> None:
|
|
242
|
+
# Command queue (worker input)
|
|
243
|
+
self._command_queue_r, self._command_queue_w = socket.socketpair()
|
|
244
|
+
self._command_queue_r.setblocking(False)
|
|
245
|
+
self._command_queue_w.setblocking(False)
|
|
246
|
+
|
|
247
|
+
self._command_queue: queue.Queue[ThreadCommand[Any]] = queue.Queue()
|
|
248
|
+
|
|
249
|
+
self._frame_buffer: deque[AdapterFrame] = deque(maxlen=self._FRAME_BUFFER_MAX)
|
|
250
|
+
|
|
251
|
+
self._worker_descriptor: Descriptor | None = None
|
|
252
|
+
|
|
253
|
+
self._pending_read: _PendingRead | None = None
|
|
254
|
+
|
|
255
|
+
self._timeout: Timeout | None = None
|
|
256
|
+
|
|
257
|
+
self._worker_logger = logging.getLogger(LoggerAlias.ADAPTER_WORKER.value)
|
|
258
|
+
|
|
259
|
+
self._stop_conditions: list[StopCondition] = []
|
|
260
|
+
|
|
261
|
+
# Worker lifecycle and state
|
|
262
|
+
self._thread_running = True
|
|
263
|
+
self._opened = False
|
|
264
|
+
self._first_opened = False
|
|
265
|
+
|
|
266
|
+
# Fragment assembly state
|
|
267
|
+
self._first_fragment: bool = True
|
|
268
|
+
self.fragments: list[Fragment] = []
|
|
269
|
+
self._previous_buffer = Fragment(b"", time.time())
|
|
270
|
+
self._first_fragment_timestamp: float | None = None
|
|
271
|
+
self._last_fragment_timestamp: float | None = None
|
|
272
|
+
self._last_write_timestamp: float | None = None
|
|
273
|
+
self._timeout_origin: StopConditionType | None = None
|
|
274
|
+
self._next_stop_condition_timeout_timestamp: float | None = None
|
|
275
|
+
self._read_start_timestamp: float | None = None
|
|
276
|
+
|
|
277
|
+
self._event_callback: Callable[[AdapterEvent], None] | None = None
|
|
278
|
+
|
|
279
|
+
# ┌─────────────────┐
|
|
280
|
+
# │ Worker plumbing │
|
|
281
|
+
# └─────────────────┘
|
|
282
|
+
|
|
283
|
+
def _worker_send_command(self, command: ThreadCommand[Any]) -> None:
|
|
284
|
+
self._command_queue.put(command)
|
|
285
|
+
# Wake up worker
|
|
286
|
+
try:
|
|
287
|
+
self._command_queue_w.send(self._COMMAND_READY)
|
|
288
|
+
except OSError:
|
|
289
|
+
# Worker may already be stopped
|
|
290
|
+
pass
|
|
291
|
+
|
|
292
|
+
def _worker_drain_wakeup(self) -> None:
|
|
293
|
+
# Drain all pending wakeup bytes (non-blocking)
|
|
294
|
+
while True:
|
|
295
|
+
try:
|
|
296
|
+
_ = self._command_queue_r.recv(1024)
|
|
297
|
+
if not _:
|
|
298
|
+
return
|
|
299
|
+
except BlockingIOError:
|
|
300
|
+
return
|
|
301
|
+
except OSError:
|
|
302
|
+
return
|
|
303
|
+
|
|
304
|
+
def _worker_check_descriptor(self) -> None:
|
|
305
|
+
if (
|
|
306
|
+
self._worker_descriptor is None
|
|
307
|
+
or not self._worker_descriptor.is_initialized()
|
|
308
|
+
):
|
|
309
|
+
raise AdapterOpenError("Descriptor not initialized")
|
|
310
|
+
|
|
311
|
+
# Abstract worker methods, to be implemented in the adapter subclasses
|
|
312
|
+
@abstractmethod
|
|
313
|
+
def _selectable(self) -> HasFileno | None:
|
|
314
|
+
"""Return an object with fileno() that becomes readable when device data is available."""
|
|
315
|
+
|
|
316
|
+
@abstractmethod
|
|
317
|
+
def _worker_read(self, fragment_timestamp: float) -> Fragment:
|
|
318
|
+
"""Read one fragment from the low-level layer and return it."""
|
|
319
|
+
|
|
320
|
+
@abstractmethod
|
|
321
|
+
def _worker_write(self, data: bytes) -> None:
|
|
322
|
+
if not self._opened and not self._first_opened:
|
|
323
|
+
self._worker_open()
|
|
324
|
+
if not self._opened:
|
|
325
|
+
raise AdapterWriteError("Adapter not opened")
|
|
326
|
+
|
|
327
|
+
@abstractmethod
|
|
328
|
+
def _worker_open(self) -> None: ...
|
|
329
|
+
|
|
330
|
+
@abstractmethod
|
|
331
|
+
def _worker_close(self) -> None: ...
|
|
332
|
+
|
|
333
|
+
# ┌──────────────────────────┐
|
|
334
|
+
# │ Worker: command handling │
|
|
335
|
+
# └──────────────────────────┘
|
|
336
|
+
|
|
337
|
+
def _worker_manage_command(self, command: ThreadCommand[Any]) -> None:
|
|
338
|
+
# pylint: disable=too-many-branches
|
|
339
|
+
try:
|
|
340
|
+
match command:
|
|
341
|
+
case WriteCommand():
|
|
342
|
+
self._last_write_timestamp = time.time()
|
|
343
|
+
self._worker_write(command.data)
|
|
344
|
+
command.set_result(None)
|
|
345
|
+
case OpenCommand():
|
|
346
|
+
self._worker_open()
|
|
347
|
+
self._opened = True
|
|
348
|
+
self._first_opened = True
|
|
349
|
+
command.set_result(None)
|
|
350
|
+
case CloseCommand():
|
|
351
|
+
self._worker_close()
|
|
352
|
+
self._opened = False
|
|
353
|
+
# Closing should also reset read assembly
|
|
354
|
+
self._worker_reset_read()
|
|
355
|
+
self._frame_buffer.clear()
|
|
356
|
+
# Cancel any pending read
|
|
357
|
+
if self._pending_read is not None:
|
|
358
|
+
self._pending_read.cmd.set_exception(AdapterDisconnected())
|
|
359
|
+
self._pending_read = None
|
|
360
|
+
command.set_result(None)
|
|
361
|
+
case StopThreadCommand():
|
|
362
|
+
self._thread_running = False
|
|
363
|
+
command.set_result(None)
|
|
364
|
+
case FlushReadCommand():
|
|
365
|
+
self._frame_buffer.clear()
|
|
366
|
+
self._worker_reset_read()
|
|
367
|
+
command.set_result(None)
|
|
368
|
+
case SetStopConditionsCommand():
|
|
369
|
+
self._stop_conditions = command.stop_conditions
|
|
370
|
+
command.set_result(None)
|
|
371
|
+
case SetTimeoutCommand():
|
|
372
|
+
self._timeout = command.timeout
|
|
373
|
+
command.set_result(None)
|
|
374
|
+
case IsOpenCommand():
|
|
375
|
+
command.set_result(self._opened)
|
|
376
|
+
case SetEventCallbackCommand():
|
|
377
|
+
self._event_callback = command.event_callback
|
|
378
|
+
command.set_result(None)
|
|
379
|
+
case ReadCommand():
|
|
380
|
+
self._worker_begin_read(command)
|
|
381
|
+
case SetDescriptorCommand():
|
|
382
|
+
self._worker_descriptor = command.descriptor
|
|
383
|
+
command.set_result(None)
|
|
384
|
+
case _:
|
|
385
|
+
command.set_exception(
|
|
386
|
+
WorkerThreadError(f"Invalid command {command!r}")
|
|
387
|
+
)
|
|
388
|
+
except AdapterError as e:
|
|
389
|
+
command.set_exception(e)
|
|
390
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
391
|
+
command.set_exception(WorkerThreadError(str(e)))
|
|
392
|
+
|
|
393
|
+
def _worker_begin_read(self, cmd: ReadCommand) -> None:
|
|
394
|
+
"""
|
|
395
|
+
Register a pending read in the worker.
|
|
396
|
+
|
|
397
|
+
- If scope is BUFFERED and we have buffered frames, complete immediately.
|
|
398
|
+
- Otherwise store pending read and let the fragment/frame pipeline satisfy it.
|
|
399
|
+
"""
|
|
400
|
+
if self._pending_read is not None:
|
|
401
|
+
cmd.set_exception(
|
|
402
|
+
WorkerThreadError("Concurrent read_detailed is not supported")
|
|
403
|
+
)
|
|
404
|
+
return
|
|
405
|
+
|
|
406
|
+
# If buffered scope, serve immediately from buffer if available
|
|
407
|
+
if cmd.scope == ReadScope.BUFFERED and len(self._frame_buffer) > 0:
|
|
408
|
+
frame = self._frame_buffer.popleft()
|
|
409
|
+
cmd.set_result(frame)
|
|
410
|
+
return
|
|
411
|
+
|
|
412
|
+
start = time.time()
|
|
413
|
+
|
|
414
|
+
# Resolve timeout
|
|
415
|
+
if cmd.timeout is ...:
|
|
416
|
+
read_timeout = self._timeout
|
|
417
|
+
elif cmd.timeout is None:
|
|
418
|
+
read_timeout = Timeout(response=None)
|
|
419
|
+
elif isinstance(cmd.timeout, Timeout):
|
|
420
|
+
read_timeout = cmd.timeout
|
|
421
|
+
else:
|
|
422
|
+
read_timeout = any_to_timeout(cmd.timeout)
|
|
423
|
+
|
|
424
|
+
if read_timeout is None:
|
|
425
|
+
raise RuntimeError("Cannot read without setting a timeout")
|
|
426
|
+
if not read_timeout.is_initialized():
|
|
427
|
+
raise RuntimeError("Timeout needs to be initialized")
|
|
428
|
+
|
|
429
|
+
resp = read_timeout.response()
|
|
430
|
+
response_deadline = None if resp is None else (start + resp)
|
|
431
|
+
|
|
432
|
+
# Resolve stop-condition override (applied at next qualifying frame boundary)
|
|
433
|
+
stop_override: list[StopCondition] | None = None
|
|
434
|
+
if cmd.stop_conditions is not ...:
|
|
435
|
+
if isinstance(cmd.stop_conditions, StopCondition):
|
|
436
|
+
stop_override = [cmd.stop_conditions]
|
|
437
|
+
elif isinstance(cmd.stop_conditions, list):
|
|
438
|
+
stop_override = cmd.stop_conditions
|
|
439
|
+
else:
|
|
440
|
+
raise ValueError("Invalid stop_conditions override")
|
|
441
|
+
|
|
442
|
+
self._pending_read = _PendingRead(
|
|
443
|
+
cmd=cmd,
|
|
444
|
+
start_time=start,
|
|
445
|
+
response_deadline=response_deadline,
|
|
446
|
+
scope=cmd.scope,
|
|
447
|
+
stop_override=stop_override,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
# ┌────────────────────────┐
|
|
451
|
+
# │ Worker: event emission │
|
|
452
|
+
# └────────────────────────┘
|
|
453
|
+
|
|
454
|
+
def _worker_emit_event(self, event: AdapterEvent) -> None:
|
|
455
|
+
if self._event_callback is not None:
|
|
456
|
+
try:
|
|
457
|
+
self._event_callback(event)
|
|
458
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
459
|
+
# Never let user callback break worker
|
|
460
|
+
self._worker_logger.exception(
|
|
461
|
+
"Adapter event callback failed with error : %s", str(e)
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
def _worker_deliver_frame(self, frame: AdapterFrame) -> None:
|
|
465
|
+
"""
|
|
466
|
+
Route a completed frame:
|
|
467
|
+
- complete pending read if it matches scope/time rules
|
|
468
|
+
- else buffer it
|
|
469
|
+
- always emit callback event (if configured)
|
|
470
|
+
"""
|
|
471
|
+
self._worker_emit_event(AdapterFrameEvent(frame))
|
|
472
|
+
|
|
473
|
+
pr = self._pending_read
|
|
474
|
+
if pr is not None:
|
|
475
|
+
first_ts = frame.fragments[0].timestamp if frame.fragments else float("nan")
|
|
476
|
+
qualifies = (first_ts > pr.start_time) or (pr.scope == ReadScope.BUFFERED)
|
|
477
|
+
if qualifies:
|
|
478
|
+
# Restore stop conditions if we had applied an override
|
|
479
|
+
if pr.stop_override_applied and pr.prev_stop_conditions is not None:
|
|
480
|
+
self._stop_conditions = pr.prev_stop_conditions
|
|
481
|
+
|
|
482
|
+
pr.cmd.set_result(frame)
|
|
483
|
+
self._pending_read = None
|
|
484
|
+
return
|
|
485
|
+
|
|
486
|
+
# Not consumed by a pending read => buffer it
|
|
487
|
+
self._frame_buffer.append(frame)
|
|
488
|
+
|
|
489
|
+
def _worker_fail_pending_read_timeout(self) -> None:
|
|
490
|
+
"""
|
|
491
|
+
Called when the pending read response timeout expires BEFORE a qualifying first fragment.
|
|
492
|
+
"""
|
|
493
|
+
pr = self._pending_read
|
|
494
|
+
if pr is None:
|
|
495
|
+
return
|
|
496
|
+
|
|
497
|
+
# Resolve timeout again the same way as begin_read did
|
|
498
|
+
cmd = pr.cmd
|
|
499
|
+
if cmd.timeout is ...:
|
|
500
|
+
read_timeout = self._timeout
|
|
501
|
+
elif cmd.timeout is None:
|
|
502
|
+
read_timeout = Timeout(response=None)
|
|
503
|
+
elif isinstance(cmd.timeout, Timeout):
|
|
504
|
+
read_timeout = cmd.timeout
|
|
505
|
+
else:
|
|
506
|
+
read_timeout = any_to_timeout(cmd.timeout)
|
|
507
|
+
|
|
508
|
+
if read_timeout is None:
|
|
509
|
+
pr.cmd.set_exception(AdapterReadError("Read timeout configuration invalid"))
|
|
510
|
+
self._pending_read = None
|
|
511
|
+
return
|
|
512
|
+
|
|
513
|
+
match read_timeout.action:
|
|
514
|
+
case TimeoutAction.RETURN_EMPTY:
|
|
515
|
+
pr.cmd.set_result(
|
|
516
|
+
AdapterFrame(
|
|
517
|
+
fragments=[Fragment(b"", time.time())],
|
|
518
|
+
stop_timestamp=None,
|
|
519
|
+
stop_condition_type=None,
|
|
520
|
+
previous_read_buffer_used=False,
|
|
521
|
+
response_delay=None,
|
|
522
|
+
)
|
|
523
|
+
)
|
|
524
|
+
self._pending_read = None
|
|
525
|
+
case TimeoutAction.ERROR:
|
|
526
|
+
timeout_value = read_timeout.response()
|
|
527
|
+
pr.cmd.set_exception(
|
|
528
|
+
AdapterTimeoutError(
|
|
529
|
+
float("nan") if timeout_value is None else timeout_value
|
|
530
|
+
)
|
|
531
|
+
)
|
|
532
|
+
self._pending_read = None
|
|
533
|
+
case _:
|
|
534
|
+
pr.cmd.set_exception(NotImplementedError())
|
|
535
|
+
self._pending_read = None
|
|
536
|
+
|
|
537
|
+
# ┌──────────────────────────────┐
|
|
538
|
+
# │ Worker: fragment/frame logic │
|
|
539
|
+
# └──────────────────────────────┘
|
|
540
|
+
|
|
541
|
+
def _worker_on_first_fragment(self, fragment: Fragment) -> None:
|
|
542
|
+
"""
|
|
543
|
+
Called at frame boundary (first fragment of a new frame).
|
|
544
|
+
Used to:
|
|
545
|
+
- mark the pending read as having seen a qualifying first fragment
|
|
546
|
+
(disables response timeout)
|
|
547
|
+
- apply stop-condition overrides at frame boundary (not mid-frame)
|
|
548
|
+
"""
|
|
549
|
+
pr = self._pending_read
|
|
550
|
+
if pr is None:
|
|
551
|
+
return
|
|
552
|
+
|
|
553
|
+
qualifies = (fragment.timestamp > pr.start_time) or (
|
|
554
|
+
pr.scope == ReadScope.BUFFERED
|
|
555
|
+
)
|
|
556
|
+
if not qualifies:
|
|
557
|
+
return
|
|
558
|
+
|
|
559
|
+
pr.first_fragment_seen = True
|
|
560
|
+
pr.response_deadline = (
|
|
561
|
+
None # disable response timeout once we have a qualifying first fragment
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
if pr.stop_override is not None and not pr.stop_override_applied:
|
|
565
|
+
pr.prev_stop_conditions = self._stop_conditions
|
|
566
|
+
self._stop_conditions = pr.stop_override
|
|
567
|
+
pr.stop_override_applied = True
|
|
568
|
+
|
|
569
|
+
def _worker_manage_fragment(self, fragment: Fragment) -> None:
|
|
570
|
+
# pylint: disable=too-many-branches, too-many-statements
|
|
571
|
+
self._last_fragment_timestamp = fragment.timestamp
|
|
572
|
+
|
|
573
|
+
if self._last_write_timestamp is not None:
|
|
574
|
+
write_delta = fragment.timestamp - self._last_write_timestamp
|
|
575
|
+
initiate_timestamp = fragment.timestamp
|
|
576
|
+
else:
|
|
577
|
+
write_delta = float("nan")
|
|
578
|
+
initiate_timestamp = time.time()
|
|
579
|
+
|
|
580
|
+
if fragment.data == b"":
|
|
581
|
+
# Disconnected / EOF
|
|
582
|
+
try:
|
|
583
|
+
self._worker_close()
|
|
584
|
+
except AdapterError:
|
|
585
|
+
pass
|
|
586
|
+
self._opened = False
|
|
587
|
+
self._worker_emit_event(AdapterDisconnectedEvent())
|
|
588
|
+
|
|
589
|
+
# Fail any pending read
|
|
590
|
+
if self._pending_read is not None:
|
|
591
|
+
self._pending_read.cmd.set_exception(AdapterDisconnected())
|
|
592
|
+
# Restore stop conditions if overridden
|
|
593
|
+
if (
|
|
594
|
+
self._pending_read.stop_override_applied
|
|
595
|
+
and self._pending_read.prev_stop_conditions is not None
|
|
596
|
+
):
|
|
597
|
+
self._stop_conditions = self._pending_read.prev_stop_conditions
|
|
598
|
+
self._pending_read = None
|
|
599
|
+
return
|
|
600
|
+
|
|
601
|
+
suffix = " (first)" if self._first_fragment else ""
|
|
602
|
+
self._worker_logger.debug(
|
|
603
|
+
"New fragment %+.3f %s%s", write_delta, fragment, suffix
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
stop_timestamp = float("nan")
|
|
607
|
+
kept = fragment
|
|
608
|
+
|
|
609
|
+
while True:
|
|
610
|
+
if self._first_fragment:
|
|
611
|
+
self._first_fragment = False
|
|
612
|
+
self._read_start_timestamp = fragment.timestamp
|
|
613
|
+
self._first_fragment_timestamp = fragment.timestamp
|
|
614
|
+
|
|
615
|
+
# Notify pending read (and apply stop override at boundary)
|
|
616
|
+
self._worker_on_first_fragment(fragment)
|
|
617
|
+
|
|
618
|
+
for stop_condition in self._stop_conditions:
|
|
619
|
+
stop_condition.initiate_read(initiate_timestamp)
|
|
620
|
+
|
|
621
|
+
stop = False
|
|
622
|
+
stop_condition_type: StopConditionType | None = None
|
|
623
|
+
|
|
624
|
+
for stop_condition in self._stop_conditions:
|
|
625
|
+
(
|
|
626
|
+
stop,
|
|
627
|
+
kept,
|
|
628
|
+
self._previous_buffer,
|
|
629
|
+
next_stop_condition_timeout_timestamp,
|
|
630
|
+
) = stop_condition.evaluate(kept)
|
|
631
|
+
|
|
632
|
+
self._next_stop_condition_timeout_timestamp = nmin(
|
|
633
|
+
next_stop_condition_timeout_timestamp,
|
|
634
|
+
self._next_stop_condition_timeout_timestamp,
|
|
635
|
+
)
|
|
636
|
+
if stop:
|
|
637
|
+
stop_condition_type = stop_condition.type()
|
|
638
|
+
stop_timestamp = kept.timestamp
|
|
639
|
+
break
|
|
640
|
+
|
|
641
|
+
self.fragments.append(kept)
|
|
642
|
+
|
|
643
|
+
if stop_condition_type is None:
|
|
644
|
+
break
|
|
645
|
+
|
|
646
|
+
# frame complete
|
|
647
|
+
self._first_fragment = True
|
|
648
|
+
|
|
649
|
+
if self._last_write_timestamp is None:
|
|
650
|
+
response_delay = None
|
|
651
|
+
else:
|
|
652
|
+
response_delay = (
|
|
653
|
+
self.fragments[0].timestamp - self._last_write_timestamp
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
frame = AdapterFrame(
|
|
657
|
+
fragments=self.fragments,
|
|
658
|
+
stop_timestamp=stop_timestamp,
|
|
659
|
+
stop_condition_type=stop_condition_type,
|
|
660
|
+
previous_read_buffer_used=False,
|
|
661
|
+
response_delay=response_delay,
|
|
662
|
+
)
|
|
663
|
+
self._worker_logger.debug(
|
|
664
|
+
"Frame %s (%s)",
|
|
665
|
+
"+".join(repr(f.data) for f in self.fragments),
|
|
666
|
+
stop_condition_type.value if stop_condition_type is not None else "---",
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
self._worker_deliver_frame(frame)
|
|
670
|
+
|
|
671
|
+
# Reset for next frame
|
|
672
|
+
self._next_stop_condition_timeout_timestamp = None
|
|
673
|
+
self.fragments = []
|
|
674
|
+
|
|
675
|
+
if len(self._previous_buffer.data) > 0:
|
|
676
|
+
kept = self._previous_buffer
|
|
677
|
+
else:
|
|
678
|
+
break
|
|
679
|
+
|
|
680
|
+
def _worker_on_stop_condition_timeout(self, timestamp: float) -> None:
|
|
681
|
+
"""
|
|
682
|
+
Called when a stop-condition timeout expires (Continuation/Total),
|
|
683
|
+
producing a frame if we have accumulated fragments.
|
|
684
|
+
"""
|
|
685
|
+
if len(self.fragments) > 0:
|
|
686
|
+
if self._last_write_timestamp is None:
|
|
687
|
+
response_delay = None
|
|
688
|
+
else:
|
|
689
|
+
response_delay = (
|
|
690
|
+
self.fragments[0].timestamp - self._last_write_timestamp
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
frame = AdapterFrame(
|
|
694
|
+
fragments=self.fragments,
|
|
695
|
+
stop_timestamp=timestamp,
|
|
696
|
+
stop_condition_type=self._timeout_origin,
|
|
697
|
+
previous_read_buffer_used=False,
|
|
698
|
+
response_delay=response_delay,
|
|
699
|
+
)
|
|
700
|
+
self._worker_deliver_frame(frame)
|
|
701
|
+
|
|
702
|
+
self._worker_reset_read()
|
|
703
|
+
|
|
704
|
+
def _worker_reset_read(self) -> None:
|
|
705
|
+
self._last_fragment_timestamp = None
|
|
706
|
+
self._first_fragment_timestamp = None
|
|
707
|
+
self._first_fragment = True
|
|
708
|
+
self._last_write_timestamp = None
|
|
709
|
+
self.fragments = []
|
|
710
|
+
self._next_stop_condition_timeout_timestamp = None
|
|
711
|
+
self._timeout_origin = None
|
|
712
|
+
|
|
713
|
+
def _worker_next_timeout_timestamp(self) -> float | None:
|
|
714
|
+
stop_conditions = self._stop_conditions
|
|
715
|
+
next_timestamp = None
|
|
716
|
+
|
|
717
|
+
for stop_condition in stop_conditions:
|
|
718
|
+
if isinstance(stop_condition, Continuation):
|
|
719
|
+
if self._last_fragment_timestamp is not None:
|
|
720
|
+
next_timestamp = nmin(
|
|
721
|
+
next_timestamp,
|
|
722
|
+
self._last_fragment_timestamp + stop_condition.continuation,
|
|
723
|
+
)
|
|
724
|
+
self._timeout_origin = stop_condition.type()
|
|
725
|
+
elif isinstance(stop_condition, Total):
|
|
726
|
+
if self._first_fragment_timestamp is not None:
|
|
727
|
+
next_timestamp = nmin(
|
|
728
|
+
next_timestamp,
|
|
729
|
+
self._first_fragment_timestamp + stop_condition.total,
|
|
730
|
+
)
|
|
731
|
+
self._timeout_origin = stop_condition.type()
|
|
732
|
+
|
|
733
|
+
return next_timestamp
|
|
734
|
+
|
|
735
|
+
# ┌───────────────────┐
|
|
736
|
+
# │ Worker: main loop │
|
|
737
|
+
# └───────────────────┘
|
|
738
|
+
|
|
739
|
+
# pylint: disable=too-many-branches
|
|
740
|
+
def _worker_thread_method(self) -> None:
|
|
741
|
+
"""
|
|
742
|
+
Main worker thread loop (select-based reactor)
|
|
743
|
+
|
|
744
|
+
- Always waits on:
|
|
745
|
+
* command wakeup socket
|
|
746
|
+
* device selectable (if any)
|
|
747
|
+
- Also wakes up on the earliest deadline among:
|
|
748
|
+
* stop-condition timeout (Continuation/Total)
|
|
749
|
+
* pending read response deadline (before first qualifying fragment)
|
|
750
|
+
"""
|
|
751
|
+
while self._thread_running:
|
|
752
|
+
now = time.time()
|
|
753
|
+
|
|
754
|
+
# Refresh next stop-condition timeout from current fragment state
|
|
755
|
+
self._next_stop_condition_timeout_timestamp = (
|
|
756
|
+
self._worker_next_timeout_timestamp()
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
# Compute pending read response deadline (only before first qualifying fragment)
|
|
760
|
+
pr_deadline = None
|
|
761
|
+
if (
|
|
762
|
+
self._pending_read is not None
|
|
763
|
+
and not self._pending_read.first_fragment_seen
|
|
764
|
+
):
|
|
765
|
+
pr_deadline = self._pending_read.response_deadline
|
|
766
|
+
|
|
767
|
+
# Earliest deadline wins
|
|
768
|
+
deadline = nmin(self._next_stop_condition_timeout_timestamp, pr_deadline)
|
|
769
|
+
if deadline is None:
|
|
770
|
+
select_timeout = None
|
|
771
|
+
else:
|
|
772
|
+
select_timeout = max(0.0, deadline - now)
|
|
773
|
+
|
|
774
|
+
# Selectables
|
|
775
|
+
selectables: list[HasFileno] = [self._command_queue_r]
|
|
776
|
+
s = self._selectable()
|
|
777
|
+
if s is not None:
|
|
778
|
+
selectables.append(s)
|
|
779
|
+
|
|
780
|
+
readable, _, _ = select(selectables, [], [], select_timeout)
|
|
781
|
+
t = time.time()
|
|
782
|
+
|
|
783
|
+
if self._command_queue_r in readable:
|
|
784
|
+
self._worker_drain_wakeup()
|
|
785
|
+
# Drain all commands currently queued
|
|
786
|
+
while True:
|
|
787
|
+
try:
|
|
788
|
+
cmd = self._command_queue.get(block=False)
|
|
789
|
+
except queue.Empty:
|
|
790
|
+
break
|
|
791
|
+
self._worker_manage_command(cmd)
|
|
792
|
+
continue
|
|
793
|
+
|
|
794
|
+
if s is not None and s in readable:
|
|
795
|
+
if not self._opened and not self._first_opened:
|
|
796
|
+
self._worker_open()
|
|
797
|
+
if not self._opened:
|
|
798
|
+
raise AdapterReadError("Adapter not opened")
|
|
799
|
+
|
|
800
|
+
frag = self._worker_read(t)
|
|
801
|
+
self._worker_manage_fragment(frag)
|
|
802
|
+
continue
|
|
803
|
+
|
|
804
|
+
# Timeout wakeup: decide what timed out
|
|
805
|
+
# 1) pending read response timeout (before qualifying first fragment)
|
|
806
|
+
if (
|
|
807
|
+
self._pending_read is not None
|
|
808
|
+
and not self._pending_read.first_fragment_seen
|
|
809
|
+
):
|
|
810
|
+
dl = self._pending_read.response_deadline
|
|
811
|
+
if dl is not None and t >= dl:
|
|
812
|
+
self._worker_fail_pending_read_timeout()
|
|
813
|
+
# do NOT return; stop-condition timeout might also be due
|
|
814
|
+
|
|
815
|
+
# 2) stop-condition timeout (Continuation/Total)
|
|
816
|
+
if (
|
|
817
|
+
self._next_stop_condition_timeout_timestamp is not None
|
|
818
|
+
and t >= self._next_stop_condition_timeout_timestamp
|
|
819
|
+
):
|
|
820
|
+
self._worker_on_stop_condition_timeout(t)
|