cgse-common 0.16.14__py3-none-any.whl → 0.17.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.
egse/socketdevice.py CHANGED
@@ -2,29 +2,52 @@
2
2
  This module defines base classes and generic functions to work with sockets.
3
3
  """
4
4
 
5
+ import asyncio
6
+ import select
5
7
  import socket
8
+ import time
9
+ from typing import Optional
6
10
 
11
+ from egse.device import AsyncDeviceInterface
12
+ from egse.device import AsyncDeviceTransport
7
13
  from egse.device import DeviceConnectionError
8
14
  from egse.device import DeviceConnectionInterface
9
15
  from egse.device import DeviceTimeoutError
10
16
  from egse.device import DeviceTransport
11
17
  from egse.log import logger
18
+ from egse.system import type_name
12
19
 
13
20
 
14
21
  class SocketDevice(DeviceConnectionInterface, DeviceTransport):
15
22
  """Base class that implements the socket interface."""
16
23
 
17
- def __init__(self, hostname: str, port: int):
24
+ # We set a default connect timeout of 3.0 sec before connecting and reset
25
+ # to None (=blocking) after connecting. The reason for this is that when no
26
+ # device is available, e.g. during testing, the timeout will take about
27
+ # two minutes which is way too long. It needs to be evaluated if this
28
+ # approach is acceptable and not causing problems during production.
29
+
30
+ def __init__(
31
+ self,
32
+ hostname: str,
33
+ port: int,
34
+ connect_timeout: float = 3.0,
35
+ read_timeout: float | None = 1.0,
36
+ separator: str = b"\x03",
37
+ ):
18
38
  super().__init__()
19
39
  self.is_connection_open = False
20
40
  self.hostname = hostname
21
41
  self.port = port
42
+ self.connect_timeout = connect_timeout
43
+ self.read_timeout = read_timeout
44
+ self.separator = separator
22
45
  self.socket = None
23
46
 
24
47
  @property
25
48
  def device_name(self):
26
49
  """The name of the device that this interface connects to."""
27
- raise NotImplementedError
50
+ return f"SocketDevice({self.hostname}:{self.port})"
28
51
 
29
52
  def connect(self):
30
53
  """
@@ -53,18 +76,12 @@ class SocketDevice(DeviceConnectionInterface, DeviceTransport):
53
76
 
54
77
  try:
55
78
  self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
56
- except socket.error as e_socket:
57
- raise ConnectionError(f"{self.device_name}: Failed to create socket.") from e_socket
58
-
59
- # We set a timeout of 3 sec before connecting and reset to None
60
- # (=blocking) after the connect. The reason for this is because when no
61
- # device is available, e.g during testing, the timeout will take about
62
- # two minutes which is way too long. It needs to be evaluated if this
63
- # approach is acceptable and not causing problems during production.
79
+ except socket.error as exc:
80
+ raise ConnectionError(f"{self.device_name}: Failed to create socket.") from exc
64
81
 
65
82
  try:
66
83
  logger.debug(f'Connecting a socket to host "{self.hostname}" using port {self.port}')
67
- self.socket.settimeout(3)
84
+ self.socket.settimeout(self.connect_timeout)
68
85
  self.socket.connect((self.hostname, self.port))
69
86
  self.socket.settimeout(None)
70
87
  except ConnectionRefusedError as exc:
@@ -120,32 +137,64 @@ class SocketDevice(DeviceConnectionInterface, DeviceTransport):
120
137
 
121
138
  def read(self) -> bytes:
122
139
  """
123
-
124
- Returns:
125
- A bytes object containing the received telemetry.
140
+ Read until ETX (b'\x03') or until `self.read_timeout` elapses.
141
+ Uses `select` to avoid blocking indefinitely when no data is available.
142
+ If `self.read_timeout` was set to None in the constructor, this will block anyway.
126
143
  """
127
- idx, n_total = 0, 0
144
+ if not self.socket:
145
+ raise DeviceConnectionError(self.device_name, "Not connected")
146
+
128
147
  buf_size = 1024 * 4
129
- response = bytes()
148
+ response = bytearray()
149
+
150
+ # If read_timeout is None we preserve blocking behaviour; otherwise enforce overall timeout.
151
+ if self.read_timeout is None:
152
+ end_time = None
153
+ else:
154
+ end_time = time.monotonic() + self.read_timeout
130
155
 
131
156
  try:
