syndesi 0.5.0__py3-none-any.whl → 0.5.1__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.
@@ -0,0 +1,229 @@
1
+ # File : tracehub.py
2
+ # Author : Sébastien Deriaz
3
+ # License : GPL
4
+ """
5
+ Trace module
6
+
7
+ A single global instance of TraceHub allows all the syndesi modules and workers
8
+ to emit trace events
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import socket
15
+ import struct
16
+ import time
17
+ from dataclasses import asdict, dataclass, field
18
+ from typing import Any
19
+
20
+ from syndesi.adapters.stop_conditions import StopConditionType
21
+
22
+ STOP_CONDITION_INDICATOR = {
23
+ StopConditionType.CONTINUATION : "Cont",
24
+ StopConditionType.FRAGMENT : "Frag",
25
+ StopConditionType.LENGTH : "Len",
26
+ StopConditionType.TERMINATION : "Term",
27
+ StopConditionType.TOTAL : "Tot",
28
+ StopConditionType.TIMEOUT : "Time"
29
+ }
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class TraceEvent:
34
+ """
35
+ Base trace event
36
+ """
37
+ descriptor : str
38
+ timestamp : float
39
+ t : str = field(default="", init=False)
40
+
41
+ @dataclass(frozen=True)
42
+ class OpenEvent(TraceEvent):
43
+ """
44
+ Adapter open trace event
45
+ """
46
+ t : str = field(default="open", init=False)
47
+
48
+ @dataclass(frozen=True)
49
+ class FragmentEvent(TraceEvent):
50
+ """
51
+ Fragment received trace event
52
+ """
53
+ data : str
54
+ length : int
55
+ t : str = field(default="fragment", init=False)
56
+
57
+ @dataclass(frozen=True)
58
+ class CloseEvent(TraceEvent):
59
+ """
60
+ Adapter close trace event
61
+ """
62
+ t : str = field(default="close", init=False)
63
+
64
+ @dataclass(frozen=True)
65
+ class ReadEvent(TraceEvent):
66
+ """
67
+ Adapter read trace event
68
+ """
69
+ data : str
70
+ t : str = field(default="read", init=False)
71
+ length : int
72
+ stop_condition_indicator : str
73
+
74
+ @dataclass(frozen=True)
75
+ class WriteEvent(TraceEvent):
76
+ """
77
+ Adapter write trace event
78
+ """
79
+ data : str
80
+ length : int
81
+ t : str = field(default="write", init=False)
82
+
83
+ EVENTS : list[type[TraceEvent]] = [FragmentEvent, OpenEvent, CloseEvent, ReadEvent, WriteEvent]
84
+
85
+ EVENTS_MAP : dict[str, type[TraceEvent]]= {
86
+ e.t : e for e in EVENTS
87
+ }
88
+
89
+ DEFAULT_MULTICAST_GROUP = "239.255.42.99"
90
+ DEFAULT_MULTICAST_PORT = 12000
91
+
92
+ def json_to_trace_event(payload : dict[str, Any]) -> TraceEvent:
93
+ """
94
+ Convert json data to TraceEvent
95
+ """
96
+ payload_type = payload.get("t", None)
97
+ if payload_type in EVENTS_MAP:
98
+ arguments = payload.copy()
99
+ arguments.pop("t")
100
+ return EVENTS_MAP[payload_type](**arguments)
101
+
102
+ raise ValueError(f"Could not parse payload : {payload}")
103
+
104
+ class _TraceHub:
105
+ TTL = 1
106
+ LOOPBACK = True
107
+ TRUNCATE_LENGTH = 50
108
+
109
+ _udp_addr = (DEFAULT_MULTICAST_GROUP, DEFAULT_MULTICAST_PORT)
110
+
111
+ TRUNCATION_TERMINATION = "..."
112
+ # CHARACTERS_REPLACEMENT = {
113
+ # '\n' : '\\n',
114
+ # '\r' : '\\r',
115
+ # '\t' : '\t',
116
+
117
+ # }
118
+
119
+ def __init__(self) -> None:
120
+ #self._udp_sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, 1)
121
+ #self._udp_sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 1)
122
+
123
+ self._udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
124
+ self._udp_sock.setblocking(False)
125
+ self._udp_sock.setsockopt(
126
+ socket.IPPROTO_IP,
127
+ socket.IP_MULTICAST_IF,
128
+ socket.inet_aton("127.0.0.1")
129
+ )
130
+ self._udp_sock.setsockopt(
131
+ socket.IPPROTO_IP,
132
+ socket.IP_MULTICAST_TTL,
133
+ struct.pack("b", self.TTL)
134
+ )
135
+ self._udp_sock.setsockopt(
136
+ socket.IPPROTO_IP,
137
+ socket.IP_MULTICAST_LOOP,
138
+ struct.pack("b", 1 if self.LOOPBACK else 0)
139
+ )
140
+ self._udp_dropped = 0
141
+
142
+ def emit_open(self, descriptor : str) -> None:
143
+ """
144
+ Emit an open trace event
145
+ """
146
+ self._emit_event(OpenEvent(descriptor, time.time()))
147
+
148
+ def emit_close(self, descriptor : str) -> None:
149
+ """
150
+ Emit a close trace event
151
+ """
152
+ self._emit_event(CloseEvent(descriptor, time.time()))
153
+
154
+ def _format_data(self, data : bytes) -> str:
155
+ if len(data) > 4*self.TRUNCATE_LENGTH:
156
+ # Pre-truncate to avoid working with super long data
157
+ data = data[:4*self.TRUNCATE_LENGTH]
158
+
159
+ str_data = repr(data)[2:-1]
160
+
161
+ truncated_str = str_data[:self.TRUNCATE_LENGTH]
162
+
163
+ if len(str_data) != len(truncated_str):
164
+ return truncated_str[:-len(self.TRUNCATION_TERMINATION)] + self.TRUNCATION_TERMINATION
165
+ return truncated_str
166
+
167
+ def emit_fragment(self, descriptor : str, data : bytes) -> None:
168
+ """
169
+ Emit a fragment trace event
170
+ """
171
+ self._emit_event(FragmentEvent(
172
+ descriptor,
173
+ time.time(),
174
+ self._format_data(data),
175
+ len(data)
176
+ ))
177
+
178
+ def emit_write(self, descriptor : str, data : bytes) -> None:
179
+ """
180
+ Emit a write trace event
181
+ """
182
+ self._emit_event(WriteEvent(
183
+ descriptor,
184
+ time.time(),
185
+ self._format_data(data),
186
+ len(data)
187
+ ))
188
+
189
+ def emit_read(
190
+ self,
191
+ descriptor : str,
192
+ data : bytes,
193
+ stop_condition_type : StopConditionType
194
+ ) -> None:
195
+ """
196
+ Emit a read trace event
197
+ """
198
+
199
+ indicator = STOP_CONDITION_INDICATOR[stop_condition_type]
200
+
201
+ self._emit_event(ReadEvent(
202
+ descriptor,
203
+ time.time(),
204
+ self._format_data(data),
205
+ len(data),
206
+ indicator
207
+ ))
208
+
209
+ def _emit_event(self, ev: TraceEvent) -> None:
210
+ d = asdict(ev)
211
+ # meta = d.setdefault("meta", {})
212
+ # if isinstance(meta, dict):
213
+ # meta.setdefault("pid", os.getpid())
214
+
215
+ payload = json.dumps(d, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
216
+
217
+ # if len(payload) > self._udp_max:
218
+ # if isinstance(meta, dict):
219
+ # meta["truncated"] = True
220
+ # payload = payload[: self._udp_max]
221
+
222
+ try:
223
+ self._udp_sock.sendto(payload, self._udp_addr)
224
+ except (BlockingIOError, InterruptedError, OSError):
225
+ # drop rather than blocking I/O paths
226
+ self._udp_dropped += 1
227
+
228
+ # Public singleton
229
+ tracehub = _TraceHub()
syndesi/adapters/visa.py CHANGED
@@ -15,9 +15,12 @@ from collections.abc import Callable
15
15
  from dataclasses import dataclass
16
16
  from enum import Enum
17
17
  from types import EllipsisType
18
+ from typing import cast
19
+
20
+ import pyvisa
21
+ from pyvisa.resources import MessageBasedResource
18
22
 
19
23
  from syndesi.adapters.adapter_worker import (
20
- AdapterDisconnectedEvent,
21
24
  AdapterEvent,
22
25
  HasFileno,
23
26
  )
@@ -28,13 +31,20 @@ from syndesi.tools.errors import AdapterReadError
28
31
  from .adapter import Adapter
29
32
  from .timeout import Timeout
30
33
 
31
- # --- Runtime optional import
32
- try:
33
- import pyvisa # type: ignore
34
- from pyvisa.resources import Resource # type: ignore
35
- except ImportError:
36
- pyvisa = None
37
- Resource = None
34
+
35
+ class QueueEvent:
36
+ """VISA adapter queue event"""
37
+
38
+
39
+ class DisconnectedEvent(QueueEvent):
40
+ """VISA queue disconnected event"""
41
+
42
+
43
+ @dataclass
44
+ class FragmentEvent(QueueEvent):
45
+ """VISA queue new fragment event"""
46
+
47
+ fragment: Fragment
38
48
 
39
49
 
40
50
  @dataclass
@@ -100,6 +110,8 @@ class Visa(Adapter):
100
110
  It uses pyvisa under the hood
101
111
  """
102
112
 
113
+ THREAD_STOP_DELAY = 0.2
114
+
103
115
  def __init__(
104
116
  self,
105
117
  descriptor: str,
@@ -109,21 +121,12 @@ class Visa(Adapter):
109
121
  timeout: None | float | Timeout | EllipsisType = ...,
110
122
  encoding: str = "utf-8",
111
123
  event_callback: Callable[[AdapterEvent], None] | None = None,
124
+ auto_open: bool = False,
112
125
  ) -> None:
113
- super().__init__(
114
- descriptor=VisaDescriptor.from_string(descriptor),
115
- alias=alias,
116
- stop_conditions=stop_conditions,
117
- timeout=timeout,
118
- encoding=encoding,
119
- event_callback=event_callback,
120
- )
121
126
 
122
127
  self._worker_descriptor: VisaDescriptor
123
128
  self._descriptor: VisaDescriptor
124
129
 
125
- self._logger.info("Setting up VISA IP adapter")
126
-
127
130
  if pyvisa is None:
128
131
  raise ImportError(
129
132
  "Missing optional dependency 'pyvisa'. Install with:\n"
@@ -131,7 +134,9 @@ class Visa(Adapter):
131
134
  )
132
135
 
133
136
  self._rm = pyvisa.ResourceManager()
134
- self._inst: Resource | None = None # annotation only; no runtime import needed
137
+ self._inst: MessageBasedResource | None = (
138
+ None # annotation only; no runtime import needed
139
+ )
135
140
 
136
141
  # We need a socket pair because VISA doesn't expose a selectable fileno/socket
137
142
  # So we create a thread to read data and push that to the socket
@@ -142,18 +147,19 @@ class Visa(Adapter):
142
147
  self._stop_lock = threading.Lock()
143
148
  self.stop = False
144
149
 
145
- self._fragment_lock = threading.Lock()
146
- self._fragment: Fragment | None = None
147
- self._event_queue: queue.Queue[AdapterEvent] = queue.Queue()
150
+ self._event_queue: queue.Queue[QueueEvent] = queue.Queue()
148
151
 
149
- self._thread = threading.Thread(
150
- target=self._internal_thread,
151
- args=(self._inst, self._event_queue),
152
- daemon=True,
153
- )
154
- self._thread.start()
152
+ self._thread: threading.Thread | None = None
155
153
 
156
- self._inst_lock = threading.Lock()
154
+ super().__init__(
155
+ descriptor=VisaDescriptor.from_string(descriptor),
156
+ alias=alias,
157
+ stop_conditions=stop_conditions,
158
+ timeout=timeout,
159
+ encoding=encoding,
160
+ event_callback=event_callback,
161
+ auto_open=auto_open,
162
+ )
157
163
 
158
164
  def _default_timeout(self) -> Timeout:
159
165
  return Timeout(response=5, action="error")
@@ -186,83 +192,98 @@ class Visa(Adapter):
186
192
  return available_resources
187
193
 
188
194
  def _worker_close(self) -> None:
189
- with self._inst_lock:
190
- if self._inst is not None:
191
- self._inst.close()
192
- self._opened = False
195
+ super()._worker_close()
196
+ # with self._inst_lock:
197
+ # Stop the thread
198
+ if self._thread is not None:
193
199
  with self._stop_lock:
194
200
  self.stop = True
201
+ self._thread.join(timeout=self.THREAD_STOP_DELAY)
202
+
203
+ # if self._inst is not None:
204
+ # self._inst.close()
205
+ self._opened = False
195
206
 
196
207
  def _worker_write(self, data: bytes) -> None:
208
+ super()._worker_write(data)
197
209
  # TODO : Add try around write
198
- with self._inst_lock:
199
- if self._inst is not None:
200
- self._inst.write_raw(data)
210
+ # TODO : We assume that the instance is thread safe because
211
+ # it would slow things down to have a lock because the internal thread
212
+ # would release it every cycle (50ms)
213
+
214
+ # with self._inst_lock:
215
+ if self._inst is not None:
216
+ self._inst.write_raw(data)
201
217
 
202
218
  def _worker_read(self, fragment_timestamp: float) -> Fragment:
203
219
  self._notify_recv.recv(1)
204
- if not self._event_queue.empty():
205
- event = self._event_queue.get()
206
- if isinstance(event, AdapterDisconnectedEvent):
207
- # return Fragment(b"", fragment_timestamp)
208
- raise AdapterReadError("Could not read from adapter")
209
-
210
- with self._fragment_lock:
211
- if self._fragment is None:
212
- raise AdapterReadError("Invalid fragment")
213
- output = self._fragment
214
- self._fragment = None
215
- return output
220
+ event = self._event_queue.get(block=False, timeout=None)
221
+
222
+ if isinstance(event, DisconnectedEvent):
223
+ # Signal that the adapter disconnected
224
+ return Fragment(b"", fragment_timestamp)
225
+ if isinstance(event, FragmentEvent):
226
+ return event.fragment
227
+
228
+ raise AdapterReadError("Invalid queue event")
216
229
 
217
230
  def _worker_open(self) -> None:
231
+ super()._worker_open()
218
232
  self._worker_check_descriptor()
219
233
 
220
- if self._inst is None:
221
- # NOTE: self._rm is always defined in __init__ when pyvisa is present
222
- self._inst = self._rm.open_resource(self._worker_descriptor.descriptor)
234
+ if self._thread is not None:
235
+ self.close()
223
236
 
224
- if not self._opened:
225
- # These attributes exist on pyvisa resources
226
- self._inst.write_termination = ""
227
- self._inst.read_termination = None
228
- self._opened = True
237
+ # if self._inst is None:
238
+ # NOTE: self._rm is always defined in __init__ when pyvisa is present
229
239
 
230
- # TODO : Tell the thread to open
240
+ self._inst = cast(
241
+ MessageBasedResource,
242
+ self._rm.open_resource(self._worker_descriptor.descriptor),
243
+ )
244
+ self._inst.write_termination = ""
245
+ self._inst.read_termination = None
231
246
 
232
- def _internal_thread(
233
- self,
234
- inst: Resource,
235
- event_queue: queue.Queue[AdapterEvent],
236
- ) -> None:
237
- timeout = 2000
247
+ self._opened = True
248
+
249
+ self._thread = threading.Thread(
250
+ target=self._internal_thread,
251
+ args=(self._inst,),
252
+ daemon=True,
253
+ )
254
+ self._thread.start()
255
+
256
+ def _internal_thread(self, instance: MessageBasedResource) -> None:
257
+ timeout = 50e-3
238
258
  while True:
239
259
  payload = b""
240
- with self._fragment_lock:
241
- self._fragment = None # Fragment(b"", None)
260
+ fragment: Fragment | None = None
242
261
  try:
243
- inst.timeout = timeout
262
+ instance.timeout = timeout
244
263
  except pyvisa.InvalidSession:
245
- pass
264
+ return
246
265
  try:
247
266
  while True:
248
267
  # Read up to an error
249
- payload += inst.read_bytes(1)
250
- inst.timeout = 0
268
+ payload += instance.read_bytes(1) # TODO : Maybe test with read_raw
269
+ instance.timeout = 0
251
270
  except pyvisa.VisaIOError:
252
271
  # Timeout
253
272
  if payload:
254
- with self._fragment_lock:
255
- if self._fragment is None:
256
- self._fragment = Fragment(payload, time.time())
257
- else:
258
- self._fragment.data += payload
273
+ if fragment is None:
274
+ fragment = Fragment(payload, time.time())
275
+ else:
276
+ fragment.data += payload
259
277
  # Tell the session that there's data (write to a virtual socket)
278
+ self._event_queue.put(FragmentEvent(fragment))
260
279
  self._notify_send.send(b"1")
261
280
  except (TypeError, pyvisa.InvalidSession, BrokenPipeError):
262
- event_queue.put(AdapterDisconnectedEvent())
281
+ self._event_queue.put(DisconnectedEvent())
263
282
  self._notify_send.send(b"1")
283
+
264
284
  with self._stop_lock:
265
285
  if self.stop:
286
+ instance.close()
266
287
  break
267
288
 
268
289
  def _selectable(self) -> HasFileno | None:
syndesi/cli/shell.py CHANGED
@@ -128,10 +128,9 @@ class AdapterShell:
128
128
  self._parser.add_argument(
129
129
  "-t",
130
130
  "--timeout",
131
- nargs="+",
132
131
  type=float,
133
132
  required=False,
134
- default=[2],
133
+ default=2.0,
135
134
  help="Adapter timeout (response)",
136
135
  )
137
136
  self._parser.add_argument(
@@ -202,7 +201,9 @@ class AdapterShell:
202
201
  auto_open=False,
203
202
  )
204
203
  elif kind == AdapterType.VISA:
205
- self.adapter = Visa(descriptor=args.descriptor, timeout=timeout)
204
+ self.adapter = Visa(
205
+ descriptor=args.descriptor, timeout=timeout, auto_open=False
206
+ )
206
207
 
207
208
  self.adapter.set_default_timeout(Timeout(action="return_empty"))
208
209
 
@@ -245,7 +246,8 @@ class AdapterShell:
245
246
  else:
246
247
  self.shell.run()
247
248
  self.shell.print(
248
- f"Opened adapter {self.adapter.descriptor}", style=Shell.Style.NOTE
249
+ f"Opened adapter {self.adapter.get_descriptor()}",
250
+ style=Shell.Style.NOTE,
249
251
  )
250
252
 
251
253
  def on_command(self, command: str) -> None:
@@ -276,5 +278,4 @@ class AdapterShell:
276
278
  )
277
279
  elif isinstance(event, ProtocolFrameEvent):
278
280
  data = event.frame.get_payload()
279
- # TODO : Catch data from delimited with formatting
280
281
  self.shell.print(data)
syndesi/component.py CHANGED
@@ -57,7 +57,7 @@ class Frame(Generic[T]):
57
57
  """
58
58
 
59
59
  stop_timestamp: float | None
60
- stop_condition_type: StopConditionType | None # None if it's a response timeout
60
+ stop_condition_type: StopConditionType
61
61
  previous_read_buffer_used: bool
62
62
  response_delay: float | None
63
63
 
@@ -133,7 +133,7 @@ class Component(ABC, Generic[T]):
133
133
  """
134
134
 
135
135
  def __init__(self, logger_alias: LoggerAlias) -> None:
136
- super().__init__()
136
+ #super().__init__()
137
137
  self._logger = logging.getLogger(logger_alias.value)
138
138
 
139
139
  # ==== open ====
@@ -9,23 +9,11 @@ command-like formats with specified delimiters (like \\n, \\r, \\r\\n, etc...)
9
9
  from collections.abc import Callable
10
10
  from types import EllipsisType
11
11
 
12
- from syndesi.adapters.adapter_worker import (
13
- AdapterDisconnectedEvent,
14
- AdapterEvent,
15
- AdapterFrameEvent,
16
- )
17
-
18
12
  from ..adapters.adapter import Adapter
19
13
  from ..adapters.stop_conditions import StopCondition, Termination
20
14
  from ..adapters.timeout import Timeout
21
15
  from ..component import AdapterFrame, ReadScope
22
- from .protocol import (
23
- Protocol,
24
- ProtocolDisconnectedEvent,
25
- ProtocolEvent,
26
- ProtocolFrame,
27
- ProtocolFrameEvent,
28
- )
16
+ from .protocol import Protocol, ProtocolEvent, ProtocolFrame
29
17
 
30
18
 
31
19
  class DelimitedFrame(ProtocolFrame[str]):
@@ -70,7 +58,10 @@ class Delimited(Protocol[str]):
70
58
  event_callback: Callable[[ProtocolEvent], None] | None = None,
71
59
  receive_termination: str | None = None,
72
60
  ) -> None:
73
- if not isinstance(termination, str) or isinstance(termination, bytes):
61
+ self._encoding = encoding
62
+ if isinstance(termination, bytes):
63
+ termination = termination.decode(self._encoding)
64
+ elif not isinstance(termination, str):
74
65
  raise ValueError(
75
66
  f"end argument must be of type str or bytes, not {type(termination)}"
76
67
  )
@@ -79,7 +70,6 @@ class Delimited(Protocol[str]):
79
70
  else:
80
71
  self._receive_termination = receive_termination
81
72
  self._termination = termination
82
- self._encoding = encoding
83
73
  self._response_formatting = format_response
84
74
 
85
75
  adapter.set_stop_conditions(
@@ -89,8 +79,6 @@ class Delimited(Protocol[str]):
89
79
 
90
80
  self._adapter.set_event_callback(self._on_event)
91
81
 
92
- # TODO : Disable encoding/decoding when encoding==None
93
-
94
82
  def __str__(self) -> str:
95
83
  if self._receive_termination == self._termination:
96
84
  return f"Delimited({self._adapter},{repr(self._termination)})"
@@ -153,16 +141,16 @@ class Delimited(Protocol[str]):
153
141
  )
154
142
  return frame.get_payload()
155
143
 
156
- def _on_event(self, event: AdapterEvent) -> None:
144
+ # def _on_event(self, event: AdapterEvent) -> None:
157
145
 
158
- if self._event_callback is not None:
159
- output_event: ProtocolEvent | None = None
160
- if isinstance(event, AdapterDisconnectedEvent):
161
- output_event = ProtocolDisconnectedEvent()
162
- if isinstance(event, AdapterFrameEvent):
163
- output_event = ProtocolFrameEvent(
164
- frame=self._adapter_to_protocol(event.frame)
165
- )
146
+ # if self._event_callback is not None:
147
+ # output_event: ProtocolEvent | None = None
148
+ # if isinstance(event, AdapterDisconnectedEvent):
149
+ # output_event = ProtocolDisconnectedEvent()
150
+ # if isinstance(event, AdapterFrameEvent):
151
+ # output_event = ProtocolFrameEvent(
152
+ # frame=self._adapter_to_protocol(event.frame)
153
+ # )
166
154
 
167
- if output_event is not None:
168
- self._event_callback(output_event)
155
+ # if output_event is not None:
156
+ # self._event_callback(output_event)
@@ -43,7 +43,6 @@ from math import ceil
43
43
  from types import EllipsisType
44
44
  from typing import cast
45
45
 
46
- from syndesi.adapters.adapter_worker import AdapterEvent
47
46
  from syndesi.component import AdapterFrame
48
47
 
49
48
  from ..adapters.adapter import Adapter
@@ -1422,8 +1421,6 @@ class Modbus(Protocol[ModbusSDU]):
1422
1421
  def _default_timeout(self) -> Timeout | None:
1423
1422
  return Timeout(response=1, action="error")
1424
1423
 
1425
- def _on_event(self, event: AdapterEvent) -> None: ...
1426
-
1427
1424
  def _protocol_to_adapter(self, protocol_payload: ModbusSDU) -> bytes:
1428
1425
  if isinstance(protocol_payload, SerialLineOnlySDU):
1429
1426
  if self._modbus_type == ModbusType.TCP: