cgse-common 0.17.3__tar.gz → 0.17.4__tar.gz

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 (45) hide show
  1. {cgse_common-0.17.3 → cgse_common-0.17.4}/PKG-INFO +1 -1
  2. {cgse_common-0.17.3 → cgse_common-0.17.4}/pyproject.toml +1 -1
  3. {cgse_common-0.17.3 → cgse_common-0.17.4}/src/egse/device.py +70 -0
  4. {cgse_common-0.17.3 → cgse_common-0.17.4}/src/egse/hk.py +2 -2
  5. {cgse_common-0.17.3 → cgse_common-0.17.4}/src/egse/log.py +8 -0
  6. {cgse_common-0.17.3 → cgse_common-0.17.4}/src/egse/scpi.py +123 -52
  7. cgse_common-0.17.4/src/egse/socketdevice.py +380 -0
  8. {cgse_common-0.17.3 → cgse_common-0.17.4}/src/egse/system.py +5 -0
  9. cgse_common-0.17.3/justfile +0 -20
  10. cgse_common-0.17.3/service_registry.db +0 -0
  11. {cgse_common-0.17.3 → cgse_common-0.17.4}/.gitignore +0 -0
  12. {cgse_common-0.17.3 → cgse_common-0.17.4}/README.md +0 -0
  13. {cgse_common-0.17.3 → cgse_common-0.17.4}/noxfile.py +0 -0
  14. {cgse_common-0.17.3 → cgse_common-0.17.4}/src/cgse_common/__init__.py +0 -0
  15. {cgse_common-0.17.3 → cgse_common-0.17.4}/src/cgse_common/cgse.py +0 -0
  16. {cgse_common-0.17.3 → cgse_common-0.17.4}/src/cgse_common/settings.yaml +0 -0
  17. {cgse_common-0.17.3 → cgse_common-0.17.4}/src/egse/bits.py +0 -0
  18. {cgse_common-0.17.3 → cgse_common-0.17.4}/src/egse/calibration.py +0 -0
  19. {cgse_common-0.17.3 → cgse_common-0.17.4}/src/egse/config.py +0 -0
  20. {cgse_common-0.17.3 → cgse_common-0.17.4}/src/egse/counter.py +0 -0
  21. {cgse_common-0.17.3 → cgse_common-0.17.4}/src/egse/decorators.py +0 -0
  22. {cgse_common-0.17.3 → cgse_common-0.17.4}/src/egse/dicts.py +0 -0
  23. {cgse_common-0.17.3 → cgse_common-0.17.4}/src/egse/env.py +0 -0
  24. {cgse_common-0.17.3 → cgse_common-0.17.4}/src/egse/exceptions.py +0 -0
  25. {cgse_common-0.17.3 → cgse_common-0.17.4}/src/egse/heartbeat.py +0 -0
  26. {cgse_common-0.17.3 → cgse_common-0.17.4}/src/egse/metrics.py +0 -0
  27. {cgse_common-0.17.3 → cgse_common-0.17.4}/src/egse/observer.py +0 -0
  28. {cgse_common-0.17.3 → cgse_common-0.17.4}/src/egse/obsid.py +0 -0
  29. {cgse_common-0.17.3 → cgse_common-0.17.4}/src/egse/persistence.py +0 -0
  30. {cgse_common-0.17.3 → cgse_common-0.17.4}/src/egse/plugin.py +0 -0
  31. {cgse_common-0.17.3 → cgse_common-0.17.4}/src/egse/plugins/metrics/influxdb.py +0 -0
  32. {cgse_common-0.17.3 → cgse_common-0.17.4}/src/egse/process.py +0 -0
  33. {cgse_common-0.17.3 → cgse_common-0.17.4}/src/egse/py.typed +0 -0
  34. {cgse_common-0.17.3 → cgse_common-0.17.4}/src/egse/randomwalk.py +0 -0
  35. {cgse_common-0.17.3 → cgse_common-0.17.4}/src/egse/reload.py +0 -0
  36. {cgse_common-0.17.3 → cgse_common-0.17.4}/src/egse/resource.py +0 -0
  37. {cgse_common-0.17.3 → cgse_common-0.17.4}/src/egse/response.py +0 -0
  38. {cgse_common-0.17.3 → cgse_common-0.17.4}/src/egse/settings.py +0 -0
  39. {cgse_common-0.17.3 → cgse_common-0.17.4}/src/egse/settings.yaml +0 -0
  40. {cgse_common-0.17.3 → cgse_common-0.17.4}/src/egse/setup.py +0 -0
  41. {cgse_common-0.17.3 → cgse_common-0.17.4}/src/egse/signal.py +0 -0
  42. {cgse_common-0.17.3 → cgse_common-0.17.4}/src/egse/state.py +0 -0
  43. {cgse_common-0.17.3 → cgse_common-0.17.4}/src/egse/task.py +0 -0
  44. {cgse_common-0.17.3 → cgse_common-0.17.4}/src/egse/version.py +0 -0
  45. {cgse_common-0.17.3 → cgse_common-0.17.4}/src/egse/zmq_ser.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cgse-common
3
- Version: 0.17.3
3
+ Version: 0.17.4
4
4
  Summary: Software framework to support hardware testing
5
5
  Author: IvS KU Leuven
6
6
  Maintainer-email: Rik Huygen <rik.huygen@kuleuven.be>, Sara Regibo <sara.regibo@kuleuven.be>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cgse-common"
3
- version = "0.17.3"
3
+ version = "0.17.4"
4
4
  description = "Software framework to support hardware testing"
5
5
  authors = [
6
6
  {name = "IvS KU Leuven"}
@@ -339,6 +339,76 @@ class AsyncDeviceTransport:
339
339
  return await self.trans(command)
340
340
 
341
341
 
342
+ class AsyncDeviceConnectionInterface(DeviceConnectionObservable):
343
+ """Generic connection interface for all Device classes and Controllers.
344
+
345
+ This interface shall be implemented in the Controllers that directly connect to the
346
+ hardware, but also in the simulators to guarantee an identical interface as the controllers.
347
+
348
+ This interface will be implemented in the Proxy classes through the
349
+ YAML definitions. Therefore, the YAML files shall define at least
350
+ the following commands: `connect`, `disconnect`, `reconnect`, `is_connected`.
351
+ """
352
+
353
+ def __init__(self):
354
+ super().__init__()
355
+
356
+ def __enter__(self):
357
+ self.connect()
358
+ return self
359
+
360
+ def __exit__(self, exc_type, exc_val, exc_tb):
361
+ self.disconnect()
362
+
363
+ async def connect(self) -> None:
364
+ """Connect to the device controller.
365
+
366
+ Raises:
367
+ ConnectionError: when the connection can not be opened.
368
+ """
369
+
370
+ raise NotImplementedError
371
+
372
+ async def disconnect(self) -> None:
373
+ """Disconnect from the device controller.
374
+
375
+ Raises:
376
+ ConnectionError: when the connection can not be closed.
377
+ """
378
+ raise NotImplementedError
379
+
380
+ async def reconnect(self):
381
+ """Reconnect the device controller.
382
+
383
+ Raises:
384
+ ConnectionError: when the device can not be reconnected for some reason.
385
+ """
386
+ raise NotImplementedError
387
+
388
+ async def is_connected(self) -> bool:
389
+ """Check if the device is connected.
390
+
391
+ Returns:
392
+ True if the device is connected and responds to a command, False otherwise.
393
+ """
394
+ raise NotImplementedError
395
+
396
+
397
+ class AsyncDeviceInterface(AsyncDeviceConnectionInterface):
398
+ """A generic interface for all device classes."""
399
+
400
+ def is_simulator(self) -> bool:
401
+ """Checks whether the device is a simulator rather than a real hardware controller.
402
+
403
+ This can be useful for testing purposes or when doing actual movement simulations.
404
+
405
+ Returns:
406
+ True if the Device is a Simulator; False if the Device is connected to real hardware.
407
+ """
408
+
409
+ raise NotImplementedError
410
+
411
+
342
412
  class DeviceFactoryInterface:
343
413
  """
344
414
  Base class for creating a device factory class to access devices.
@@ -51,8 +51,8 @@ class TmDictionaryColumns(str, Enum):
51
51
  """ # noqa
52
52
 
53
53
  STORAGE_MNEMONIC = "Storage mnemonic"
54
- CORRECT_HK_NAMES = "CAM EGSE mnemonic"
55
- ORIGINAL_EGSE_HK_NAMES = "Original name in EGSE"
54
+ CORRECT_HK_NAMES = "CGSE mnemonic"
55
+ ORIGINAL_EGSE_HK_NAMES = "Original name in CGSE"
56
56
  SYNOPTICS_ORIGIN = f"Origin of synoptics at {get_site_id()}"
57
57
  TIMESTAMP_NAMES = "Name of corresponding timestamp"
58
58
  DESCRIPTION = "Description"
@@ -131,6 +131,14 @@ for handler in root_logger.handlers:
131
131
  handler.addFilter(NonEGSEFilter())
132
132
  handler.addFilter(PackageFilter())
133
133
 
134
+ try:
135
+ from textual.logging import TextualHandler
136
+
137
+ root_logger.addHandler(TextualHandler())
138
+ except ImportError:
139
+ pass
140
+
141
+
134
142
  logger = egse_logger
135
143
 
136
144
  if __name__ == "__main__":
@@ -4,17 +4,23 @@ from typing import Any
4
4
  from typing import Dict
5
5
  from typing import Optional
6
6
 
7
+ from egse.device import AsyncDeviceInterface
7
8
  from egse.device import AsyncDeviceTransport
8
9
  from egse.device import DeviceConnectionError
9
10
  from egse.device import DeviceError
10
11
  from egse.device import DeviceTimeoutError
12
+ from egse.env import bool_env
11
13
  from egse.log import logger
12
14
 
13
- # Constants that can be overridden by specific device implementations
14
15
  DEFAULT_READ_TIMEOUT = 1.0 # seconds
15
16
  DEFAULT_CONNECT_TIMEOUT = 3.0 # seconds
16
17
  IDENTIFICATION_QUERY = "*IDN?"
17
18
 
19
+ VERBOSE_DEBUG = bool_env("VERBOSE_DEBUG")
20
+
21
+ SEPARATOR = b"\n"
22
+ SEPARATOR_STR = SEPARATOR.decode()
23
+
18
24
 
19
25
  class SCPICommand:
20
26
  """Base class for SCPI commands."""
@@ -32,7 +38,7 @@ class SCPICommand:
32
38
  raise NotImplementedError("Subclasses must implement get_cmd_string().")
33
39
 
34
40
 
35
- class AsyncSCPIInterface(AsyncDeviceTransport):
41
+ class AsyncSCPIInterface(AsyncDeviceInterface, AsyncDeviceTransport):
36
42
  """Generic asynchronous interface for devices that use SCPI commands over Ethernet."""
37
43
 
38
44
  def __init__(
@@ -56,7 +62,9 @@ class AsyncSCPIInterface(AsyncDeviceTransport):
56
62
  read_timeout: Timeout for read operations in seconds
57
63
  id_validation: String that should appear in the device's identification response
58
64
  """
59
- self.device_name = device_name
65
+ super().__init__()
66
+
67
+ self._device_name = device_name
60
68
  self.hostname = hostname
61
69
  self.port = port
62
70
  self.settings = settings or {}
@@ -70,8 +78,66 @@ class AsyncSCPIInterface(AsyncDeviceTransport):
70
78
  self._connect_lock = asyncio.Lock()
71
79
  """Prevents multiple, simultaneous connect or disconnect attempts."""
72
80
  self._io_lock = asyncio.Lock()
73
- """Prevents multiple coroutines from attempting to read, write or query from the same stream
74
- at the same time."""
81
+ """Prevents multiple coroutines from attempting to read, write or query from the same stream at the same time."""
82
+
83
+ def is_simulator(self) -> bool:
84
+ return False
85
+
86
+ @property
87
+ def device_name(self) -> str:
88
+ return self._device_name
89
+
90
+ async def initialize(self, commands: list[tuple[str, bool]] = None, reset_device: bool = False) -> list[str | None]:
91
+ """Initialize the device with optional reset and command sequence.
92
+
93
+ Performs device initialization by optionally resetting the device and then
94
+ executing a sequence of commands. Each command can optionally expect a
95
+ response that will be logged for debugging purposes.
96
+
97
+ Args:
98
+ commands: List of tuples containing (command_string, expects_response).
99
+ Each tuple specifies a command to send and whether to wait for and
100
+ log the response. Defaults to None (no commands executed).
101
+ reset_device: Whether to send a reset command (*RST) before executing
102
+ the command sequence. Defaults to False.
103
+
104
+ Returns:
105
+ Response for each of the commands, or None when no response was expected.
106
+
107
+ Raises:
108
+ Any exceptions raised by the underlying write() or trans() methods,
109
+ typically communication errors or device timeouts.
110
+
111
+ Example:
112
+ await device.initialize(
113
+ [
114
+ ("*IDN?", True), # Query device ID, expect response
115
+ ("SYST:ERR?", True), # Check for errors, expect response
116
+ ("OUTP ON", False), # Enable output, no response expected
117
+ ],
118
+ reset_device=True
119
+ )
120
+ """
121
+
122
+ commands = commands or []
123
+ responses = []
124
+
125
+ if reset_device:
126
+ logger.info(f"Resetting the {self._device_name}...")
127
+ await self.write("*RST") # this also resets the user-defined buffer
128
+
129
+ for cmd, expects_response in commands:
130
+ if expects_response:
131
+ logger.debug(f"Sending {cmd}...")
132
+ response = (await self.trans(cmd)).decode().strip()
133
+ responses.append(response)
134
+ logger.debug(f"{response = }")
135
+ else:
136
+ logger.debug(f"Sending {cmd}...")
137
+ await self.write(cmd)
138
+ responses.append(None)
139
+
140
+ return responses
75
141
 
76
142
  async def connect(self) -> None:
77
143
  """Connect to the device asynchronously.
@@ -84,47 +150,51 @@ class AsyncSCPIInterface(AsyncDeviceTransport):
84
150
  async with self._connect_lock:
85
151
  # Sanity checks
86
152
  if self._is_connection_open:
87
- logger.warning(f"{self.device_name}: Trying to connect to an already connected device.")
153
+ logger.warning(f"{self._device_name}: Trying to connect to an already connected device.")
88
154
  return
89
155
 
90
156
  if not self.hostname:
91
- raise ValueError(f"{self.device_name}: Hostname is not initialized.")
157
+ raise ValueError(f"{self._device_name}: Hostname is not initialized.")
92
158
 
93
159
  if not self.port:
94
- raise ValueError(f"{self.device_name}: Port number is not initialized.")
160
+ raise ValueError(f"{self._device_name}: Port number is not initialized.")
95
161
 
96
162
  # Attempt to establish a connection
97
163
  try:
98
- logger.debug(f'Connecting to {self.device_name} at "{self.hostname}" using port {self.port}')
164
+ logger.debug(f'Connecting to {self._device_name} at "{self.hostname}" using port {self.port}')
99
165
 
100
166
  connect_task = asyncio.open_connection(self.hostname, self.port)
101
167
  self._reader, self._writer = await asyncio.wait_for(connect_task, timeout=self.connect_timeout)
102
168
 
103
169
  self._is_connection_open = True
104
170
 
105
- logger.debug(f"Successfully connected to {self.device_name}.")
171
+ response = await self.read_string()
172
+ if VERBOSE_DEBUG:
173
+ logger.debug(f"Response after connection: {response}")
174
+
175
+ logger.debug(f"Successfully connected to {self._device_name}.")
106
176
 
107
177
  except asyncio.TimeoutError as exc:
108
178
  raise DeviceTimeoutError(
109
- self.device_name, f"Connection to {self.hostname}:{self.port} timed out"
179
+ self._device_name, f"Connection to {self.hostname}:{self.port} timed out"
110
180
  ) from exc
111
181
  except ConnectionRefusedError as exc:
112
182
  raise DeviceConnectionError(
113
- self.device_name, f"Connection refused to {self.hostname}:{self.port}"
183
+ self._device_name, f"Connection refused to {self.hostname}:{self.port}"
114
184
  ) from exc
115
185
  except socket.gaierror as exc:
116
- raise DeviceConnectionError(self.device_name, f"Address resolution error for {self.hostname}") from exc
186
+ raise DeviceConnectionError(self._device_name, f"Address resolution error for {self.hostname}") from exc
117
187
  except socket.herror as exc:
118
- raise DeviceConnectionError(self.device_name, f"Host address error for {self.hostname}") from exc
188
+ raise DeviceConnectionError(self._device_name, f"Host address error for {self.hostname}") from exc
119
189
  except OSError as exc:
120
- raise DeviceConnectionError(self.device_name, f"OS error: {exc}") from exc
190
+ raise DeviceConnectionError(self._device_name, f"OS error: {exc}") from exc
121
191
 
122
192
  # Validate device identity if requested
123
193
  if self.id_validation:
124
194
  logger.debug("Validating connection..")
125
195
  if not await self.is_connected():
126
196
  await self.disconnect()
127
- raise DeviceConnectionError(self.device_name, "Device connected but failed identity verification")
197
+ raise DeviceConnectionError(self._device_name, "Device connected but failed identity verification")
128
198
 
129
199
  async def disconnect(self) -> None:
130
200
  """Disconnect from the device asynchronously.
@@ -135,22 +205,20 @@ class AsyncSCPIInterface(AsyncDeviceTransport):
135
205
  async with self._connect_lock:
136
206
  try:
137
207
  if self._is_connection_open and self._writer is not None:
138
- logger.debug(f"Disconnecting from {self.device_name} at {self.hostname}")
208
+ logger.debug(f"Disconnecting from {self._device_name} at {self.hostname}")
139
209
  self._writer.close()
140
210
  await self._writer.wait_closed()
141
211
  self._writer = None
142
212
  self._reader = None
143
213
  self._is_connection_open = False
144
214
  except Exception as exc:
145
- raise DeviceConnectionError(self.device_name, f"Could not close connection: {exc}") from exc
215
+ raise DeviceConnectionError(self._device_name, f"Could not close connection: {exc}") from exc
146
216
 
147
217
  async def reconnect(self) -> None:
148
218
  """Reconnect to the device asynchronously."""
149
- async with self._connect_lock:
150
- if self._is_connection_open:
151
- await self.disconnect()
152
- await asyncio.sleep(0.1)
153
- await self.connect()
219
+ await self.disconnect()
220
+ await asyncio.sleep(0.1)
221
+ await self.connect()
154
222
 
155
223
  async def is_connected(self) -> bool:
156
224
  """Check if the device is connected and responds correctly to identification.
@@ -168,7 +236,7 @@ class AsyncSCPIInterface(AsyncDeviceTransport):
168
236
  # Validate the response if validation string is provided
169
237
  if self.id_validation and self.id_validation not in id_response:
170
238
  logger.error(
171
- f"{self.device_name}: Device did not respond correctly to identification query. "
239
+ f"{self._device_name}: Device did not respond correctly to identification query. "
172
240
  f'Expected "{self.id_validation}" in response, got: {id_response}'
173
241
  )
174
242
  await self.disconnect()
@@ -177,8 +245,7 @@ class AsyncSCPIInterface(AsyncDeviceTransport):
177
245
  return True
178
246
 
179
247
  except DeviceError as exc:
180
- logger.exception(exc)
181
- logger.error(f"{self.device_name}: Connection test failed")
248
+ logger.error(f"{self._device_name}: Connection test failed: {exc}", exc_info=True)
182
249
  await self.disconnect()
183
250
  return False
184
251
 
@@ -195,19 +262,20 @@ class AsyncSCPIInterface(AsyncDeviceTransport):
195
262
  async with self._io_lock:
196
263
  try:
197
264
  if not self._is_connection_open or self._writer is None:
198
- raise DeviceConnectionError(self.device_name, "Device not connected, use connect() first")
265
+ raise DeviceConnectionError(self._device_name, "Device not connected, use connect() first")
199
266
 
200
- # Ensure command ends with newline
201
- if not command.endswith("\n"):
202
- command += "\n"
267
+ # Ensure command ends with the proper separator or terminator
268
+ if not command.endswith(SEPARATOR_STR):
269
+ command += SEPARATOR_STR
203
270
 
271
+ logger.info(f"-----> {command}")
204
272
  self._writer.write(command.encode())
205
273
  await self._writer.drain()
206
274
 
207
275
  except asyncio.TimeoutError as exc:
208
- raise DeviceTimeoutError(self.device_name, "Write operation timed out") from exc
276
+ raise DeviceTimeoutError(self._device_name, "Write operation timed out") from exc
209
277
  except (ConnectionError, OSError) as exc:
210
- raise DeviceConnectionError(self.device_name, f"Communication error: {exc}") from exc
278
+ raise DeviceConnectionError(self._device_name, f"Communication error: {exc}") from exc
211
279
 
212
280
  async def read(self) -> bytes:
213
281
  """
@@ -222,7 +290,7 @@ class AsyncSCPIInterface(AsyncDeviceTransport):
222
290
  """
223
291
  async with self._io_lock:
224
292
  if not self._is_connection_open or self._reader is None:
225
- raise DeviceConnectionError(self.device_name, "Device not connected, use connect() first")
293
+ raise DeviceConnectionError(self._device_name, "Device not connected, use connect() first")
226
294
 
227
295
  try:
228
296
  # First, small delay to allow device to prepare response
@@ -231,18 +299,19 @@ class AsyncSCPIInterface(AsyncDeviceTransport):
231
299
  # Try to read until newline (common SCPI terminator)
232
300
  try:
233
301
  response = await asyncio.wait_for(
234
- self._reader.readuntil(separator=b"\n"), timeout=self.read_timeout
302
+ self._reader.readuntil(separator=SEPARATOR), timeout=self.read_timeout
235
303
  )
304
+ logger.info(f"<----- {response}")
236
305
  return response
237
306
 
238
307
  except asyncio.IncompleteReadError as exc:
239
308
  # Connection closed before receiving full response
240
- logger.warning(f"{self.device_name}: Incomplete read, got {len(exc.partial)} bytes")
241
- return exc.partial if exc.partial else b"\r\n"
309
+ logger.warning(f"{self._device_name}: Incomplete read, got {len(exc.partial)} bytes")
310
+ return exc.partial if exc.partial else SEPARATOR
242
311
 
243
312
  except asyncio.LimitOverrunError:
244
313
  # Response too large for buffer
245
- logger.warning(f"{self.device_name}: Response exceeded buffer limits")
314
+ logger.warning(f"{self._device_name}: Response exceeded buffer limits")
246
315
  # Fall back to reading a large chunk
247
316
  return await asyncio.wait_for(
248
317
  self._reader.read(8192), # Larger buffer for exceptional cases
@@ -250,9 +319,9 @@ class AsyncSCPIInterface(AsyncDeviceTransport):
250
319
  )
251
320
 
252
321
  except asyncio.TimeoutError as exc:
253
- raise DeviceTimeoutError(self.device_name, "Read operation timed out") from exc
322
+ raise DeviceTimeoutError(self._device_name, "Read operation timed out") from exc
254
323
  except Exception as exc:
255
- raise DeviceConnectionError(self.device_name, f"Read error: {exc}") from exc
324
+ raise DeviceConnectionError(self._device_name, f"Read error: {exc}") from exc
256
325
 
257
326
  async def trans(self, command: str) -> bytes:
258
327
  """
@@ -273,33 +342,35 @@ class AsyncSCPIInterface(AsyncDeviceTransport):
273
342
  async with self._io_lock:
274
343
  try:
275
344
  if not self._is_connection_open or self._writer is None:
276
- raise DeviceConnectionError(self.device_name, "Device not connected, use connect() first")
345
+ raise DeviceConnectionError(self._device_name, "Device not connected, use connect() first")
277
346
 
278
- # Ensure command ends with newline
279
- if not command.endswith("\n"):
280
- command += "\n"
347
+ # Ensure command ends with the required terminator
348
+ if not command.endswith(SEPARATOR_STR):
349
+ command += SEPARATOR_STR
281
350
 
351
+ logger.info(f"-----> {command=}")
282
352
  self._writer.write(command.encode())
283
353
  await self._writer.drain()
284
354
 
285
- # First, small delay to allow device to prepare response
355
+ # First, small delay to allow the device to prepare a response
286
356
  await asyncio.sleep(0.01)
287
357
 
288
- # Try to read until newline (common SCPI terminator)
358
+ # Try to read until the required separator (common SCPI terminator)
289
359
  try:
290
360
  response = await asyncio.wait_for(
291
- self._reader.readuntil(separator=b"\n"), timeout=self.read_timeout
361
+ self._reader.readuntil(separator=SEPARATOR), timeout=self.read_timeout
292
362
  )
363
+ logger.info(f"<----- {response=}")
293
364
  return response
294
365
 
295
366
  except asyncio.IncompleteReadError as exc:
296
367
  # Connection closed before receiving full response
297
- logger.warning(f"{self.device_name}: Incomplete read, got {len(exc.partial)} bytes")
298
- return exc.partial if exc.partial else b"\r\n"
368
+ logger.warning(f"{self._device_name}: Incomplete read, got {len(exc.partial)} bytes")
369
+ return exc.partial if exc.partial else SEPARATOR
299
370
 
300
371
  except asyncio.LimitOverrunError:
301
372
  # Response too large for buffer
302
- logger.warning(f"{self.device_name}: Response exceeded buffer limits")
373
+ logger.warning(f"{self._device_name}: Response exceeded buffer limits")
303
374
  # Fall back to reading a large chunk
304
375
  return await asyncio.wait_for(
305
376
  self._reader.read(8192), # Larger buffer for exceptional cases
@@ -307,11 +378,11 @@ class AsyncSCPIInterface(AsyncDeviceTransport):
307
378
  )
308
379
 
309
380
  except asyncio.TimeoutError as exc:
310
- raise DeviceTimeoutError(self.device_name, "Communication timed out") from exc
381
+ raise DeviceTimeoutError(self._device_name, "Communication timed out") from exc
311
382
  except (ConnectionError, OSError) as exc:
312
- raise DeviceConnectionError(self.device_name, f"Communication error: {exc}") from exc
383
+ raise DeviceConnectionError(self._device_name, f"Communication error: {exc}") from exc
313
384
  except Exception as exc:
314
- raise DeviceConnectionError(self.device_name, f"Transaction error: {exc}") from exc
385
+ raise DeviceConnectionError(self._device_name, f"Transaction error: {exc}") from exc
315
386
 
316
387
  async def __aenter__(self):
317
388
  """Async context manager entry."""
@@ -0,0 +1,380 @@
1
+ """
2
+ This module defines base classes and generic functions to work with sockets.
3
+ """
4
+
5
+ import asyncio
6
+ import select
7
+ import socket
8
+ import time
9
+ from typing import Optional
10
+
11
+ from egse.device import AsyncDeviceInterface
12
+ from egse.device import AsyncDeviceTransport
13
+ from egse.device import DeviceConnectionError
14
+ from egse.device import DeviceConnectionInterface
15
+ from egse.device import DeviceTimeoutError
16
+ from egse.device import DeviceTransport
17
+ from egse.log import logger
18
+ from egse.system import type_name
19
+
20
+ SEPARATOR = b"\x03"
21
+ SEPARATOR_STR = SEPARATOR.decode()
22
+
23
+
24
+ class SocketDevice(DeviceConnectionInterface, DeviceTransport):
25
+ """Base class that implements the socket interface."""
26
+
27
+ # We set a default connect timeout of 3.0 sec before connecting and reset
28
+ # to None (=blocking) after connecting. The reason for this is that when no
29
+ # device is available, e.g. during testing, the timeout will take about
30
+ # two minutes which is way too long. It needs to be evaluated if this
31
+ # approach is acceptable and not causing problems during production.
32
+
33
+ def __init__(
34
+ self,
35
+ hostname: str,
36
+ port: int,
37
+ connect_timeout: float = 3.0,
38
+ read_timeout: float | None = 1.0,
39
+ separator: bytes = SEPARATOR,
40
+ ):
41
+ super().__init__()
42
+ self.is_connection_open = False
43
+ self.hostname = hostname
44
+ self.port = port
45
+ self.connect_timeout = connect_timeout
46
+ self.read_timeout = read_timeout
47
+ self.separator = separator
48
+ self.socket = None
49
+
50
+ @property
51
+ def device_name(self):
52
+ """The name of the device that this interface connects to."""
53
+ return f"SocketDevice({self.hostname}:{self.port})"
54
+
55
+ def connect(self):
56
+ """
57
+ Connect the device.
58
+
59
+ Raises:
60
+ ConnectionError: When the connection could not be established. Check the logging
61
+ messages for more detail.
62
+ TimeoutError: When the connection timed out.
63
+ ValueError: When hostname or port number are not provided.
64
+ """
65
+
66
+ # Sanity checks
67
+
68
+ if self.is_connection_open:
69
+ logger.warning(f"{self.device_name}: trying to connect to an already connected socket.")
70
+ return
71
+
72
+ if self.hostname in (None, ""):
73
+ raise ValueError(f"{self.device_name}: hostname is not initialized.")
74
+
75
+ if self.port in (None, 0):
76
+ raise ValueError(f"{self.device_name}: port number is not initialized.")
77
+
78
+ # Create a new socket instance
79
+
80
+ try:
81
+ self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
82
+ except socket.error as exc:
83
+ raise ConnectionError(f"{self.device_name}: Failed to create socket.") from exc
84
+
85
+ try:
86
+ logger.debug(f'Connecting a socket to host "{self.hostname}" using port {self.port}')
87
+ self.socket.settimeout(self.connect_timeout)
88
+ self.socket.connect((self.hostname, self.port))
89
+ self.socket.settimeout(None)
90
+ except ConnectionRefusedError as exc:
91
+ raise ConnectionError(f"{self.device_name}: Connection refused to {self.hostname}:{self.port}.") from exc
92
+ except TimeoutError as exc:
93
+ raise TimeoutError(f"{self.device_name}: Connection to {self.hostname}:{self.port} timed out.") from exc
94
+ except socket.gaierror as exc:
95
+ raise ConnectionError(f"{self.device_name}: socket address info error for {self.hostname}") from exc
96
+ except socket.herror as exc:
97
+ raise ConnectionError(f"{self.device_name}: socket host address error for {self.hostname}") from exc
98
+ except OSError as exc:
99
+ raise ConnectionError(f"{self.device_name}: OSError caught ({exc}).") from exc
100
+
101
+ self.is_connection_open = True
102
+
103
+ def disconnect(self):
104
+ """
105
+ Disconnect from the Ethernet connection.
106
+
107
+ Raises:
108
+ ConnectionError when the socket could not be closed.
109
+ """
110
+
111
+ try:
112
+ if self.is_connection_open:
113
+ logger.debug(f"Disconnecting from {self.hostname}")
114
+ self.socket.close()
115
+ self.is_connection_open = False
116
+ except Exception as e_exc:
117
+ raise ConnectionError(f"{self.device_name}: Could not close socket to {self.hostname}") from e_exc
118
+
119
+ def is_connected(self) -> bool:
120
+ """
121
+ Check if the device is connected.
122
+
123
+ Returns:
124
+ True is the device is connected, False otherwise.
125
+ """
126
+
127
+ return bool(self.is_connection_open)
128
+
129
+ def reconnect(self):
130
+ """
131
+ Reconnect to the device. If the connection is open, this function will first disconnect
132
+ and then connect again.
133
+ """
134
+
135
+ if self.is_connection_open:
136
+ self.disconnect()
137
+ self.connect()
138
+
139
+ def read(self) -> bytes:
140
+ """
141
+ Read until ETX (b'\x03') or until `self.read_timeout` elapses.
142
+ Uses `select` to avoid blocking indefinitely when no data is available.
143
+ If `self.read_timeout` was set to None in the constructor, this will block anyway.
144
+ """
145
+ if not self.socket:
146
+ raise DeviceConnectionError(self.device_name, "Not connected")
147
+
148
+ buf_size = 1024 * 4
149
+ response = bytearray()
150
+
151
+ # If read_timeout is None we preserve blocking behaviour; otherwise enforce overall timeout.
152
+ if self.read_timeout is None:
153
+ end_time = None
154
+ else:
155
+ end_time = time.monotonic() + self.read_timeout
156
+
157
+ try:
158
+ while True:
159
+ # compute the remaining timeout for select, this is needed because we read in different parts
160
+ # until ETX is received, and we want to receive the complete messages including ETX within
161
+ # the read timeout.
162
+ if end_time is None:
163
+ timeout = None
164
+ else:
165
+ remaining = end_time - time.monotonic()
166
+ if remaining <= 0.0:
167
+ raise DeviceTimeoutError(self.device_name, "Socket read timed out")
168
+ timeout = remaining
169
+
170
+ ready, _, _ = select.select([self.socket], [], [], timeout)
171
+
172
+ if not ready:
173
+ # no socket ready within timeout
174
+ raise DeviceTimeoutError(self.device_name, "Socket read timed out")
175
+
176
+ try:
177
+ data = self.socket.recv(buf_size)
178
+ except OSError as exc:
179
+ raise DeviceConnectionError(self.device_name, f"Caught {type_name(exc)}: {exc}") from exc
180
+
181
+ if not data:
182
+ # remote closed connection (EOF)
183
+ raise DeviceConnectionError(self.device_name, "Connection closed by peer")
184
+
185
+ response.extend(data)
186
+
187
+ if self.separator in response:
188
+ break
189
+
190
+ except DeviceTimeoutError:
191
+ raise
192
+ except DeviceConnectionError:
193
+ raise
194
+ except Exception as exc:
195
+ # unexpected errors - translate to DeviceConnectionError
196
+ raise DeviceConnectionError(self.device_name, "Socket read error") from exc
197
+
198
+ return bytes(response)
199
+
200
+ def write(self, command: str):
201
+ """
202
+ Send a command to the device.
203
+
204
+ No processing is done on the command string, except for the encoding into a bytes object.
205
+
206
+ Args:
207
+ command: the command string including terminators.
208
+
209
+ Raises:
210
+ A DeviceTimeoutError when the send timed out, and a DeviceConnectionError if
211
+ there was a socket related error.
212
+ """
213
+
214
+ try:
215
+ self.socket.sendall(command.encode())
216
+ except socket.timeout as exc:
217
+ raise DeviceTimeoutError(self.device_name, "Socket timeout error") from exc
218
+ except socket.error as exc:
219
+ # Interpret any socket-related error as an I/O error
220
+ raise DeviceConnectionError(self.device_name, "Socket communication error.") from exc
221
+
222
+ def trans(self, command: str) -> bytes:
223
+ """
224
+ Send a command to the device and wait for the response.
225
+
226
+ No processing is done on the command string, except for the encoding into a bytes object.
227
+
228
+ Args:
229
+ command: the command string including terminators.
230
+
231
+ Returns:
232
+ A bytes object containing the response from the device. No processing is done
233
+ on the response.
234
+
235
+ Raises:
236
+ A DeviceTimeoutError when the send timed out, and a DeviceConnectionError if
237
+ there was a socket related error.
238
+ """
239
+
240
+ try:
241
+ # Attempt to send the complete command
242
+
243
+ self.socket.sendall(command.encode())
244
+
245
+ # wait for, read and return the response (will be at most TBD chars)
246
+
247
+ return_string = self.read()
248
+
249
+ return return_string
250
+
251
+ except socket.timeout as exc:
252
+ raise DeviceTimeoutError(self.device_name, "Socket timeout error") from exc
253
+ except socket.error as exc:
254
+ # Interpret any socket-related error as an I/O error
255
+ raise DeviceConnectionError(self.device_name, "Socket communication error.") from exc
256
+
257
+
258
+ class AsyncSocketDevice(AsyncDeviceInterface, AsyncDeviceTransport):
259
+ """
260
+ Async socket-backed device using asyncio streams.
261
+
262
+ - async connect() / disconnect()
263
+ - async read() reads until ETX (b'\\x03') or timeout
264
+ - async write() and async trans()
265
+ """
266
+
267
+ def __init__(
268
+ self,
269
+ hostname: str,
270
+ port: int,
271
+ connect_timeout: float = 3.0,
272
+ read_timeout: float | None = 1.0,
273
+ separator: bytes = SEPARATOR,
274
+ ):
275
+ super().__init__()
276
+ self.hostname = hostname
277
+ self.port = port
278
+ self.connect_timeout = connect_timeout
279
+ self.read_timeout = read_timeout
280
+ self.separator = separator
281
+ self.reader: Optional[asyncio.StreamReader] = None
282
+ self.writer: Optional[asyncio.StreamWriter] = None
283
+ self.is_connection_open = False
284
+
285
+ @property
286
+ def device_name(self) -> str:
287
+ # Override this property for a decent name
288
+ return f"AsyncSocketDevice({self.hostname}:{self.port})"
289
+
290
+ async def connect(self) -> None:
291
+ if self.is_connection_open:
292
+ logger.debug(f"{self.device_name}: already connected")
293
+ return
294
+
295
+ if not self.hostname:
296
+ raise ValueError(f"{self.device_name}: hostname is not initialized.")
297
+ if not self.port:
298
+ raise ValueError(f"{self.device_name}: port is not initialized.")
299
+
300
+ try:
301
+ logger.debug(f"{self.device_name}: connect() called; is_connection_open={self.is_connection_open}")
302
+ coro = asyncio.open_connection(self.hostname, self.port)
303
+ self.reader, self.writer = await asyncio.wait_for(coro, timeout=self.connect_timeout)
304
+ self.is_connection_open = True
305
+ logger.debug(f"{self.device_name}: connected -> peer={self.writer.get_extra_info('peername')}")
306
+
307
+ except asyncio.TimeoutError as exc:
308
+ await self._cleanup()
309
+ logger.warning(f"{self.device_name}: connect timed out")
310
+ raise DeviceTimeoutError(self.device_name, f"Connection to {self.hostname}:{self.port} timed out.") from exc
311
+ except Exception as exc:
312
+ await self._cleanup()
313
+ logger.warning(f"{self.device_name}: connect failed: {type_name(exc)} – {exc}")
314
+ raise DeviceConnectionError(self.device_name, f"Failed to connect to {self.hostname}:{self.port}") from exc
315
+
316
+ async def disconnect(self) -> None:
317
+ logger.debug(f"{self.device_name}: disconnect() called; writer_exists={bool(self.writer)}")
318
+ peer = None
319
+ try:
320
+ if self.writer and not self.writer.is_closing():
321
+ peer = self.writer.get_extra_info("peername")
322
+ self.writer.close()
323
+ # wait for close, but don't hang forever
324
+ try:
325
+ await asyncio.wait_for(self.writer.wait_closed(), timeout=1.0)
326
+ except asyncio.TimeoutError:
327
+ logger.debug(f"{self.device_name}: wait_closed() timed out for peer={peer}")
328
+
329
+ finally:
330
+ await self._cleanup()
331
+ logger.debug(f"{self.device_name}: disconnected ({peer=})")
332
+
333
+ def is_connected(self) -> bool:
334
+ return bool(self.is_connection_open and self.writer and not self.writer.is_closing())
335
+
336
+ async def _cleanup(self) -> None:
337
+ self.reader = None
338
+ self.writer = None
339
+ self.is_connection_open = False
340
+
341
+ async def read(self) -> bytes:
342
+ if not self.reader:
343
+ raise DeviceConnectionError(self.device_name, "Not connected")
344
+ try:
345
+ # readuntil includes the separator; we keep it for parity with existing code
346
+ data = await asyncio.wait_for(self.reader.readuntil(separator=self.separator), timeout=self.read_timeout)
347
+ return data
348
+ except asyncio.IncompleteReadError as exc:
349
+ # EOF before separator
350
+ await self._cleanup()
351
+ raise DeviceConnectionError(self.device_name, "Connection closed while reading") from exc
352
+ except asyncio.TimeoutError as exc:
353
+ raise DeviceTimeoutError(self.device_name, "Socket read timed out") from exc
354
+ except Exception as exc:
355
+ await self._cleanup()
356
+ raise DeviceConnectionError(self.device_name, "Socket read error") from exc
357
+
358
+ async def write(self, command: str) -> None:
359
+ if not self.writer:
360
+ raise DeviceConnectionError(self.device_name, "Not connected")
361
+ try:
362
+ self.writer.write(command.encode())
363
+ await asyncio.wait_for(self.writer.drain(), timeout=self.read_timeout)
364
+ except asyncio.TimeoutError as exc:
365
+ raise DeviceTimeoutError(self.device_name, "Socket write timed out") from exc
366
+ except Exception as exc:
367
+ await self._cleanup()
368
+ raise DeviceConnectionError(self.device_name, "Socket write error") from exc
369
+
370
+ async def trans(self, command: str) -> bytes:
371
+ if not self.writer or not self.reader:
372
+ raise DeviceConnectionError(self.device_name, "Not connected")
373
+ try:
374
+ await self.write(command)
375
+ return await self.read()
376
+ except (DeviceTimeoutError, DeviceConnectionError):
377
+ raise
378
+ except Exception as exc:
379
+ await self._cleanup()
380
+ raise DeviceConnectionError(self.device_name, "Socket trans error") from exc
@@ -2283,6 +2283,11 @@ def kebab_to_title(kebab_str: str) -> str:
2283
2283
  return kebab_str.replace("-", " ").title()
2284
2284
 
2285
2285
 
2286
+ def title_to_kebab(title_str: str) -> str:
2287
+ """Convert Title Case (each word capitalized) to kebab-case"""
2288
+ return title_str.replace(" ", "-").lower()
2289
+
2290
+
2286
2291
  def snake_to_title(snake_str: str) -> str:
2287
2292
  """Convert snake_case to Title Case (each word capitalized)"""
2288
2293
  return snake_str.replace("_", " ").title()
@@ -1,20 +0,0 @@
1
- # If you don't have 'just' installed, install it with the following command:
2
- #
3
- # $ uv tool install rust-just
4
- #
5
- # The 'just' website: https://just.systems/man/en/
6
-
7
- default:
8
- @just --list
9
-
10
- typecheck:
11
- uv run --with mypy mypy -p egse --strict
12
-
13
- test:
14
- uv run pytest -v
15
-
16
- format:
17
- uvx ruff format
18
-
19
- check:
20
- uvx ruff check --no-fix
Binary file
File without changes
File without changes
File without changes