132
- for idx in range(100):
133
- # time.sleep(0.1) # Give the device time to fill the buffer
134
- data = self.socket.recv(buf_size)
135
- n = len(data)
136
- n_total += n
137
- response += data
138
- # if n < buf_size:
139
- # break # there is not more data in the buffer
140
- if b"\x03" in response:
157
+ while True:
158
+ # compute remaining timeout for select, this is needed because we read in different parts
159
+ # until ETX is received, and we want to receive the complete messages including ETX within
160
+ # the read timeout.
161
+ if end_time is None:
162
+ timeout = None
163
+ else:
164
+ remaining = end_time - time.monotonic()
165
+ if remaining <= 0.0:
166
+ raise DeviceTimeoutError(self.device_name, "Socket read timed out")
167
+ timeout = remaining
168
+
169
+ ready, _, _ = select.select([self.socket], [], [], timeout)
170
+
171
+ if not ready:
172
+ # no socket ready within timeout
173
+ raise DeviceTimeoutError(self.device_name, "Socket read timed out")
174
+
175
+ try:
176
+ data = self.socket.recv(buf_size)
177
+ except OSError as exc:
178
+ raise DeviceConnectionError(self.device_name, f"Caught {type_name(exc)}: {exc}") from exc
179
+
180
+ if not data:
181
+ # remote closed connection (EOF)
182
+ raise DeviceConnectionError(self.device_name, "Connection closed by peer")
183
+
184
+ response.extend(data)
185
+
186
+ if self.separator in response:
141
187
  break
142
- except socket.timeout as e_timeout:
143
- logger.warning(f"Socket timeout error from {e_timeout}")
144
- raise DeviceTimeoutError(self.device_name, "Socket timeout error") from e_timeout
145
188
 
146
- # logger.debug(f"Total number of bytes received is {n_total}, idx={idx}")
189
+ except DeviceTimeoutError:
190
+ raise
191
+ except DeviceConnectionError:
192
+ raise
193
+ except Exception as exc:
194
+ # unexpected errors - translate to DeviceConnectionError
195
+ raise DeviceConnectionError(self.device_name, "Socket read error") from exc
147
196
 
148
- return response
197
+ return bytes(response)
149
198
 
150
199
  def write(self, command: str):
151
200
  """
@@ -163,11 +212,11 @@ class SocketDevice(DeviceConnectionInterface, DeviceTransport):
163
212
 
164
213
  try:
165
214
  self.socket.sendall(command.encode())
166
- except socket.timeout as e_timeout:
167
- raise DeviceTimeoutError(self.device_name, "Socket timeout error") from e_timeout
168
- except socket.error as e_socket:
215
+ except socket.timeout as exc:
216
+ raise DeviceTimeoutError(self.device_name, "Socket timeout error") from exc
217
+ except socket.error as exc:
169
218
  # Interpret any socket-related error as an I/O error
170
- raise DeviceConnectionError(self.device_name, "Socket communication error.") from e_socket
219
+ raise DeviceConnectionError(self.device_name, "Socket communication error.") from exc
171
220
 
172
221
  def trans(self, command: str) -> bytes:
173
222
  """
@@ -198,8 +247,133 @@ class SocketDevice(DeviceConnectionInterface, DeviceTransport):
198
247
 
199
248
  return return_string
200
249
 
201
- except socket.timeout as e_timeout:
202
- raise DeviceTimeoutError(self.device_name, "Socket timeout error") from e_timeout
203
- except socket.error as e_socket:
250
+ except socket.timeout as exc:
251
+ raise DeviceTimeoutError(self.device_name, "Socket timeout error") from exc
252
+ except socket.error as exc:
204
253
  # Interpret any socket-related error as an I/O error
205
- raise DeviceConnectionError(self.device_name, "Socket communication error.") from e_socket
254
+ raise DeviceConnectionError(self.device_name, "Socket communication error.") from exc
255
+
256
+
257
+ class AsyncSocketDevice(AsyncDeviceInterface, AsyncDeviceTransport):
258
+ """
259
+ Async socket-backed device using asyncio streams.
260
+
261
+ - async connect() / disconnect()
262
+ - async read() reads until ETX (b'\\x03') or timeout
263
+ - async write() and async trans()
264
+ """
265
+
266
+ def __init__(
267
+ self,
268
+ hostname: str,
269
+ port: int,
270
+ connect_timeout: float = 3.0,
271
+ read_timeout: float | None = 1.0,
272
+ separator: str = b"\x03",
273
+ ):
274
+ super().__init__()
275
+ self.hostname = hostname
276
+ self.port = port
277
+ self.connect_timeout = connect_timeout
278
+ self.read_timeout = read_timeout
279
+ self.separator = separator
280
+ self.reader: Optional[asyncio.StreamReader] = None
281
+ self.writer: Optional[asyncio.StreamWriter] = None
282
+ self.is_connection_open = False
283
+
284
+ @property
285
+ def device_name(self) -> str:
286
+ # Override this property for a decent name
287
+ return f"AsyncSocketDevice({self.hostname}:{self.port})"
288
+
289
+ async def connect(self) -> None:
290
+ if self.is_connection_open:
291
+ logger.debug(f"{self.device_name}: already connected")
292
+ return
293
+
294
+ if not self.hostname:
295
+ raise ValueError(f"{self.device_name}: hostname is not initialized.")
296
+ if not self.port:
297
+ raise ValueError(f"{self.device_name}: port is not initialized.")
298
+
299
+ try:
300
+ logger.debug(f"{self.device_name}: connect() called; is_connection_open={self.is_connection_open}")
301
+ coro = asyncio.open_connection(self.hostname, self.port)
302
+ self.reader, self.writer = await asyncio.wait_for(coro, timeout=self.connect_timeout)
303
+ self.is_connection_open = True
304
+ logger.debug(f"{self.device_name}: connected -> peer={self.writer.get_extra_info('peername')}")
305
+
306
+ except asyncio.TimeoutError as exc:
307
+ await self._cleanup()
308
+ logger.warning(f"{self.device_name}: connect timed out")
309
+ raise DeviceTimeoutError(self.device_name, f"Connection to {self.hostname}:{self.port} timed out.") from exc
310
+ except Exception as exc:
311
+ await self._cleanup()
312
+ logger.warning(f"{self.device_name}: connect failed: {type_name(exc)} – {exc}")
313
+ raise DeviceConnectionError(self.device_name, f"Failed to connect to {self.hostname}:{self.port}") from exc
314
+
315
+ async def disconnect(self) -> None:
316
+ logger.debug(f"{self.device_name}: disconnect() called; writer_exists={bool(self.writer)}")
317
+ peer = None
318
+ try:
319
+ if self.writer and not self.writer.is_closing():
320
+ peer = self.writer.get_extra_info("peername")
321
+ self.writer.close()
322
+ # wait for close, but don't hang forever
323
+ try:
324
+ await asyncio.wait_for(self.writer.wait_closed(), timeout=1.0)
325
+ except asyncio.TimeoutError:
326
+ logger.debug(f"{self.device_name}: wait_closed() timed out for peer={peer}")
327
+
328
+ finally:
329
+ await self._cleanup()
330
+ logger.debug(f"{self.device_name}: disconnected ({peer=})")
331
+
332
+ def is_connected(self) -> bool:
333
+ return bool(self.is_connection_open and self.writer and not self.writer.is_closing())
334
+
335
+ async def _cleanup(self) -> None:
336
+ self.reader = None
337
+ self.writer = None
338
+ self.is_connection_open = False
339
+
340
+ async def read(self) -> bytes:
341
+ if not self.reader:
342
+ raise DeviceConnectionError(self.device_name, "Not connected")
343
+ try:
344
+ # readuntil includes the separator; we keep it for parity with existing code
345
+ data = await asyncio.wait_for(self.reader.readuntil(separator=self.separator), timeout=self.read_timeout)
346
+ return data
347
+ except asyncio.IncompleteReadError as exc:
348
+ # EOF before separator
349
+ await self._cleanup()
350
+ raise DeviceConnectionError(self.device_name, "Connection closed while reading") from exc
351
+ except asyncio.TimeoutError as exc:
352
+ raise DeviceTimeoutError(self.device_name, "Socket read timed out") from exc
353
+ except Exception as exc:
354
+ await self._cleanup()
355
+ raise DeviceConnectionError(self.device_name, "Socket read error") from exc
356
+
357
+ async def write(self, command: str) -> None:
358
+ if not self.writer:
359
+ raise DeviceConnectionError(self.device_name, "Not connected")
360
+ try:
361
+ self.writer.write(command.encode())
362
+ await asyncio.wait_for(self.writer.drain(), timeout=self.read_timeout)
363
+ except asyncio.TimeoutError as exc:
364
+ raise DeviceTimeoutError(self.device_name, "Socket write timed out") from exc
365
+ except Exception as exc:
366
+ await self._cleanup()
367
+ raise DeviceConnectionError(self.device_name, "Socket write error") from exc
368
+
369
+ async def trans(self, command: str) -> bytes:
370
+ if not self.writer or not self.reader:
371
+ raise DeviceConnectionError(self.device_name, "Not connected")
372
+ try:
373
+ await self.write(command)
374
+ return await self.read()
375
+ except (DeviceTimeoutError, DeviceConnectionError):
376
+ raise
377
+ except Exception as exc:
378
+ await self._cleanup()
379
+ raise DeviceConnectionError(self.device_name, "Socket trans error") from exc