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.
Files changed (57) hide show
  1. syndesi/__init__.py +22 -2
  2. syndesi/adapters/adapter.py +332 -489
  3. syndesi/adapters/adapter_worker.py +820 -0
  4. syndesi/adapters/auto.py +58 -25
  5. syndesi/adapters/descriptors.py +38 -0
  6. syndesi/adapters/ip.py +203 -71
  7. syndesi/adapters/serialport.py +154 -25
  8. syndesi/adapters/stop_conditions.py +354 -0
  9. syndesi/adapters/timeout.py +58 -21
  10. syndesi/adapters/visa.py +236 -11
  11. syndesi/cli/console.py +51 -16
  12. syndesi/cli/shell.py +95 -47
  13. syndesi/cli/terminal_tools.py +8 -8
  14. syndesi/component.py +315 -0
  15. syndesi/protocols/delimited.py +92 -107
  16. syndesi/protocols/modbus.py +2368 -868
  17. syndesi/protocols/protocol.py +186 -33
  18. syndesi/protocols/raw.py +45 -62
  19. syndesi/protocols/scpi.py +65 -102
  20. syndesi/remote/remote.py +188 -0
  21. syndesi/scripts/syndesi.py +12 -2
  22. syndesi/tools/errors.py +49 -31
  23. syndesi/tools/log_settings.py +21 -8
  24. syndesi/tools/{log.py → logmanager.py} +24 -13
  25. syndesi/tools/types.py +9 -7
  26. syndesi/version.py +5 -1
  27. {syndesi-0.4.2.dist-info → syndesi-0.5.0.dist-info}/METADATA +1 -1
  28. syndesi-0.5.0.dist-info/RECORD +41 -0
  29. syndesi/adapters/backend/__init__.py +0 -0
  30. syndesi/adapters/backend/adapter_backend.py +0 -438
  31. syndesi/adapters/backend/adapter_manager.py +0 -48
  32. syndesi/adapters/backend/adapter_session.py +0 -346
  33. syndesi/adapters/backend/backend.py +0 -438
  34. syndesi/adapters/backend/backend_status.py +0 -0
  35. syndesi/adapters/backend/backend_tools.py +0 -66
  36. syndesi/adapters/backend/descriptors.py +0 -153
  37. syndesi/adapters/backend/ip_backend.py +0 -149
  38. syndesi/adapters/backend/serialport_backend.py +0 -241
  39. syndesi/adapters/backend/stop_condition_backend.py +0 -219
  40. syndesi/adapters/backend/timed_queue.py +0 -39
  41. syndesi/adapters/backend/timeout.py +0 -252
  42. syndesi/adapters/backend/visa_backend.py +0 -197
  43. syndesi/adapters/ip_server.py +0 -102
  44. syndesi/adapters/stop_condition.py +0 -90
  45. syndesi/cli/backend_console.py +0 -96
  46. syndesi/cli/backend_status.py +0 -274
  47. syndesi/cli/backend_wrapper.py +0 -61
  48. syndesi/scripts/syndesi_backend.py +0 -37
  49. syndesi/tools/backend_api.py +0 -175
  50. syndesi/tools/backend_logger.py +0 -64
  51. syndesi/tools/exceptions.py +0 -16
  52. syndesi/tools/internal.py +0 -0
  53. syndesi-0.4.2.dist-info/RECORD +0 -60
  54. {syndesi-0.4.2.dist-info → syndesi-0.5.0.dist-info}/WHEEL +0 -0
  55. {syndesi-0.4.2.dist-info → syndesi-0.5.0.dist-info}/entry_points.txt +0 -0
  56. {syndesi-0.4.2.dist-info → syndesi-0.5.0.dist-info}/licenses/LICENSE +0 -0
  57. {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)