syndesi 0.3.1__py3-none-any.whl → 0.4.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 (52) hide show
  1. syndesi/__init__.py +9 -2
  2. syndesi/adapters/__init__.py +0 -4
  3. syndesi/adapters/adapter.py +408 -242
  4. syndesi/adapters/auto.py +4 -3
  5. syndesi/adapters/backend/adapter_backend.py +151 -94
  6. syndesi/adapters/backend/adapter_manager.py +0 -1
  7. syndesi/adapters/backend/adapter_session.py +113 -129
  8. syndesi/adapters/backend/backend.py +160 -91
  9. syndesi/adapters/backend/backend_tools.py +30 -19
  10. syndesi/adapters/backend/descriptors.py +20 -8
  11. syndesi/adapters/backend/ip_backend.py +24 -13
  12. syndesi/adapters/backend/serialport_backend.py +44 -34
  13. syndesi/adapters/backend/stop_condition_backend.py +98 -219
  14. syndesi/adapters/backend/visa_backend.py +65 -47
  15. syndesi/adapters/ip.py +36 -16
  16. syndesi/adapters/serialport.py +18 -12
  17. syndesi/adapters/stop_condition.py +103 -42
  18. syndesi/adapters/timeout.py +55 -36
  19. syndesi/adapters/visa.py +19 -13
  20. syndesi/cli/backend_console.py +26 -18
  21. syndesi/cli/backend_status.py +137 -73
  22. syndesi/cli/backend_wrapper.py +18 -7
  23. syndesi/cli/console.py +281 -0
  24. syndesi/cli/shell.py +234 -258
  25. syndesi/cli/shell_tools.py +105 -0
  26. syndesi/cli/terminal_apps.py +0 -0
  27. syndesi/cli/terminal_tools.py +14 -0
  28. syndesi/protocols/__init__.py +0 -10
  29. syndesi/protocols/delimited.py +74 -47
  30. syndesi/protocols/modbus.py +132 -95
  31. syndesi/protocols/protocol.py +31 -22
  32. syndesi/protocols/raw.py +43 -21
  33. syndesi/protocols/scpi.py +42 -84
  34. syndesi/scripts/syndesi.py +9 -9
  35. syndesi/scripts/syndesi_backend.py +8 -14
  36. syndesi/tools/backend_api.py +74 -56
  37. syndesi/tools/backend_logger.py +19 -7
  38. syndesi/tools/errors.py +34 -1
  39. syndesi/tools/log.py +115 -31
  40. syndesi/tools/log_settings.py +1 -0
  41. syndesi/tools/types.py +14 -22
  42. syndesi/version.py +1 -1
  43. {syndesi-0.3.1.dist-info → syndesi-0.4.0.dist-info}/METADATA +35 -7
  44. syndesi-0.4.0.dist-info/RECORD +59 -0
  45. syndesi-0.4.0.dist-info/entry_points.txt +3 -0
  46. syndesi/cli/adapter_shell.py +0 -210
  47. syndesi-0.3.1.dist-info/RECORD +0 -56
  48. syndesi-0.3.1.dist-info/entry_points.txt +0 -3
  49. /syndesi/{adapters/backend/backend_status.py → cli/terminal.py} +0 -0
  50. {syndesi-0.3.1.dist-info → syndesi-0.4.0.dist-info}/WHEEL +0 -0
  51. {syndesi-0.3.1.dist-info → syndesi-0.4.0.dist-info}/licenses/LICENSE +0 -0
  52. {syndesi-0.3.1.dist-info → syndesi-0.4.0.dist-info}/top_level.txt +0 -0
@@ -15,63 +15,86 @@
15
15
  # An adapter is meant to work with bytes objects but it can accept strings.
16
16
  # Strings will automatically be converted to bytes using utf-8 encoding
17
17
 
18
+ from enum import Enum
18
19
  import logging
19
20
  import queue
20
21
  import subprocess
21
22
  import sys
22
23
  import threading
23
24
  import time
24
- from typing import Any, Callable, Literal, Optional, Tuple, Union, overload
25
- import uuid
26
25
  import weakref
27
26
  from abc import ABC, abstractmethod
28
- from multiprocessing.connection import Client
29
-
30
- from syndesi.tools.types import DEFAULT, DefaultType, NumberLike, is_number
31
-
32
- from ..tools.backend_api import BACKEND_PORT, Action, BackendResponse, _default_host, is_event
27
+ from collections.abc import Callable
28
+ from multiprocessing.connection import Client, Connection
29
+ from types import EllipsisType
30
+ from typing import Any
31
+ import os
32
+
33
+ from .backend.backend_tools import BACKEND_REQUEST_DEFAULT_TIMEOUT
34
+ from syndesi.tools.types import NumberLike, is_number
35
+
36
+ from ..tools.backend_api import (
37
+ BACKEND_PORT,
38
+ Action,
39
+ BackendResponse,
40
+ default_host,
41
+ raise_if_error,
42
+ EXTRA_BUFFER_RESPONSE_TIME
43
+ )
33
44
  from ..tools.log_settings import LoggerAlias
