syndesi 0.4.4__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 +6 -1
  2. syndesi/adapters/adapter.py +318 -550
  3. syndesi/adapters/adapter_worker.py +820 -0
  4. syndesi/adapters/auto.py +32 -10
  5. syndesi/adapters/descriptors.py +38 -0
  6. syndesi/adapters/ip.py +197 -71
  7. syndesi/adapters/serialport.py +136 -20
  8. syndesi/adapters/stop_conditions.py +354 -0
  9. syndesi/adapters/timeout.py +57 -21
  10. syndesi/adapters/visa.py +227 -10
  11. syndesi/cli/console.py +50 -15
  12. syndesi/cli/shell.py +95 -47
  13. syndesi/cli/terminal_tools.py +8 -8
  14. syndesi/component.py +267 -31
  15. syndesi/protocols/delimited.py +92 -107
  16. syndesi/protocols/modbus.py +2370 -871
  17. syndesi/protocols/protocol.py +184 -37
  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 +11 -1
  22. syndesi/tools/errors.py +31 -33
  23. syndesi/tools/log_settings.py +21 -8
  24. syndesi/tools/{log.py → logmanager.py} +23 -13
  25. syndesi/tools/types.py +8 -7
  26. syndesi/version.py +1 -1
  27. {syndesi-0.4.4.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 -345
  33. syndesi/adapters/backend/backend.py +0 -443
  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 -152
  38. syndesi/adapters/backend/serialport_backend.py +0 -246
  39. syndesi/adapters/backend/stop_condition_backend.py +0 -222
  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 -108
  44. syndesi/adapters/stop_condition.py +0 -114
  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 -182
  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.4.dist-info/RECORD +0 -61
  54. {syndesi-0.4.4.dist-info → syndesi-0.5.0.dist-info}/WHEEL +0 -0
  55. {syndesi-0.4.4.dist-info → syndesi-0.5.0.dist-info}/entry_points.txt +0 -0
  56. {syndesi-0.4.4.dist-info → syndesi-0.5.0.dist-info}/licenses/LICENSE +0 -0
  57. {syndesi-0.4.4.dist-info → syndesi-0.5.0.dist-info}/top_level.txt +0 -0
@@ -1,702 +1,470 @@
1
- # File : adapters.py
1
+ # File : adapter.py
2
2
  # Author : Sébastien Deriaz
3
3
  # License : GPL
4
4
 
5
5
  """
6
6
  Adapters provide a common abstraction for the media layers (physical + data link + network)
7
- The following classes are provided, which all are derived from the main Adapter class
8
- - IP
9
- - Serial
10
- - VISA
11
7
 
12
- Note that technically VISA is not part of the media layer, only USB is.
13
- This is a limitation as it is to this day not possible to communicate "raw"
14
- with a device through USB yet
8
+ The user calls methods of the Adapter class synchronously.
15
9
 
16
10
  An adapter is meant to work with bytes objects but it can accept strings.
17
11
  Strings will automatically be converted to bytes using utf-8 encoding
12
+
13
+ Each adapter contains a worker thread that monitors the low-level communication layers.
14
+ This approach allows for precise time management (when each fragment is sent/received) and allows
15
+ for asynchronous events (fragment received).
16
+
17
+ Async facade:
18
+ - aopen/awrite/aread/aread_detailed simply await the SAME underlying worker-thread commands
19
+ using asyncio.wrap_future (no extra threads are spawned).
18
20
  """
19
21
 
20
- import logging
21
- import os
22
- import queue
23
- import subprocess
24
- import sys
22
+ # NOTE:
23
+ # This version removes the "worker publishes events into a queue that read_detailed consumes".
24
+ # Instead:
25
+ # - The worker continuously assembles AdapterFrame from fragments (as before).
26
+ # - A read_detailed command registers a "pending read" inside the worker.
27
+ # - When a frame completes, the worker either:
28
+ # * completes the pending read future, OR
29
+ # * buffers the frame for later buffered reads, and optionally calls the callback.
30
+ #
31
+ # This avoids having a sync queue AND an async queue, and makes async wrappers trivial.
32
+
33
+ import asyncio
25
34
  import threading
26
- import time
27
35
  import weakref
28
- from abc import ABC, abstractmethod
36
+ from abc import abstractmethod
29
37
  from collections.abc import Callable
30
38
  from enum import Enum
31
- from multiprocessing.connection import Client, Connection
32
39
  from types import EllipsisType
33
- from typing import Any
34
-
35
- from ..component import Component
36
- from ..tools.errors import (
37
- AdapterDisconnected,
38
- AdapterFailedToOpen,
39
- AdapterTimeoutError,
40
- BackendCommunicationError,
41
- )
42
- from ..tools.types import NumberLike, is_number
43
40
 
44
- from ..tools.backend_api import (
45
- BACKEND_PORT,
46
- DEFAULT_ADAPTER_OPEN_TIMEOUT,
47
- EXTRA_BUFFER_RESPONSE_TIME,
48
- Action,
49
- BackendResponse,
50
- Fragment,
51
- default_host,
52
- raise_if_error,
53
- )
41
+ from syndesi.tools.errors import AdapterError
42
+
43
+ from ..component import AdapterFrame, Component, Descriptor, ReadScope
54
44
  from ..tools.log_settings import LoggerAlias
55
- from .backend.adapter_backend import (
56
- AdapterDisconnectedSignal,
57
- AdapterReadPayload,
58
- AdapterResponseTimeout,
59
- AdapterSignal,
45
+ from ..tools.types import NumberLike, is_number
46
+ from .adapter_worker import (
47
+ AdapterEvent,
48
+ AdapterWorker,
49
+ CloseCommand,
50
+ FlushReadCommand,
51
+ IsOpenCommand,
52
+ OpenCommand,
53
+ ReadCommand,
54
+ SetDescriptorCommand,
55
+ SetEventCallbackCommand,
56
+ SetStopConditionsCommand,
57
+ SetTimeoutCommand,
58
+ StopThreadCommand,
59
+ WriteCommand,
60
60
  )
61
- from .backend.backend_tools import BACKEND_REQUEST_DEFAULT_TIMEOUT
62
- from .backend.descriptors import Descriptor
63
- from .stop_condition import Continuation, StopCondition, StopConditionType
61
+ from .stop_conditions import Fragment, StopCondition
64
62
  from .timeout import Timeout, TimeoutAction, any_to_timeout
65
63
 
66
- DEFAULT_STOP_CONDITION = Continuation(time=0.1)
67
-
68
- DEFAULT_TIMEOUT = Timeout(response=5, action="error")
69
-
70
- # Maximum time to let the backend start
71
- START_TIMEOUT = 2
72
- # Time to shutdown the backend
73
- SHUTDOWN_DELAY = 2
64
+ fragments: list[Fragment]
74
65
 
75
66
 
76
- class SignalQueue(queue.Queue[AdapterSignal]):
67
+ # pylint: disable=too-many-public-methods, too-many-instance-attributes
68
+ class Adapter(Component[bytes], AdapterWorker):
77
69
  """
78
- A smart queue to hold adapter signals
79
- """
80
- def __init__(self) -> None:
81
- self._read_payload_counter = 0
82
- super().__init__(0)
83
-
84
- def has_read_payload(self) -> bool:
85
- """
86
- Return True if the queue contains a read payload
87
- """
88
- return self._read_payload_counter > 0
89
-
90
- def put(self, signal: AdapterSignal) -> None:
91
- """
92
- Put a signal in the queue
93
-
94
- Parameters
95
- ----------
96
- signal : AdapterSignal
97
- """
98
- if isinstance(signal, AdapterReadPayload):
99
- self._read_payload_counter += 1
100
- return super().put(signal)
101
-
102
- def get(self, block: bool = True, timeout: float | None = None) -> AdapterSignal:
103
- """
104
- Get an AdapterSignal from the queue
105
- """
106
- signal = super().get(block, timeout)
107
- if isinstance(signal, AdapterReadPayload):
108
- self._read_payload_counter -= 1
109
- return signal
110
-
111
- def is_backend_running(address: str, port: int) -> bool:
112
- """
113
- Return True if the backend is running
114
- """
115
- try:
116
- conn = Client((address, port))
117
- except ConnectionRefusedError:
118
- return False
119
- conn.close()
120
- return True
121
-
122
- def start_backend(port: int | None = None) -> None:
123
- """
124
- Start the backend in a separate process
125
-
126
- A custom port can be specified
127
-
128
- Parameters
129
- ----------
130
- port : int
131
- """
132
- arguments = [
133
- sys.executable,
134
- "-m",
135
- "syndesi.adapters.backend.backend",
136
- "-s",
137
- str(SHUTDOWN_DELAY),
138
- "-q",
139
- "-p",
140
- str(BACKEND_PORT if port is None else port),
141
- ]
142
-
143
- stdin = subprocess.DEVNULL
144
- stdout = subprocess.DEVNULL
145
- stderr = subprocess.DEVNULL
146
-
147
- if os.name == "posix":
148
- subprocess.Popen( #pylint: disable=consider-using-with
149
- arguments,
150
- stdin=stdin,
151
- stdout=stdout,
152
- stderr=stderr,
153
- start_new_session=True,
154
- close_fds=True,
155
- )
156
-
157
- else:
158
- # Windows: detach from the parent's console so keyboard Ctrl+C won't propagate.
159
- create_new_process_group = subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore
160
- detached_process = 0x00000008 # not exposed by subprocess on all Pythons
161
- # Optional: CREATE_NO_WINDOW (no window even for console apps)
162
- create_no_window = 0x08000000
163
-
164
- creationflags = create_new_process_group | detached_process | create_no_window
165
-
166
- subprocess.Popen( #pylint: disable=consider-using-with
167
- arguments,
168
- stdin=stdin,
169
- stdout=stdout,
170
- stderr=stderr,
171
- creationflags=creationflags,
172
- close_fds=True,
173
- )
174
-
175
-
176
- class ReadScope(Enum):
177
- """
178
- Read scope
70
+ Adapter class
179
71
 
180
- NEXT : Only read data after the start of the read() call
181
- BUFFERED : Return any data that was present before the read() call
72
+ An adapter manages communication with a hardware device.
182
73
  """
183
- NEXT = "next"
184
- BUFFERED = "buffered"
185
74
 
75
+ class WorkerTimeout(Enum):
76
+ """Timeout value for each worker command scenario"""
186
77
 
187
- class Adapter(Component[bytes]):
188
- """
189
- Adapter class
78
+ OPEN = 2
79
+ STOP = 1
80
+ IMMEDIATE_COMMAND = 0.2
81
+ CLOSE = 0.5
82
+ WRITE = 0.5
83
+ READ = None
190
84
 
191
- An adapter permits communication with a hardware device.
192
- The adapter is the user interface of the backend adapter
193
- """
194
- #pylint: disable=too-many-instance-attributes
195
85
  def __init__(
196
86
  self,
197
87
  *,
198
88
  descriptor: Descriptor,
199
- alias: str = "",
200
- stop_conditions: StopCondition | EllipsisType | list[StopCondition] = ...,
201
- timeout: Timeout | EllipsisType | NumberLike | None = ...,
89
+ stop_conditions: StopCondition | EllipsisType | list[StopCondition],
90
+ timeout: Timeout | EllipsisType | NumberLike | None,
91
+ alias: str,
202
92
  encoding: str = "utf-8",
203
- event_callback: Callable[[AdapterSignal], None] | None = None,
93
+ event_callback: Callable[[AdapterEvent], None] | None = None,
204
94
  auto_open: bool = True,
205
- backend_address: str | None = None,
206
- backend_port: int | None = None,
207
95
  ) -> None:
208
- """
209
- Adapter instance
210
-
211
- Parameters
212
- ----------
213
- alias : str
214
- The alias is used to identify the class in the logs
215
- timeout : float or Timeout instance
216
- Default timeout is
217
- stop_condition : StopCondition or None
218
- Default to None
219
- encoding : str
220
- Which encoding to use if str has to be encoded into bytes
221
- """
222
96
  super().__init__(LoggerAlias.ADAPTER)
223
97
  self.encoding = encoding
224
- self._signal_queue: SignalQueue = SignalQueue()
225
- self.event_callback: Callable[[AdapterSignal], None] | None = event_callback
226
- self.backend_connection: Connection | None = None
227
- self._backend_connection_lock = threading.Lock()
228
- self._make_backend_request_queue: queue.Queue[BackendResponse] = queue.Queue()
229
- self._make_backend_request_flag = threading.Event()
230
- self._opened = False
231
98
  self._alias = alias
232
99
 
233
- # Use custom backend or the default one
234
- self._backend_address = default_host if backend_address is None else backend_address
235
- self._backend_port = BACKEND_PORT if backend_port is None else backend_port
236
-
237
- # There a two possibilities here
238
- # A) The descriptor is fully initialized
239
- # -> The adapter can be connected directly
240
- # B) The descriptor is not fully initialized
241
- # -> Wait for the protocol to set defaults and then connect the adapter
242
-
243
100
  self.descriptor = descriptor
244
101
  self.auto_open = auto_open
245
102
 
246
- # Set the stop-condition
247
- self._stop_conditions: list[StopCondition]
103
+ self._initial_event_callback = event_callback
104
+
105
+ # Default stop conditions
106
+ self._initial_stop_conditions: list[StopCondition]
248
107
  if stop_conditions is ...:
249
- self._default_stop_condition = True
250
- self._stop_conditions = [DEFAULT_STOP_CONDITION]
108
+ self._is_default_stop_condition = True
109
+ self._initial_stop_conditions = self._default_stop_conditions()
251
110
  else:
252
- self._default_stop_condition = False
111
+ self._is_default_stop_condition = False
253
112
  if isinstance(stop_conditions, StopCondition):
254
- self._stop_conditions = [stop_conditions]
113
+ self._initial_stop_conditions = [stop_conditions]
255
114
  elif isinstance(stop_conditions, list):
256
- self._stop_conditions = stop_conditions
115
+ self._initial_stop_conditions = stop_conditions
257
116
  else:
258
117
  raise ValueError("Invalid stop_conditions")
259
118
 
260
- # Set the timeout
261
- self.is_default_timeout = False
262
- self._timeout: Timeout | None
119
+ # Default timeout
120
+ self.is_default_timeout = timeout is Ellipsis
121
+
263
122
  if timeout is Ellipsis:
264
- # Not set
265
- self.is_default_timeout = True
266
- self._timeout = DEFAULT_TIMEOUT
123
+ self._initial_timeout = self._default_timeout()
267
124
  elif isinstance(timeout, Timeout):
268
- self._timeout = timeout
125
+ self._initial_timeout = timeout
269
126
  elif is_number(timeout):
270
- self._timeout = Timeout(timeout, action=TimeoutAction.ERROR)
127
+ self._initial_timeout = Timeout(timeout, action=TimeoutAction.ERROR)
271
128
  elif timeout is None:
272
- self._timeout = timeout
129
+ self._initial_timeout = Timeout(None)
130
+ else:
131
+ raise ValueError(f"Invalid timeout : {timeout}")
273
132
 
274
- # Buffer for data that has been pulled from the queue but
275
- # not used because of termination or length stop condition
276
- self._previous_buffer = b""
133
+ # Worker thread
134
+ self._worker_thread = threading.Thread(
135
+ target=self._worker_thread_method, daemon=True
136
+ )
137
+ self._worker_thread.start()
277
138
 
278
- if self.descriptor.is_initialized():
279
- self.connect()
139
+ # Serialize read/write/query ordering for sync callers.
140
+ self._sync_io_lock = threading.Lock()
141
+ # Serialize read/write/query ordering for async callers.
142
+ self._async_io_lock = asyncio.Lock()
280
143
 
281
- weakref.finalize(self, self._cleanup)
144
+ self._logger.info(f"Setting up {self.descriptor} adapter ")
145
+ self._update_descriptor()
146
+ self.set_stop_conditions(self._initial_stop_conditions)
147
+ self.set_timeout(self._initial_timeout)
148
+ self.set_event_callback(self._initial_event_callback)
282
149
 
283
- # We can auto-open only if auto_open is enabled and if
284
- # connection with the backend has been made (descriptor initialized)
285
- if self.auto_open and self.backend_connection is not None:
150
+ if self.descriptor.is_initialized() and auto_open:
286
151
  self.open()
287
152
 
288
- def connect(self) -> None:
289
- """
290
- Connect to the backend
291
- """
292
- if self.backend_connection is not None:
293
- # No need to connect, everything has been done already
294
- return
295
- if not self.descriptor.is_initialized():
296
- raise RuntimeError("Descriptor wasn't initialized fully")
297
-
298
- if is_backend_running(self._backend_address, self._backend_port):
299
- self._logger.info("Backend already running")
300
- else:
301
- self._logger.info("Starting backend...")
302
- start_backend(self._backend_port)
303
- start = time.time()
304
- while time.time() < (start + START_TIMEOUT):
305
- if is_backend_running(self._backend_address, self._backend_port):
306
- self._logger.info("Backend started")
307
- break
308
- time.sleep(0.1)
309
- else:
310
- # Backend could not start
311
- self._logger.error("Could not start backend")
153
+ weakref.finalize(self, self._cleanup)
312
154
 
313
- # Create the client to communicate with the backend
155
+ # ┌──────────────────────────┐
156
+ # │ Defaults / configuration │
157
+ # └──────────────────────────┘
158
+
159
+ def _stop(self) -> None:
160
+ cmd = StopThreadCommand()
161
+ self._worker_send_command(cmd)
314
162
  try:
315
- self.backend_connection = Client((default_host, BACKEND_PORT))
316
- except ConnectionRefusedError as err:
317
- raise BackendCommunicationError("Failed to connect to backend") from err
318
- self._read_thread = threading.Thread(
319
- target=self.read_thread,
320
- args=(self._signal_queue, self._make_backend_request_queue),
321
- daemon=True,
322
- )
323
- self._read_thread.start()
163
+ cmd.result(self.WorkerTimeout.STOP.value)
164
+ except AdapterError:
165
+ pass
324
166
 
325
- # Identify ourselves
326
- self._make_backend_request(Action.SET_ROLE_ADAPTER)
167
+ def _update_descriptor(self) -> None:
168
+ cmd = SetDescriptorCommand(self.descriptor)
169
+ self._worker_send_command(cmd)
170
+ cmd.result(self.WorkerTimeout.IMMEDIATE_COMMAND.value)
327
171
 
328
- # Set the adapter
329
- self._make_backend_request(Action.SELECT_ADAPTER, str(self.descriptor))
172
+ @abstractmethod
173
+ def _default_timeout(self) -> Timeout:
174
+ raise NotImplementedError
330
175
 
331
- if self.auto_open:
332
- self.open()
176
+ @abstractmethod
177
+ def _default_stop_conditions(self) -> list[StopCondition]:
178
+ raise NotImplementedError
333
179
 
334
- def _make_backend_request(
335
- self,
336
- action: Action,
337
- *args: Any,
338
- timeout: float = BACKEND_REQUEST_DEFAULT_TIMEOUT,
339
- ) -> BackendResponse:
340
- """
341
- Send a request to the backend and return the arguments
342
- """
180
+ def __str__(self) -> str:
181
+ return str(self.descriptor)
343
182
 
344
- with self._backend_connection_lock:
345
- if self.backend_connection is not None:
346
- self.backend_connection.send((action.value, *args))
183
+ def __repr__(self) -> str:
184
+ return self.__str__()
347
185
 
348
- self._make_backend_request_flag.set()
186
+ def _cleanup(self) -> None:
187
+ # Be defensive: finalizers can run at interpreter shutdown.
349
188
  try:
350
- response = self._make_backend_request_queue.get(timeout=timeout)
351
- except queue.Empty as err:
352
- raise BackendCommunicationError(
353
- f"Failed to receive response from backend to {action}"
354
- ) from err
355
-
356
- assert (
357
- isinstance(response, tuple) and len(response) > 0
358
- ), f"Invalid response received from backend : {response}"
359
- raise_if_error(response)
189
+ if self.is_open():
190
+ self.close()
191
+ except AdapterError:
192
+ pass
360
193
 
361
- return response[1:]
194
+ self._stop()
362
195
 
363
- def read_thread(
364
- self,
365
- signal_queue: SignalQueue,
366
- request_queue: queue.Queue[BackendResponse],
367
- ) -> None:
368
- """
369
- Main adapter thread, it constantly listens for data coming from the backend
370
-
371
- - signal -> put only the signal in the signal queue
372
- - otherwise -> put the whole request in the request queue
373
- """
374
- while True:
375
- try:
376
- if self.backend_connection is None:
377
- raise RuntimeError("Backend connection wasn't initialized")
378
- response: tuple[Any, ...] = self.backend_connection.recv()
379
- except (EOFError, TypeError, OSError):
380
- signal_queue.put(AdapterDisconnectedSignal())
381
- request_queue.put((Action.ERROR_BACKEND_DISCONNECTED,))
382
- break
383
- else:
384
- if not isinstance(response, tuple):
385
- raise BackendCommunicationError(
386
- f"Invalid response from backend : {response}"
387
- )
388
- action = Action(response[0])
389
-
390
- if action == Action.ADAPTER_SIGNAL:
391
- if len(response) <= 1:
392
- raise BackendCommunicationError(
393
- f"Invalid event response : {response}"
394
- )
395
- signal: AdapterSignal = response[1]
396
- if self.event_callback is not None:
397
- self.event_callback(signal)
398
- signal_queue.put(signal)
399
- else:
400
- request_queue.put(response)
196
+ try:
197
+ self._command_queue_r.close()
198
+ self._command_queue_w.close()
199
+ except AdapterError:
200
+ pass
401
201
 
402
- @abstractmethod
403
- def _default_timeout(self) -> Timeout:
404
- pass
202
+ # ┌────────────┐
203
+ # Public API │
204
+ # └────────────┘
405
205
 
406
- def set_timeout(self, timeout: Timeout | None) -> None:
206
+ def set_timeout(self, timeout: Timeout | None | float) -> None:
407
207
  """
408
- Overwrite timeout
208
+ Set adapter timeout
409
209
 
410
210
  Parameters
411
211
  ----------
412
- timeout : Timeout
212
+ timeout : Timeout, float or None
413
213
  """
414
- self._timeout = timeout
214
+ # This is read by the worker when ReadCommand.timeout is ...
215
+ timeout_instance = any_to_timeout(timeout)
216
+ cmd = SetTimeoutCommand(timeout_instance)
217
+ self._worker_send_command(cmd)
218
+ cmd.result(self.WorkerTimeout.IMMEDIATE_COMMAND.value)
415
219
 
416
220
  def set_default_timeout(self, default_timeout: Timeout | None) -> None:
417
221
  """
418
- Set the default timeout for this adapter. If a previous
419
- timeout has been set, it will be fused
222
+ Configure adapter default timeout. Timeout will only be set if none
223
+ has been configured before
420
224
 
421
225
  Parameters
422
226
  ----------
423
- default_timeout : Timeout or tuple or float
227
+ default_timeout : Timeout or None
424
228
  """
425
229
  if self.is_default_timeout:
426
- self._logger.debug(f"Setting default timeout to {default_timeout}")
427
- self._timeout = default_timeout
230
+ new_timeout = any_to_timeout(default_timeout)
231
+ self._logger.debug(f"Setting default timeout to {new_timeout}")
232
+ self.set_timeout(new_timeout)
428
233
 
429
234
  def set_stop_conditions(
430
235
  self, stop_conditions: StopCondition | None | list[StopCondition]
431
236
  ) -> None:
432
237
  """
433
- Overwrite the stop-condition
238
+ Set adapter stop-conditions
434
239
 
435
240
  Parameters
436
241
  ----------
437
- stop_condition : StopCondition
242
+ stop_conditions : [StopCondition] or None
438
243
  """
439
244
  if isinstance(stop_conditions, list):
440
- self._stop_conditions = stop_conditions
245
+ lst = stop_conditions
441
246
  elif isinstance(stop_conditions, StopCondition):
442
- self._stop_conditions = [stop_conditions]
247
+ lst = [stop_conditions]
443
248
  elif stop_conditions is None:
444
- self._stop_conditions = []
249
+ lst = []
250
+ else:
251
+ raise ValueError("Invalid stop_conditions")
445
252
 
446
- self._make_backend_request(Action.SET_STOP_CONDITIONs, self._stop_conditions)
253
+ cmd = SetStopConditionsCommand(lst)
254
+ self._worker_send_command(cmd)
255
+ cmd.result(self.WorkerTimeout.IMMEDIATE_COMMAND.value)
447
256
 
448
- def set_default_stop_condition(self, stop_condition: StopCondition) -> None:
257
+ def set_default_stop_conditions(self, stop_conditions: list[StopCondition]) -> None:
449
258
  """
450
- Set the default stop condition for this adapter.
259
+ Configure adapter default stop-condition. Stop-condition will only be set if none
260
+ has been configured before
451
261
 
452
262
  Parameters
453
263
  ----------
454
- stop_condition : StopCondition
264
+ stop_conditions : [StopCondition]
455
265
  """
456
- if self._default_stop_condition:
457
- self.set_stop_conditions(stop_condition)
266
+ if self._is_default_stop_condition:
267
+ self.set_stop_conditions(stop_conditions)
458
268
 
459
- def flush_read(self) -> None:
460
- """
461
- Flush the input buffer
269
+ def set_event_callback(
270
+ self, callback: Callable[[AdapterEvent], None] | None
271
+ ) -> None:
462
272
  """
463
- self._make_backend_request(
464
- Action.FLUSHREAD,
465
- )
466
- while True:
467
- try:
468
- self._signal_queue.get(block=False)
469
- except queue.Empty:
470
- break
273
+ Configure event callback. Event callback is called as such :
471
274
 
472
- def previous_read_buffer_empty(self) -> bool:
473
- """
474
- Check whether the previous read buffer is empty
275
+ callback(event : AdapterEvent)
276
+
277
+ Parameters
278
+ ----------
279
+ callback : callable
475
280
 
476
- Returns
477
- -------
478
- empty : bool
479
281
  """
480
- return self._previous_buffer == b""
282
+ cmd = SetEventCallbackCommand(callback)
283
+ self._worker_send_command(cmd)
284
+ cmd.result(self.WorkerTimeout.IMMEDIATE_COMMAND.value)
285
+
286
+ # ==== open ====
287
+
288
+ def _open_future(self) -> OpenCommand:
289
+ cmd = OpenCommand()
290
+ self._worker_send_command(cmd)
291
+ return cmd
481
292
 
482
293
  def open(self) -> None:
483
294
  """
484
- Start communication with the device
295
+ Open adapter communication with the target (blocking)
485
296
  """
486
- self._make_backend_request(
487
- Action.OPEN,
488
- self._stop_conditions,
489
- timeout=BACKEND_REQUEST_DEFAULT_TIMEOUT + DEFAULT_ADAPTER_OPEN_TIMEOUT,
490
- )
491
- self._logger.info("Adapter opened")
492
- self._opened = True
297
+ return self._open_future().result(self.WorkerTimeout.OPEN.value)
493
298
 
494
- def try_open(self) -> bool:
495
- try:
496
- self.open()
497
- except AdapterFailedToOpen:
498
- return False
499
- return True
500
-
501
- def close(self, force: bool = False) -> None:
299
+ async def aopen(self) -> None:
502
300
  """
503
- Stop communication with the device
301
+ Open adapter communication with the target (async)
504
302
  """
505
- if force:
506
- self._logger.debug("Closing adapter frontend")
507
- else:
508
- self._logger.debug("Force closing adapter backend")
509
- self._make_backend_request(Action.CLOSE, force)
303
+ await asyncio.wrap_future(self._open_future())
510
304
 
511
- with self._backend_connection_lock:
512
- if self.backend_connection is not None:
513
- self.backend_connection.close()
305
+ # ==== close ====
514
306
 
515
- self._opened = False
307
+ def _close_future(self) -> CloseCommand:
308
+ cmd = CloseCommand()
309
+ self._worker_send_command(cmd)
310
+ return cmd
516
311
 
517
- def is_opened(self) -> bool:
312
+ def close(self) -> None:
518
313
  """
519
- Return True if the adapter is opened and False otherwise
520
-
521
- Returns
522
- -------
523
- opened : bool
314
+ Close adapter communication with the target (blocking)
524
315
  """
525
- return self._opened
316
+ self._close_future().result(self.WorkerTimeout.CLOSE.value)
526
317
 
527
- def write(self, data: bytes) -> None:
318
+ async def aclose(self) -> None:
528
319
  """
529
- Send data to the device
530
-
531
- Parameters
532
- ----------
533
- data : bytes or str
320
+ Close adapter communication with the target (async)
534
321
  """
322
+ await asyncio.wrap_future(self._close_future())
535
323
 
536
- if isinstance(data, str):
537
- data = data.encode(self.encoding)
538
- self._make_backend_request(Action.WRITE, data)
324
+ # ==== read_detailed ====
325
+
326
+ def _read_detailed_future(
327
+ self,
328
+ timeout: Timeout | EllipsisType | None,
329
+ stop_conditions: StopCondition | EllipsisType | list[StopCondition],
330
+ scope: str,
331
+ ) -> ReadCommand:
332
+ cmd = ReadCommand(
333
+ timeout=timeout,
334
+ stop_conditions=stop_conditions,
335
+ scope=ReadScope(scope),
336
+ )
337
+ self._worker_send_command(cmd)
338
+ return cmd
539
339
 
540
340
  def read_detailed(
541
341
  self,
542
342
  timeout: Timeout | EllipsisType | None = ...,
543
343
  stop_conditions: StopCondition | EllipsisType | list[StopCondition] = ...,
544
344
  scope: str = ReadScope.BUFFERED.value,
545
- ) -> AdapterReadPayload:
546
- """
547
- Read data from the device
548
-
549
- Parameters
550
- ----------
551
- timeout : tuple, Timeout
552
- Temporary timeout
553
- stop_condition : StopCondition
554
- Temporary stop condition
555
- scope : str
556
- Return previous data ('buffered') or only future data ('next')
557
- Returns
558
- -------
559
- data : bytes
560
- signal : AdapterReadPayload
561
- """
562
- _scope = ReadScope(scope)
563
- output_signal = None
564
- read_timeout = None
565
-
566
- if timeout is ...:
567
- read_timeout = self._timeout
568
- else:
569
- read_timeout = any_to_timeout(timeout)
345
+ ) -> AdapterFrame:
346
+ with self._sync_io_lock:
347
+ return self._read_detailed_future(
348
+ timeout=timeout, stop_conditions=stop_conditions, scope=scope
349
+ ).result(self.WorkerTimeout.READ.value)
570
350
 
571
- if read_timeout is None:
572
- raise RuntimeError("Cannot read without setting a timeout")
573
-
574
- if stop_conditions is not ...:
575
- if isinstance(stop_conditions, StopCondition):
576
- stop_conditions = [stop_conditions]
577
- self._make_backend_request(Action.SET_STOP_CONDITIONs, stop_conditions)
578
-
579
- # First, we check if data is in the buffer and if the scope if set to BUFFERED
580
- while _scope == ReadScope.BUFFERED and self._signal_queue.has_read_payload():
581
- signal = self._signal_queue.get()
582
- if isinstance(signal, AdapterReadPayload):
583
- output_signal = signal
584
- break
585
- # TODO : Implement disconnect ?
586
- else:
587
- # Nothing was found, ask the backend with a START_READ request. The backend will
588
- # respond at most after the response_time with either data or a RESPONSE_TIMEOUT
589
-
590
- if not read_timeout.is_initialized():
591
- raise RuntimeError("Timeout needs to be initialized")
592
-
593
- _response = read_timeout.response()
594
-
595
- read_init_time = time.time()
596
- start_read_id = self._make_backend_request(Action.START_READ, _response)[0]
597
-
598
- if _response is None:
599
- # Wait indefinitely
600
- read_stop_timestamp = None
601
- else:
602
- # Wait for the response time + a bit more
603
- read_stop_timestamp = read_init_time + _response
604
-
605
- while True:
606
- try:
607
- if read_stop_timestamp is None:
608
- queue_timeout = None
609
- else:
610
- queue_timeout = max(
611
- 0,
612
- read_stop_timestamp
613
- - time.time()
614
- + EXTRA_BUFFER_RESPONSE_TIME,
615
- )
616
-
617
- signal = self._signal_queue.get(timeout=queue_timeout)
618
- except queue.Empty as e:
619
- raise BackendCommunicationError(
620
- "Failed to receive response from backend"
621
- ) from e
622
- if isinstance(signal, AdapterReadPayload):
623
- output_signal = signal
624
- break
625
- if isinstance(signal, AdapterDisconnectedSignal):
626
- raise AdapterDisconnected()
627
- if isinstance(signal, AdapterResponseTimeout):
628
- if start_read_id == signal.identifier:
629
- output_signal = None
630
- break
631
- # Otherwise ignore it
632
-
633
- if output_signal is None:
634
- match read_timeout.action:
635
- case TimeoutAction.RETURN_EMPTY:
636
- t = time.time()
637
- return AdapterReadPayload(
638
- fragments=[Fragment(b"", t)],
639
- stop_timestamp=t,
640
- stop_condition_type=StopConditionType.TIMEOUT,
641
- previous_read_buffer_used=False,
642
- response_timestamp=None,
643
- response_delay=None,
644
- )
645
- case TimeoutAction.ERROR:
646
- timeout_value = read_timeout.response()
647
- raise AdapterTimeoutError(
648
- float("nan") if timeout_value is None else timeout_value
649
- )
650
- case _:
651
- raise NotImplementedError()
351
+ async def aread_detailed(
352
+ self,
353
+ timeout: Timeout | EllipsisType | None = ...,
354
+ stop_conditions: StopCondition | EllipsisType | list[StopCondition] = ...,
355
+ scope: str = ReadScope.BUFFERED.value,
356
+ ) -> AdapterFrame:
357
+ async with self._async_io_lock:
358
+ return await asyncio.wrap_future(
359
+ self._read_detailed_future(
360
+ timeout=timeout, stop_conditions=stop_conditions, scope=scope
361
+ )
362
+ )
652
363
 
653
- else:
654
- return output_signal
364
+ # ==== read ====
655
365
 
656
366
  def read(
657
367
  self,
658
368
  timeout: Timeout | EllipsisType | None = ...,
659
369
  stop_conditions: StopCondition | EllipsisType | list[StopCondition] = ...,
370
+ scope: str = ReadScope.BUFFERED.value,
660
371
  ) -> bytes:
661
- signal = self.read_detailed(timeout=timeout, stop_conditions=stop_conditions)
662
- return signal.data()
663
-
664
- def _cleanup(self) -> None:
665
- if self._opened:
666
- self.close()
372
+ frame = self.read_detailed(
373
+ timeout=timeout, stop_conditions=stop_conditions, scope=scope
374
+ )
375
+ return frame.get_payload()
667
376
 
668
- def query_detailed(
377
+ async def aread(
669
378
  self,
670
- data: bytes | str,
671
379
  timeout: Timeout | EllipsisType | None = ...,
672
380
  stop_conditions: StopCondition | EllipsisType | list[StopCondition] = ...,
673
- ) -> AdapterReadPayload:
381
+ scope: str = ReadScope.BUFFERED.value,
382
+ ) -> bytes:
383
+ frame = await self.aread_detailed(
384
+ timeout=timeout, stop_conditions=stop_conditions, scope=scope
385
+ )
386
+ return frame.get_payload()
387
+
388
+ # ==== flush_read ====
389
+
390
+ def _flush_read_future(self) -> FlushReadCommand:
391
+ cmd = FlushReadCommand()
392
+ self._worker_send_command(cmd)
393
+ return cmd
394
+
395
+ async def aflush_read(self) -> None:
396
+ """
397
+ Clear buffered completed frames and reset current fragment assembly (async)
674
398
  """
675
- Shortcut function that combines
676
- - flush_read
677
- - write
678
- - read
399
+ async with self._async_io_lock:
400
+ await asyncio.wrap_future(self._flush_read_future())
401
+
402
+ def flush_read(self) -> None:
403
+ """
404
+ Clear buffered completed frames and reset current fragment assembly (blocking)
679
405
  """
680
- self.flush_read()
681
- self.write(data)
682
- return self.read_detailed(timeout=timeout, stop_conditions=stop_conditions)
406
+ with self._sync_io_lock:
407
+ self._flush_read_future().result(self.WorkerTimeout.IMMEDIATE_COMMAND.value)
408
+
409
+ # ==== write ====
410
+
411
+ def _write_future(self, data: bytes | str) -> WriteCommand:
412
+ if isinstance(data, str):
413
+ data = data.encode(self.encoding)
414
+ cmd = WriteCommand(data)
415
+ self._worker_send_command(cmd)
416
+ return cmd
417
+
418
+ def write(self, data: bytes | str) -> None:
419
+ with self._sync_io_lock:
420
+ self._write_future(data).result(self.WorkerTimeout.WRITE.value)
421
+
422
+ async def awrite(self, data: bytes | str) -> None:
423
+ async with self._async_io_lock:
424
+ await asyncio.wrap_future(self._write_future(data))
425
+
426
+ # ==== query ====
683
427
 
684
- def query(
428
+ async def aquery_detailed(
685
429
  self,
686
- data: bytes | str,
687
- timeout: Timeout | EllipsisType | None = ...,
430
+ payload: bytes,
431
+ timeout: Timeout | None | EllipsisType = ...,
688
432
  stop_conditions: StopCondition | EllipsisType | list[StopCondition] = ...,
689
- ) -> bytes:
690
- signal = self.query_detailed(
691
- data=data, timeout=timeout, stop_conditions=stop_conditions
692
- )
693
- return signal.data()
433
+ scope: str = ReadScope.BUFFERED.value,
434
+ ) -> AdapterFrame:
435
+ async with self._async_io_lock:
436
+ await self.aflush_read()
437
+ await self.awrite(payload)
438
+ return await self.aread_detailed(
439
+ timeout=timeout, stop_conditions=stop_conditions, scope=scope
440
+ )
441
+
442
+ def query_detailed(
443
+ self,
444
+ payload: bytes,
445
+ timeout: Timeout | None | EllipsisType = ...,
446
+ stop_conditions: StopCondition | EllipsisType | list[StopCondition] = ...,
447
+ scope: str = ReadScope.BUFFERED.value,
448
+ ) -> AdapterFrame:
694
449
 
695
- def set_event_callback(self, callback: Callable[[AdapterSignal], None]) -> None:
696
- self.event_callback = callback
450
+ with self._sync_io_lock:
451
+ self.flush_read()
452
+ self.write(payload)
453
+ return self.read_detailed(
454
+ timeout=timeout, stop_conditions=stop_conditions, scope=scope
455
+ )
697
456
 
698
- def __str__(self) -> str:
699
- return str(self.descriptor)
457
+ # ==== Other ====
700
458
 
701
- def __repr__(self) -> str:
702
- return self.__str__()
459
+ def _is_open_future(self) -> IsOpenCommand:
460
+ cmd = IsOpenCommand()
461
+ self._worker_send_command(cmd)
462
+ return cmd
463
+
464
+ def is_open(self) -> bool:
465
+ """Check if the adapter is open"""
466
+ return self._is_open_future().result(self.WorkerTimeout.IMMEDIATE_COMMAND.value)
467
+
468
+ async def ais_open(self) -> bool:
469
+ """Asynchronously check if the adapter is open"""
470
+ return await asyncio.wrap_future(self._is_open_future())