34
- from .backend.adapter_backend import AdapterDisconnected, AdapterReadInit, AdapterReadPayload, AdapterSignal
35
- from .stop_condition import StopCondition, TimeoutStopCondition
45
+ from .backend.adapter_backend import (
46
+ AdapterDisconnected,
47
+ AdapterResponseTimeout,
48
+ AdapterReadPayload,
49
+ AdapterSignal,
50
+ )
51
+ from .backend.descriptors import Descriptor
52
+ from .stop_condition import StopCondition, Continuation, Total
36
53
  from .timeout import Timeout, TimeoutAction, any_to_timeout
37
54
 
38
- DEFAULT_STOP_CONDITION = TimeoutStopCondition(continuation=0.1)
55
+ DEFAULT_STOP_CONDITION = Continuation(time=0.1)
39
56
 
40
- DEFAULT_TIMEOUT = 5
41
-
42
- SHUTDOWN_DELAY = 2
57
+ DEFAULT_TIMEOUT = Timeout(response=5, action='error')
43
58
 
44
59
  # Maximum time to let the backend start
45
60
  START_TIMEOUT = 2
61
+ # Time to shutdown the backend
62
+ SHUTDOWN_DELAY = 2
46
63
 
47
- #from enum import Enum, auto
64
+ class SignalQueue(queue.Queue[AdapterSignal]):
65
+ def __init__(self) -> None:
66
+ self._read_payload_counter = 0
67
+ super().__init__(0)
48
68
 
49
- from ..tools.backend_api import raise_if_error
50
- from .backend.descriptors import Descriptor
69
+ def has_read_payload(self) -> bool:
70
+ return self._read_payload_counter > 0
51
71
 
52
72
 
53
- # class CallbackEvent(Enum):
54
- # DATA_READY = auto()
55
- # ADAPTER_DISCONNECTED = auto()
73
+ def put(self, signal: AdapterSignal, block: bool = True, timeout: float | None = None) -> None:
74
+ if isinstance(signal, AdapterReadPayload):
75
+ self._read_payload_counter += 1
76
+ return super().put(signal, block, timeout)
77
+
78
+
79
+ def get(self, block: bool = True, timeout: float | None = None) -> AdapterSignal:
80
+ signal = super().get(block, timeout)
81
+ if isinstance(signal, AdapterReadPayload):
82
+ self._read_payload_counter -= 1
83
+ return signal
56
84
 
57
85
 
58
- def is_backend_running(address : Optional[str] = None, port : Optional[int] = None) -> bool:
86
+ def is_backend_running(address: str, port: int) -> bool:
59
87
 
60
88
  try:
61
- conn = Client((
62
- _default_host if address is None else address,
63
- BACKEND_PORT if port is None else port
64
- ))
89
+ conn = Client((address, port))
65
90
  except ConnectionRefusedError:
66
91
  return False
67
92
  else:
68
93
  conn.close()
69
94
  return True
70
95
 
71
-
72
- def start_backend(port : Optional[int] = None) -> None:
73
- subprocess.Popen(
74
- [
96
+ def start_backend(port: int | None = None) -> None:
97
+ arguments = [
75
98
  sys.executable,
76
99
  "-m",
77
100
  "syndesi.adapters.backend.backend",
@@ -79,26 +102,57 @@ def start_backend(port : Optional[int] = None) -> None:
79
102
  str(SHUTDOWN_DELAY),
80
103
  "-q",
81
104
  "-p",
82
- str(BACKEND_PORT if port is None else port)
105
+ str(BACKEND_PORT if port is None else port),
83
106
  ]
84
- )
107
+
108
+ stdin = subprocess.DEVNULL
109
+ stdout = subprocess.DEVNULL
110
+ stderr = subprocess.DEVNULL
111
+
112
+ if os.name == "posix":
113
+ subprocess.Popen(
114
+ arguments,
115
+ stdin=stdin,
116
+ stdout=stdout,
117
+ stderr=stderr,
118
+ start_new_session=True,
119
+ close_fds=True,
120
+ )
121
+
122
+ else:
123
+ # Windows: detach from the parent's console so keyboard Ctrl+C won't propagate.
124
+ CREATE_NEW_PROCESS_GROUP = subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore
125
+ DETACHED_PROCESS = 0x00000008 # not exposed by subprocess on all Pythons
126
+ # Optional: CREATE_NO_WINDOW (no window even for console apps)
127
+ CREATE_NO_WINDOW = 0x08000000
128
+
129
+ creationflags = CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS | CREATE_NO_WINDOW
130
+
131
+ subprocess.Popen(
132
+ arguments,
133
+ stdin=stdin,
134
+ stdout=stdout,
135
+ stderr=stderr,
136
+ creationflags=creationflags,
137
+ close_fds=True,
138
+ )
85
139
 
140
+ class ReadScope(Enum):
141
+ NEXT = 'next'
142
+ BUFFERED = 'buffered'
86
143
 
87
144
  class Adapter(ABC):
88
- ADDITIONNAL_RESPONSE_DELAY = 1
89
- BACKEND_REQUEST_DEFAULT_TIMEOUT = 1
90
-
91
145
  def __init__(
92
146
  self,
93
147
  descriptor: Descriptor,
94
148
  alias: str = "",
95
- stop_condition: Optional[Union[StopCondition, DefaultType]] = DEFAULT,
96
- timeout: Optional[Union[Timeout, DefaultType, NumberLike]] = DEFAULT,
149
+ stop_conditions: StopCondition | EllipsisType | list[StopCondition] = ...,
150
+ timeout: Timeout | EllipsisType | NumberLike | None = ...,
97
151
  encoding: str = "utf-8",
98
- event_callback : Optional[Callable[[AdapterSignal], None]] = None,
99
- auto_open : bool = True,
100
- backend_address : Optional[str] = None,
101
- backend_port : Optional[int] = None
152
+ event_callback: Callable[[AdapterSignal], None] | None = None,
153
+ auto_open: bool = True,
154
+ backend_address: str | None = None,
155
+ backend_port: int | None = None,
102
156
  ) -> None:
103
157
  """
104
158
  Adapter instance
@@ -118,61 +172,57 @@ class Adapter(ABC):
118
172
  super().__init__()
119
173
  self._logger = logging.getLogger(LoggerAlias.ADAPTER.value)
120
174
  self.encoding = encoding
121
- self._event_queue : queue.Queue[BackendResponse] = queue.Queue()
122
- self.event_callback : Optional[Callable[[AdapterSignal], None]] = event_callback
175
+ self._signal_queue: SignalQueue = SignalQueue()
176
+ self.event_callback: Callable[[AdapterSignal], None] | None = event_callback
177
+ self.backend_connection: Connection | None = None
123
178
  self._backend_connection_lock = threading.Lock()
124
- self._make_backend_request_queue : queue.Queue[BackendResponse] = queue.Queue()
179
+ self._make_backend_request_queue: queue.Queue[BackendResponse] = queue.Queue()
125
180
  self._make_backend_request_flag = threading.Event()
126
181
  self.opened = False
182
+ self._alias = alias
127
183
 
128
- if is_backend_running(backend_address, backend_port):
129
- self._logger.info("Backend already running")
130
- elif backend_address is not None:
131
- raise RuntimeError(f"Cannot connect to backend {backend_address}")
184
+ if backend_address is None:
185
+ self._backend_address = default_host
132
186
  else:
133
- self._logger.info("Starting backend...")
134
- start_backend(backend_port)
135
- start = time.time()
136
- while time.time() < (start + START_TIMEOUT):
137
- if is_backend_running():
138
- self._logger.info("Backend started")
139
- break
140
- time.sleep(0.1)
141
- else:
142
- # Backend could not start
143
- self._logger.error("Could not start backend")
187
+ self._backend_address = backend_address
188
+ if backend_port is None:
189
+ self._backend_port = BACKEND_PORT
190
+ else:
191
+ self._backend_port = backend_port
192
+
193
+ # There a two possibilities here
194
+ # A) The descriptor is fully initialized
195
+ # -> The adapter can be connected directly
196
+ # B) The descriptor is not fully initialized
197
+ # -> Wait for the protocol to set defaults and then connect the adapter
144
198
 
145
199
  assert isinstance(
146
200
  descriptor, Descriptor
147
201
  ), "descriptor must be a Descriptor class"
148
202
  self.descriptor = descriptor
203
+ self.auto_open = auto_open
149
204
 
150
- # Open the connection with the backend
151
- try:
152
- self.backend_connection = Client((_default_host, BACKEND_PORT))
153
- except ConnectionRefusedError:
154
- raise RuntimeError("Failed to connect to backend")
155
- self._read_thread = threading.Thread(
156
- target=self.read_thread,
157
- args=(self._event_queue, self._make_backend_request_queue),
158
- daemon=True,
159
- )
160
- self._read_thread.start()
161
-
162
- # Identify ourselves
163
- self._make_backend_request(Action.SET_ROLE_ADAPTER)
164
-
165
- # Set the adapter
166
- self._make_backend_request(Action.SELECT_ADAPTER, str(self.descriptor))
167
-
168
- self._alias = alias
205
+ # Set the stop-condition
206
+ self._stop_conditions: list[StopCondition]
207
+ if stop_conditions is ...:
208
+ self._default_stop_condition = True
209
+ self._stop_conditions = [DEFAULT_STOP_CONDITION]
210
+ else:
211
+ self._default_stop_condition = False
212
+ if isinstance(stop_conditions, StopCondition):
213
+ self._stop_conditions = [stop_conditions]
214
+ elif isinstance(stop_conditions, list):
215
+ self._stop_conditions = stop_conditions
216
+ else:
217
+ raise ValueError('Invalid stop_conditions')
169
218
 
170
219
  # Set the timeout
171
220
  self.is_default_timeout = False
172
- self._timeout : Optional[Timeout]
221
+ self._timeout: Timeout | None
173
222
  if timeout is Ellipsis:
174
223
  # Not set
175
224
  self.is_default_timeout = True
225
+ self._timeout = DEFAULT_TIMEOUT
176
226
  elif isinstance(timeout, Timeout):
177
227
  self._timeout = timeout
178
228
  elif is_number(timeout):
@@ -180,41 +230,82 @@ class Adapter(ABC):
180
230
  elif timeout is None:
181
231
  self._timeout = timeout
182
232
 
183
-
184
- # Set the stop-condition
185
- self._stop_condition : Optional[StopCondition]
186
- if stop_condition is DEFAULT:
187
- self._default_stop_condition = True
188
- self._stop_condition = DEFAULT_STOP_CONDITION
189
- else:
190
- self._default_stop_condition = False
191
- self._stop_condition = stop_condition
192
-
193
233
  # Buffer for data that has been pulled from the queue but
194
234
  # not used because of termination or length stop condition
195
235
  self._previous_buffer = b""
196
236
 
237
+ if self.descriptor.is_initialized():
238
+ self.connect()
239
+
197
240
  weakref.finalize(self, self._cleanup)
198
241
  self._init_ok = True
199
- self.auto_open = auto_open
200
- if auto_open:
242
+
243
+ # We can auto-open only if auto_open is enabled and if
244
+ # connection with the backend has been made (descriptor initialized)
245
+ if self.auto_open and self.backend_connection is not None:
201
246
  self.open()
202
247
 
203
- def _make_backend_request(self, action: Action, *args : Any) -> BackendResponse:
248
+ def connect(self) -> None:
249
+ if self.backend_connection is not None:
250
+ # No need to connect, everything has been done already
251
+ return
252
+ if not self.descriptor.is_initialized():
253
+ raise RuntimeError("Descriptor wasn't initialized fully")
254
+
255
+ if is_backend_running(self._backend_address, self._backend_port):
256
+ self._logger.info("Backend already running")
257
+ else:
258
+ self._logger.info("Starting backend...")
259
+ start_backend(self._backend_port)
260
+ start = time.time()
261
+ while time.time() < (start + START_TIMEOUT):
262
+ if is_backend_running(self._backend_address, self._backend_port):
263
+ self._logger.info("Backend started")
264
+ break
265
+ time.sleep(0.1)
266
+ else:
267
+ # Backend could not start
268
+ self._logger.error("Could not start backend")
269
+
270
+ # Create the client to communicate with the backend
271
+ try:
272
+ self.backend_connection = Client((default_host, BACKEND_PORT))
273
+ except ConnectionRefusedError as err:
274
+ raise RuntimeError("Failed to connect to backend") from err
275
+ self._read_thread = threading.Thread(
276
+ target=self.read_thread,
277
+ args=(self._signal_queue, self._make_backend_request_queue),
278
+ daemon=True,
279
+ )
280
+ self._read_thread.start()
281
+
282
+ # Identify ourselves
283
+ self._make_backend_request(Action.SET_ROLE_ADAPTER)
284
+
285
+ # Set the adapter
286
+ self._make_backend_request(Action.SELECT_ADAPTER, str(self.descriptor))
287
+
288
+ if self.auto_open:
289
+ self.open()
290
+
291
+ def _make_backend_request(self, action: Action, *args: Any) -> BackendResponse:
204
292
  """
205
293
  Send a request to the backend and return the arguments
206
294
  """
207
295
 
208
296
  with self._backend_connection_lock:
209
- self.backend_connection.send((action.value, *args))
297
+ if self.backend_connection is not None:
298
+ self.backend_connection.send((action.value, *args))
210
299
 
211
300
  self._make_backend_request_flag.set()
212
301
  try:
213
302
  response = self._make_backend_request_queue.get(
214
- timeout=self.BACKEND_REQUEST_DEFAULT_TIMEOUT
303
+ timeout=BACKEND_REQUEST_DEFAULT_TIMEOUT
215
304
  )
216
- except queue.Empty:
217
- raise RuntimeError(f"Failed to receive response from backend to {action}")
305
+ except queue.Empty as err:
306
+ raise RuntimeError(
307
+ f"Failed to receive response from backend to {action}"
308
+ ) from err
218
309
 
219
310
  assert (
220
311
  isinstance(response, tuple) and len(response) > 0
@@ -223,23 +314,33 @@ class Adapter(ABC):
223
314
 
224
315
  return response[1:]
225
316
 
226
- def read_thread(self, event_queue: queue.Queue[BackendResponse], request_queue: queue.Queue[BackendResponse]) -> None:
317
+ def read_thread(
318
+ self,
319
+ signal_queue: SignalQueue,
320
+ request_queue: queue.Queue[BackendResponse],
321
+ ) -> None:
227
322
  while True:
228
323
  try:
229
- response : Tuple[Any,...] = self.backend_connection.recv()
324
+ if self.backend_connection is None:
325
+ raise RuntimeError("Backend connection wasn't initialized")
326
+ response: tuple[Any, ...] = self.backend_connection.recv()
230
327
  except (EOFError, TypeError, OSError):
231
- event_queue.put((Action.ERROR_BACKEND_DISCONNECTED,))
328
+ signal_queue.put(AdapterDisconnected())
232
329
  request_queue.put((Action.ERROR_BACKEND_DISCONNECTED,))
330
+ break
233
331
  else:
234
332
  if not isinstance(response, tuple):
235
333
  raise RuntimeError(f"Invalid response from backend : {response}")
236
334
  action = Action(response[0])
237
335
 
238
- if is_event(action):
336
+ if action == Action.ADAPTER_SIGNAL:
337
+ #if is_event(action):
338
+ if len(response) <= 1:
339
+ raise RuntimeError(f"Invalid event response : {response}")
340
+ signal: AdapterSignal = response[1]
239
341
  if self.event_callback is not None:
240
- signal : AdapterSignal = response[1]
241
342
  self.event_callback(signal)
242
- event_queue.put(response)
343
+ signal_queue.put(signal)
243
344
  else:
244
345
  request_queue.put(response)
245
346
 
@@ -247,7 +348,7 @@ class Adapter(ABC):
247
348
  def _default_timeout(self) -> Timeout:
248
349
  pass
249
350
 
250
- def set_timeout(self, timeout: Optional[Timeout]) -> None:
351
+ def set_timeout(self, timeout: Timeout | None) -> None:
251
352
  """
252
353
  Overwrite timeout
253
354
 
@@ -257,7 +358,7 @@ class Adapter(ABC):
257
358
  """
258
359
  self._timeout = timeout
259
360
 
260
- def set_default_timeout(self, default_timeout: Optional[Timeout]) -> None:
361
+ def set_default_timeout(self, default_timeout: Timeout | None) -> None:
261
362
  """
262
363
  Set the default timeout for this adapter. If a previous timeout has been set, it will be fused
263
364
 
@@ -269,7 +370,7 @@ class Adapter(ABC):
269
370
  self._logger.debug(f"Setting default timeout to {default_timeout}")
270
371
  self._timeout = default_timeout
271
372
 
272
- def set_stop_condition(self, stop_condition: Optional[StopCondition]) -> None:
373
+ def set_stop_conditions(self, stop_conditions: StopCondition | None | list[StopCondition]) -> None:
273
374
  """
274
375
  Overwrite the stop-condition
275
376
 
@@ -277,17 +378,16 @@ class Adapter(ABC):
277
378
  ----------
278
379
  stop_condition : StopCondition
279
380
  """
280
- self._stop_condition = stop_condition
281
- #payload : Optional[str]
282
- if self._stop_condition is None:
283
- payload = None
284
- else:
285
- payload = self._stop_condition.compose_json()
286
- self._make_backend_request(
287
- Action.SET_STOP_CONDITION, payload
288
- )
381
+ if isinstance(stop_conditions, list):
382
+ self._stop_conditions = stop_conditions
383
+ elif isinstance(stop_conditions, StopCondition):
384
+ self._stop_conditions = [stop_conditions]
385
+ elif stop_conditions is None:
386
+ self._stop_conditions = []
387
+
388
+ self._make_backend_request(Action.SET_STOP_CONDITION, self._stop_conditions)
289
389
 
290
- def set_default_stop_condition(self, stop_condition : StopCondition) -> None:
390
+ def set_default_stop_condition(self, stop_condition: StopCondition) -> None:
291
391
  """
292
392
  Set the default stop condition for this adapter.
293
393
 
@@ -296,7 +396,7 @@ class Adapter(ABC):
296
396
  stop_condition : StopCondition
297
397
  """
298
398
  if self._default_stop_condition:
299
- self.set_stop_condition(stop_condition)
399
+ self.set_stop_conditions(stop_condition)
300
400
 
301
401
  def flushRead(self) -> None:
302
402
  """
@@ -305,6 +405,12 @@ class Adapter(ABC):
305
405
  self._make_backend_request(
306
406
  Action.FLUSHREAD,
307
407
  )
408
+ while True:
409
+ try:
410
+ self._signal_queue.get(block=False)
411
+ except queue.Empty:
412
+ break
413
+
308
414
 
309
415
  def previous_read_buffer_empty(self) -> bool:
310
416
  """
@@ -320,14 +426,7 @@ class Adapter(ABC):
320
426
  """
321
427
  Start communication with the device
322
428
  """
323
- if self._stop_condition is None:
324
- payload = None
325
- else:
326
- payload = self._stop_condition.compose_json()
327
- self._make_backend_request(
328
- Action.OPEN,
329
- payload
330
- )
429
+ self._make_backend_request(Action.OPEN, self._stop_conditions)
331
430
  self._logger.info("Adapter opened")
332
431
  self.opened = True
333
432
 
@@ -335,14 +434,15 @@ class Adapter(ABC):
335
434
  """
336
435
  Stop communication with the device
337
436
  """
338
- self._logger.debug("Closing adapter frontend")
339
- self._make_backend_request(Action.CLOSE)
340
437
  if force:
438
+ self._logger.debug("Closing adapter frontend")
439
+ else:
341
440
  self._logger.debug("Force closing adapter backend")
342
- self._make_backend_request(Action.FORCE_CLOSE)
441
+ self._make_backend_request(Action.CLOSE, force)
343
442
 
344
443
  with self._backend_connection_lock:
345
- self.backend_connection.close()
444
+ if self.backend_connection is not None:
445
+ self.backend_connection.close()
346
446
 
347
447
  self.opened = False
348
448
 
@@ -358,29 +458,13 @@ class Adapter(ABC):
358
458
  if isinstance(data, str):
359
459
  data = data.encode(self.encoding)
360
460
  self._make_backend_request(Action.WRITE, data)
361
-
362
- @overload
363
- def read( #type: ignore (default arguments mess everything up)
364
- self,
365
- timeout: Union[Timeout, DefaultType, None] = DEFAULT,
366
- stop_condition: Union[StopCondition, DefaultType, None] = DEFAULT,
367
- full_output: Literal[True] = True,
368
- ) -> Tuple[bytes, AdapterSignal]: ...
369
461
 
370
- @overload
371
- def read(
462
+ def read_detailed(
372
463
  self,
373
- timeout: Union[Timeout, DefaultType, None] = DEFAULT,
374
- stop_condition: Union[StopCondition, DefaultType, None] = DEFAULT,
375
- full_output: Literal[False] = False,
376
- ) -> bytes: ...
377
-
378
- def read(
379
- self,
380
- timeout: Union[None, Timeout, DefaultType] = DEFAULT,
381
- stop_condition: Optional[Union[StopCondition, DefaultType]] = DEFAULT,
382
- full_output: bool = False,
383
- ) -> Union[bytes, Tuple[bytes, AdapterSignal]]:
464
+ timeout: Timeout | EllipsisType | None = ...,
465
+ stop_condition: StopCondition | EllipsisType | None = ...,
466
+ scope : str = ReadScope.BUFFERED.value,
467
+ ) -> AdapterReadPayload | None:
384
468
  """
385
469
  Read data from the device
386
470
 
@@ -390,117 +474,192 @@ class Adapter(ABC):
390
474
  Temporary timeout
391
475
  stop_condition : StopCondition
392
476
  Temporary stop condition
393
- full_output : bool
394
- If True, return read information as well as data
477
+ scope : str
478
+ Return previous data ('buffered') or only future data ('next')
395
479
  Returns
396
480
  -------
397
481
  data : bytes
398
- metrics : dict
399
- Only if full_output is True
482
+ signal : AdapterReadPayload
400
483
  """
401
-
402
- if timeout is DEFAULT:
403
- read_timeout = self._timeout
484
+ t = time.time()
485
+ _scope = ReadScope(scope)
486
+ output_signal = None
487
+
488
+ # First, we check if data is in the buffer and if the scope if set to BUFFERED
489
+ while _scope == ReadScope.BUFFERED and self._signal_queue.has_read_payload():
490
+ signal = self._signal_queue.get()
491
+ if isinstance(signal, AdapterReadPayload):
492
+ output_signal = signal
493
+ break
494
+ # TODO : Implement disconnect ?
404
495
  else:
405
- read_timeout = any_to_timeout(timeout)
496
+ # Nothing was found, ask the backend with a START_READ request. The backend will
497
+ # respond at most after the response_time with either data or a RESPONSE_TIMEOUT
498
+ if timeout is ...:
499
+ read_timeout = self._timeout
500
+ else:
501
+ read_timeout = any_to_timeout(timeout)
406
502
 
407
- output = None
503
+ if read_timeout is not None:
504
+ if not read_timeout.is_initialized():
505
+ raise RuntimeError("Timeout needs to be initialized")
408
506
 
409
- response_received = False
507
+ _response = read_timeout.response()
410
508
 
411
- if read_timeout is None:
412
- queue_timeout_timestamp = None
413
- elif read_timeout.response is None:
414
- queue_timeout_timestamp = None
415
- else:
416
- if read_timeout.response is DEFAULT:
417
- raise RuntimeError('Timeout needs to be initialized')
418
-
419
- queue_timeout_timestamp = (
420
- time.time() + read_timeout.response + self.ADDITIONNAL_RESPONSE_DELAY
421
- )
509
+ read_init_time = time.time()
510
+ start_read_id = self._make_backend_request(Action.START_READ, _response)[0]
422
511
 
423
- start_read_uuid = uuid.uuid1()
424
- # Send a read signal to the backend
425
- self._make_backend_request(
426
- Action.START_READ,
427
- read_timeout.response if read_timeout is not None else None,
428
- start_read_uuid
429
- )
512
+ if _response is None:
513
+ # Wait indefinitely
514
+ read_stop_timestamp = None
515
+ else:
516
+ # Wait for the response time + a bit more
517
+ read_stop_timestamp = read_init_time + _response
430
518
 
431
- while True:
432
- if queue_timeout_timestamp is None or response_received:
433
- queue_timeout = None
434
519
  else:
435
- queue_timeout = queue_timeout_timestamp - time.time()
436
- if queue_timeout < 0:
437
- queue_timeout = 0
520
+ start_read_id = None
521
+ read_init_time = None
438
522
 
439
- try:
440
- response = self._event_queue.get(block=True, timeout=queue_timeout)
441
- signal = response[1]
442
- except queue.Empty:
443
- raise RuntimeError(
444
- "Failed to receive response confirmation from backend"
445
- )
446
- else:
523
+
524
+ while True:
525
+ try:
526
+ if read_stop_timestamp is None:
527
+ queue_timeout = None
528
+ else:
529
+ queue_timeout = max(0, read_stop_timestamp - time.time() + EXTRA_BUFFER_RESPONSE_TIME)
530
+
531
+ signal = self._signal_queue.get(timeout=queue_timeout)
532
+ except queue.Empty:
533
+ raise RuntimeError('Failed to receive response from backend')
447
534
  if isinstance(signal, AdapterReadPayload):
448
- if response_received:
449
- output = signal.data()
450
- break
451
- elif isinstance(signal, AdapterReadInit):
452
- #signal: AdapterReadInit = response[1]
453
- # Check if it's the right read_init with the uuid, otherwise ignore it
454
- if signal.uuid == start_read_uuid:
455
- if signal.received_response_in_time:
456
- response_received = True
457
- else:
458
- if self._timeout is None:
459
- raise RuntimeError('Failed to receive data in time but timeout is None')
460
- else:
461
- if self._timeout.action == TimeoutAction.RETURN:
462
- output = b""
463
- break
464
- elif self._timeout.action == TimeoutAction.ERROR:
465
- raise TimeoutError(
466
- f"No response received from device within {self._timeout.response} seconds"
467
- )
535
+ output_signal = signal
536
+ break
468
537
  elif isinstance(signal, AdapterDisconnected):
469
538
  raise RuntimeError("Adapter disconnected")
539
+ elif isinstance(signal, AdapterResponseTimeout):
540
+ if start_read_id == signal.identifier:
541
+ output_signal = None
542
+ break
543
+ # Otherwise ignore it
544
+
545
+
546
+ if output_signal is None:
547
+ # TODO : Make read_timeout always Timeout, never None ?
548
+ match read_timeout.action:
549
+ case TimeoutAction.RETURN:
550
+ return None
551
+ case TimeoutAction.ERROR:
552
+ raise TimeoutError(
553
+ f"No response received from device within {read_timeout.response()} seconds"
554
+ )
555
+ case _:
556
+ raise NotImplementedError()
557
+
558
+ else:
559
+ return output_signal
560
+
561
+
562
+ # Okay idea : Remove the start read and instead ask for the time of the backend.
563
+ # Then we read whatever payload comes from the backend and compare that to the time
564
+ # If it doesn't match our criteria, we trash it
565
+ # When waiting for the backend payload, we wait +0.5s so make sure we received everything
566
+ # This 0.5s could be changed if we're local or not by the way
567
+
568
+
569
+ # # First, ask for the backend time, this is the official start of the read
570
+ # backend_read_start_time = cast(NumberLike, self._make_backend_request(Action.GET_BACKEND_TIME)[0])
571
+
572
+ # If not timeout is specified, use the default one
573
+
574
+ # Calculate last_valid_timestamp, the limit at which a payload is not accepted anymore
575
+ # Calculate the queue timeout (time for a response + small delay)
576
+ #last_valid_timestamp = None
577
+ # queue_timeout_timestamp = None
578
+ # if read_timeout is not None:
579
+ # response_delay = read_timeout.response()
580
+ # else:
581
+ # response_delay = None
582
+
583
+ # if response_delay is not None:
584
+ # #last_valid_timestamp = backend_read_start_time + response_delay
585
+ # queue_timeout_timestamp = time.time() + response_delay + BACKEND_REQUEST_DEFAULT_TIMEOUT
586
+
587
+ # output_signal : AdapterReadPayload | None
588
+ # # This delay is given by the backend when a fragment is received. It basically says
589
+ # # "I've received something, wait at most x seconds before raising an error"
590
+ # read_init_end_delay : float | None = None
591
+
592
+ # # Ready to read payloads
593
+ # while True:
594
+ # if read_init_end_delay is not None:
595
+ # # Find the next timeout
596
+ # queue_timeout = read_init_end_delay + 0.1 # TODO : Make this clean, this is the delay to let data arrive to the frontend
597
+ # elif queue_timeout_timestamp is None:
598
+ # queue_timeout = None
599
+ # else:
600
+ # queue_timeout = queue_timeout_timestamp - time.time()
601
+ # if queue_timeout < 0:
602
+ # queue_timeout = 0
603
+
604
+ # try:
605
+ # response = self._signal_queue.peek(block=True, timeout=queue_timeout)
606
+ # signal = response[1]
607
+
608
+ # if isinstance(signal, AdapterReadPayload):
609
+ # if response_delay is not None and signal.response_timestamp - backend_read_start_time > response_delay:
610
+ # # This signal happened after the max response time, act as if a timeout occured
611
+ # # and do not pop it out of the queue
612
+ # # TODO : Make _timeout always Timeout, never None ?
613
+ # output_signal = None
614
+ # break
615
+
616
+ # if _scope == ReadScope.NEXT and signal.response_timestamp < backend_read_start_time:
617
+ # # The payload happened before the read start
618
+ # self._signal_queue.get()
619
+ # continue
620
+
621
+ # if response_delay is not None:
622
+ # if signal.response_timestamp - backend_read_start_time > response_delay:
623
+ # self._signal_queue.get()
624
+ # output_signal = None
625
+ # break
626
+
627
+ # # Other wise the payload is valid
628
+ # self._signal_queue.get()
629
+ # output_signal = signal
630
+ # break
631
+ # elif isinstance(signal, AdapterReadInit):
632
+ # read_init_end_delay = signal.end_delay
633
+ # self._signal_queue.get()
634
+ # elif isinstance(signal, AdapterDisconnected):
635
+ # self._signal_queue.get()
636
+
637
+
638
+ # except queue.Empty:
639
+ # output_signal = None
640
+ # break
470
641
 
471
- if full_output:
472
- return output, signal
642
+ def read(
643
+ self,
644
+ timeout: Timeout | EllipsisType | None = ...,
645
+ stop_condition: StopCondition | EllipsisType | None = ...,
646
+ ) -> bytes:
647
+ signal = self.read_detailed(timeout=timeout, stop_condition=stop_condition)
648
+ if signal is None:
649
+ return None
473
650
  else:
474
- return output
651
+ return signal.data()
475
652
 
476
653
  def _cleanup(self) -> None:
477
654
  if self._init_ok and self.opened:
478
655
  self.close()
479
656
 
480
- @overload
481
- def query(
482
- self,
483
- data: Union[bytes, str],
484
- timeout: Optional[Union[Timeout, DefaultType]] = DEFAULT,
485
- stop_condition: Optional[Union[StopCondition, DefaultType]] = DEFAULT,
486
- full_output: Literal[True] = True,
487
- ) -> Tuple[bytes, AdapterSignal]: ...
488
- @overload
489
- def query(
490
- self,
491
- data: Union[bytes, str],
492
- timeout: Optional[Union[Timeout, DefaultType]] = DEFAULT,
493
- stop_condition: Optional[Union[StopCondition, DefaultType]] = DEFAULT,
494
- full_output: Literal[False] = False,
495
- ) -> bytes: ...
496
-
497
- def query(
657
+ def query_detailed(
498
658
  self,
499
- data: Union[bytes, str],
500
- timeout: Optional[Union[Timeout, DefaultType]] = DEFAULT,
501
- stop_condition: Optional[Union[StopCondition, DefaultType]] = DEFAULT,
502
- full_output: bool = False,
503
- ) -> Union[bytes, Tuple[bytes, AdapterSignal]]:
659
+ data: bytes | str,
660
+ timeout: Timeout | EllipsisType | None = ...,
661
+ stop_condition: StopCondition | EllipsisType | None = ...,
662
+ ) -> tuple[bytes, AdapterReadPayload | None]:
504
663
  """
505
664
  Shortcut function that combines
506
665
  - flush_read
@@ -509,16 +668,23 @@ class Adapter(ABC):
509
668
  """
510
669
  self.flushRead()
511
670
  self.write(data)
512
- output, signal = self.read(
513
- timeout=timeout, stop_condition=stop_condition, full_output=True
514
- )
671
+ return self.read_detailed(timeout=timeout, stop_condition=stop_condition)
515
672
 
516
- if full_output:
517
- return output, signal
673
+ def query(
674
+ self,
675
+ data: bytes | str,
676
+ timeout: Timeout | EllipsisType | None = ...,
677
+ stop_condition: StopCondition | EllipsisType | None = ...,
678
+ ) -> bytes:
679
+ signal = self.query_detailed(
680
+ data=data, timeout=timeout, stop_condition=stop_condition
681
+ )
682
+ if signal is None:
683
+ return None
518
684
  else:
519
- return output
685
+ return signal.data()
520
686
 
521
- def set_event_callback(self, callback : Callable[[AdapterSignal], None]) -> None:
687
+ def set_event_callback(self, callback: Callable[[AdapterSignal], None]) -> None:
522
688
  self.event_callback = callback
523
689
 
524
690
  def __str__(self) -> str: