cgse-common 0.17.3__py3-none-any.whl → 0.18.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cgse-common
3
- Version: 0.17.3
3
+ Version: 0.18.0
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>
@@ -6,13 +6,13 @@ egse/calibration.py,sha256=a5JDaXTC6fMwQ1M-qrwNO31Ass-yYSXxDQUK_PPsZg4,8818
6
6
  egse/config.py,sha256=M821_d1IfNT17J3tt_TDjh4bGJgGiYviGBYR0I5v5LA,9639
7
7
  egse/counter.py,sha256=7UwBeTAu213xdNdGAOYpUWNQ4jD4yVM1bOG10Ax4UFs,5097
8
8
  egse/decorators.py,sha256=ZJgXKP8IEniOFd8nHljNeUNpQLAB2Z9q4UiQsnLFzhA,27204
9
- egse/device.py,sha256=gfv5Eazf1nZt3U59w9RwSWnKnAMs4hZrhb4Zv5xBvGE,11431
9
+ egse/device.py,sha256=hHDOWUpjLU86SIMiQAH9m8P8BvdgfXoPexISOKjAWy4,13612
10
10
  egse/dicts.py,sha256=dUAq7PTPvs73OrZb2Fh3loxvYv4ifUiK6bBcgrFU77Y,3972
11
- egse/env.py,sha256=MWftGEmodckKbjXxFGm3WsQRnZ96wCDR3bci0KzZP0Y,31705
11
+ egse/env.py,sha256=Lrx_G8DP8juyWx_Yh2yjhtDBesJZGMw_QWya984-Dr8,31704
12
12
  egse/exceptions.py,sha256=yhOtO5NkCxgvTyVo8fgTyxauX-ZtiEkxDB76ZdCbPrM,1301
13
13
  egse/heartbeat.py,sha256=2SeZzX3tWFog1rgYThX-iaZPwHYq8TVma2ll7r624Eg,3039
14
- egse/hk.py,sha256=AumSpB8SYXes75CB2iiKXfLkMK5IkVDHITFKrf8IT6g,32010
15
- egse/log.py,sha256=5sdVQC2DfBlHbOQzY8ChzLjoL7kNDMErTZFaj97aNBE,5350
14
+ egse/hk.py,sha256=01ejNkJ-RxPO8NPHYB91NryMVBNF9MN5B-BsvkCvzxQ,32006
15
+ egse/log.py,sha256=EhwzRufDOFd7vJVF8_C-q5D2NmO1TGIu6x7ske_JMZI,5547
16
16
  egse/metrics.py,sha256=2hHtJXG0Rn782l2bfmLNBbw6ucC5nf7jPnNzqbhP_Zs,7012
17
17
  egse/observer.py,sha256=xQ7F7NVHqdRZ6IIsBM5M0kMuullMghoR98dwAsjgh0s,1287
18
18
  egse/obsid.py,sha256=y87AYX5mtNEBqEtpEFEec2MhEmo1Hej3Wwi5od84wR8,5848
@@ -24,18 +24,19 @@ egse/randomwalk.py,sha256=dllGv_F4AFYanp_A5ynBsAjglYzxaPYpRCBifwQScx4,6451
24
24
  egse/reload.py,sha256=PzOE0m1tmcNcQPVFH8orMe_cMoQIIiH9Gw2anpQTC40,4717
25
25
  egse/resource.py,sha256=kzNI6kJOE6Jd5QKJs2MkVAycUpwpOTLi1qydh3NSRng,15345
26
26
  egse/response.py,sha256=F04uqOYv1ClpHgDLYZlKTuOCSldHs5TezI_4x6zf2Fw,2717
27
- egse/scpi.py,sha256=DBTvB_p7yaGVW1aNJ6yhxGhWCuSe2uFzMCjFOy3XmZM,13375
27
+ egse/scpi.py,sha256=ta59pGtkfnOx8WunDaedOrrpuDw7vwsHANMWugiufzo,22541
28
28
  egse/settings.py,sha256=eiZ9eGydgF9lNBjHH8VqOgcFDxSdhO6dLs7pYA725lo,16849
29
29
  egse/settings.yaml,sha256=mz9O2QqmiptezsMvxJRLhnC1ROwIHENX0nbnhMaXUpE,190
30
- egse/setup.py,sha256=ezPYA3n1P3navdPR3qDxh0qJvZCzGl2bIREEP9n2w3Y,34116
30
+ egse/setup.py,sha256=GToyqe2esIbxVn4NT3mYIPDaFJgkWZQykbgWDOwChB8,34331
31
31
  egse/signal.py,sha256=f5pyOiNW9iTSIxV_ce5stIfG0ub9MRbaekE85kQOVzs,7992
32
+ egse/socketdevice.py,sha256=rlTX9rpQ8VKcixBD42oNJg67-qKj9IIxk2UPM_mtz6I,14672
32
33
  egse/state.py,sha256=HdU2MFOlYRbawYRZmizV6Y8MgnZrUF0bx4fXaYU-M_s,3023
33
- egse/system.py,sha256=DWKOWkqz5HJMW3526VN7wbMAQv1_EuY3oNeavEydUak,77517
34
+ egse/system.py,sha256=XiMXm5tEB95TsQQsInm_9iZTRDuPqS82VyB_9XMPL8E,77697
34
35
  egse/task.py,sha256=ODSLE05f31CgWsSVcVFFq1WYUZrJMb1LioPTx6VM824,2804
35
36
  egse/version.py,sha256=vPUsCy9HYR7nKm0Sg6EDoq1JtkBKPCr3kYrt9QYM1B8,6602
36
37
  egse/zmq_ser.py,sha256=YJFupsxuvhI8TJMeS2Hem9oMMcVmSBx0rZv93gvN-hA,3263
37
38
  egse/plugins/metrics/influxdb.py,sha256=WnAqTWRkAyMSd7W2ASwUAIEwFborrv55iX-umceevFA,8162
38
- cgse_common-0.17.3.dist-info/METADATA,sha256=tlkWJwh-TWW4AEBHyzl5lDiXk_awl34jJcDiD08bpuI,3068
39
- cgse_common-0.17.3.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
40
- cgse_common-0.17.3.dist-info/entry_points.txt,sha256=xJsPRIDjtADVgd_oEDHVW10wS5LG30Ox_3brVKeyCGw,168
41
- cgse_common-0.17.3.dist-info/RECORD,,
39
+ cgse_common-0.18.0.dist-info/METADATA,sha256=stU8OErbxr__5BPUohIhl9VJ7_Q5dYYqav9yfZSSAYs,3068
40
+ cgse_common-0.18.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
41
+ cgse_common-0.18.0.dist-info/entry_points.txt,sha256=xJsPRIDjtADVgd_oEDHVW10wS5LG30Ox_3brVKeyCGw,168
42
+ cgse_common-0.18.0.dist-info/RECORD,,
egse/device.py CHANGED
@@ -54,7 +54,7 @@ class DeviceControllerError(DeviceError):
54
54
  super().__init__(device_name, message)
55
55
 
56
56
 
57
- class DeviceConnectionError(DeviceError):
57
+ class DeviceConnectionError(DeviceError, ConnectionError):
58
58
  """A generic error for all connection type of problems.
59
59
 
60
60
  Args:
@@ -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.
egse/env.py CHANGED
@@ -170,7 +170,7 @@ def setup_env():
170
170
  return
171
171
 
172
172
  if VERBOSE_DEBUG:
173
- logger.debug(f"Initialising the environment...")
173
+ logger.debug("Initializing the environment...")
174
174
 
175
175
  load_dotenv()
176
176
 
egse/hk.py CHANGED
@@ -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"
egse/log.py CHANGED
@@ -131,6 +131,16 @@ for handler in root_logger.handlers:
131
131
  handler.addFilter(NonEGSEFilter())
132
132
  handler.addFilter(PackageFilter())
133
133
 
134
+ # Optional: integrate with Textual logging if available
135
+ #
136
+ # try:
137
+ # from textual.logging import TextualHandler
138
+
139
+ # root_logger.addHandler(TextualHandler())
140
+ # except ImportError:
141
+ # pass
142
+
143
+
134
144
  logger = egse_logger
135
145
 
136
146
  if __name__ == "__main__":
egse/scpi.py CHANGED
@@ -1,20 +1,28 @@
1
1
  import asyncio
2
+ import re
2
3
  import socket
3
4
  from typing import Any
4
5
  from typing import Dict
5
6
  from typing import Optional
6
7
 
8
+ from egse.device import AsyncDeviceInterface
7
9
  from egse.device import AsyncDeviceTransport
8
10
  from egse.device import DeviceConnectionError
9
11
  from egse.device import DeviceError
10
12
  from egse.device import DeviceTimeoutError
13
+ from egse.env import bool_env
11
14
  from egse.log import logger
12
15
 
13
- # Constants that can be overridden by specific device implementations
14
16
  DEFAULT_READ_TIMEOUT = 1.0 # seconds
15
17
  DEFAULT_CONNECT_TIMEOUT = 3.0 # seconds
18
+ DEFAULT_READ_AFTER_CONNECT = False
16
19
  IDENTIFICATION_QUERY = "*IDN?"
17
20
 
21
+ VERBOSE_DEBUG = bool_env("VERBOSE_DEBUG")
22
+
23
+ SEPARATOR = b"\n"
24
+ SEPARATOR_STR = SEPARATOR.decode()
25
+
18
26
 
19
27
  class SCPICommand:
20
28
  """Base class for SCPI commands."""
@@ -32,7 +40,7 @@ class SCPICommand:
32
40
  raise NotImplementedError("Subclasses must implement get_cmd_string().")
33
41
 
34
42
 
35
- class AsyncSCPIInterface(AsyncDeviceTransport):
43
+ class AsyncSCPIInterface(AsyncDeviceInterface, AsyncDeviceTransport):
36
44
  """Generic asynchronous interface for devices that use SCPI commands over Ethernet."""
37
45
 
38
46
  def __init__(
@@ -43,6 +51,7 @@ class AsyncSCPIInterface(AsyncDeviceTransport):
43
51
  settings: Optional[Dict[str, Any]] = None,
44
52
  connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
45
53
  read_timeout: float = DEFAULT_READ_TIMEOUT,
54
+ read_after_connect: bool = DEFAULT_READ_AFTER_CONNECT,
46
55
  id_validation: Optional[str] = None,
47
56
  ):
48
57
  """Initialize an asynchronous Ethernet interface for SCPI communication.
@@ -54,14 +63,18 @@ class AsyncSCPIInterface(AsyncDeviceTransport):
54
63
  settings: Additional device-specific settings
55
64
  connect_timeout: Timeout for connection attempts in seconds
56
65
  read_timeout: Timeout for read operations in seconds
66
+ read_after_connect: Whether to perform a read operation immediately after connecting
57
67
  id_validation: String that should appear in the device's identification response
58
68
  """
59
- self.device_name = device_name
69
+ super().__init__()
70
+
71
+ self._device_name = device_name
60
72
  self.hostname = hostname
61
73
  self.port = port
62
74
  self.settings = settings or {}
63
75
  self.connect_timeout = connect_timeout
64
76
  self.read_timeout = read_timeout
77
+ self.read_after_connect = read_after_connect
65
78
  self.id_validation = id_validation
66
79
 
67
80
  self._reader = None
@@ -70,8 +83,88 @@ class AsyncSCPIInterface(AsyncDeviceTransport):
70
83
  self._connect_lock = asyncio.Lock()
71
84
  """Prevents multiple, simultaneous connect or disconnect attempts."""
72
85
  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."""
86
+ """Prevents multiple coroutines from attempting to read, write or query from the same stream at the same time."""
87
+
88
+ def is_simulator(self) -> bool:
89
+ return False
90
+
91
+ @property
92
+ def device_name(self) -> str:
93
+ return self._device_name
94
+
95
+ async def get_idn_parts(self) -> list[str]:
96
+ """Get the device identification string and return its parts.
97
+
98
+ The *IDN? command is sent to the device, and the response is split into its
99
+ components: Manufacturer, Model, Serial Number, Firmware Version.
100
+
101
+ Returns:
102
+ A list containing Manufacturer, Model, Serial Number, Firmware Version.
103
+
104
+ Raises:
105
+ DeviceError: When the device is not connected or communication fails.
106
+ """
107
+ if not self._is_connection_open:
108
+ raise DeviceError(self._device_name, "Device not connected, use connect() first")
109
+
110
+ idn_str = (await self.query(IDENTIFICATION_QUERY)).decode().strip()
111
+ if VERBOSE_DEBUG:
112
+ logger.debug(f"{self._device_name} IDN response: {idn_str}")
113
+ return idn_str.split(",")
114
+
115
+ async def initialize(
116
+ self, commands: list[tuple[str, bool]] | None = None, reset_device: bool = False
117
+ ) -> list[str | None]:
118
+ """Initialize the device with optional reset and command sequence.
119
+
120
+ Performs device initialization by optionally resetting the device and then
121
+ executing a sequence of commands. Each command can optionally expect a
122
+ response that will be logged for debugging purposes.
123
+
124
+ Args:
125
+ commands: List of tuples containing (command_string, expects_response).
126
+ Each tuple specifies a command to send and whether to wait for and
127
+ log the response. Defaults to None (no commands executed).
128
+ reset_device: Whether to send a reset command (*RST) before executing
129
+ the command sequence. Defaults to False.
130
+
131
+ Returns:
132
+ Response for each of the commands, or None when no response was expected.
133
+
134
+ Raises:
135
+ Any exceptions raised by the underlying write() or trans() methods,
136
+ typically communication errors or device timeouts.
137
+
138
+ Example:
139
+ await device.initialize(
140
+ [
141
+ ("*IDN?", True), # Query device ID, expect response
142
+ ("SYST:ERR?", True), # Check for errors, expect response
143
+ ("OUTP ON", False), # Enable output, no response expected
144
+ ],
145
+ reset_device=True
146
+ )
147
+ """
148
+
149
+ commands = commands or []
150
+ responses = []
151
+
152
+ if reset_device:
153
+ logger.info(f"Resetting the {self._device_name}...")
154
+ await self.write("*RST") # this also resets the user-defined buffer
155
+
156
+ for cmd, expects_response in commands:
157
+ if expects_response:
158
+ logger.debug(f"Sending {cmd}...")
159
+ response = (await self.trans(cmd)).decode().strip()
160
+ responses.append(response)
161
+ logger.debug(f"{response = }")
162
+ else:
163
+ logger.debug(f"Sending {cmd}...")
164
+ await self.write(cmd)
165
+ responses.append(None)
166
+
167
+ return responses
75
168
 
76
169
  async def connect(self) -> None:
77
170
  """Connect to the device asynchronously.
@@ -84,47 +177,54 @@ class AsyncSCPIInterface(AsyncDeviceTransport):
84
177
  async with self._connect_lock:
85
178
  # Sanity checks
86
179
  if self._is_connection_open:
87
- logger.warning(f"{self.device_name}: Trying to connect to an already connected device.")
180
+ logger.warning(f"{self._device_name}: Trying to connect to an already connected device.")
88
181
  return
89
182
 
90
183
  if not self.hostname:
91
- raise ValueError(f"{self.device_name}: Hostname is not initialized.")
184
+ raise ValueError(f"{self._device_name}: Hostname is not initialized.")
92
185
 
93
186
  if not self.port:
94
- raise ValueError(f"{self.device_name}: Port number is not initialized.")
187
+ raise ValueError(f"{self._device_name}: Port number is not initialized.")
95
188
 
96
189
  # Attempt to establish a connection
97
190
  try:
98
- logger.debug(f'Connecting to {self.device_name} at "{self.hostname}" using port {self.port}')
191
+ logger.debug(f'Connecting to {self._device_name} at "{self.hostname}" using port {self.port}')
99
192
 
100
193
  connect_task = asyncio.open_connection(self.hostname, self.port)
101
194
  self._reader, self._writer = await asyncio.wait_for(connect_task, timeout=self.connect_timeout)
102
195
 
103
196
  self._is_connection_open = True
104
197
 
105
- logger.debug(f"Successfully connected to {self.device_name}.")
198
+ logger.debug(f"Successfully connected to {self._device_name}.")
106
199
 
107
200
  except asyncio.TimeoutError as exc:
108
201
  raise DeviceTimeoutError(
109
- self.device_name, f"Connection to {self.hostname}:{self.port} timed out"
202
+ self._device_name, f"Connection to {self.hostname}:{self.port} timed out"
110
203
  ) from exc
111
204
  except ConnectionRefusedError as exc:
112
205
  raise DeviceConnectionError(
113
- self.device_name, f"Connection refused to {self.hostname}:{self.port}"
206
+ self._device_name, f"Connection refused to {self.hostname}:{self.port}"
114
207
  ) from exc
115
208
  except socket.gaierror as exc:
116
- raise DeviceConnectionError(self.device_name, f"Address resolution error for {self.hostname}") from exc
209
+ raise DeviceConnectionError(self._device_name, f"Address resolution error for {self.hostname}") from exc
117
210
  except socket.herror as exc:
118
- raise DeviceConnectionError(self.device_name, f"Host address error for {self.hostname}") from exc
211
+ raise DeviceConnectionError(self._device_name, f"Host address error for {self.hostname}") from exc
119
212
  except OSError as exc:
120
- raise DeviceConnectionError(self.device_name, f"OS error: {exc}") from exc
213
+ raise DeviceConnectionError(self._device_name, f"OS error: {exc}") from exc
214
+
215
+ # Some devices require an initial read after connection.
216
+ # We have to read this message from the buffer and can do some version checking if needed.
217
+ if self.read_after_connect:
218
+ response = await self.read_string()
219
+ if VERBOSE_DEBUG:
220
+ logger.debug(f"Response after connection: {response}")
121
221
 
122
222
  # Validate device identity if requested
123
223
  if self.id_validation:
124
224
  logger.debug("Validating connection..")
125
225
  if not await self.is_connected():
126
226
  await self.disconnect()
127
- raise DeviceConnectionError(self.device_name, "Device connected but failed identity verification")
227
+ raise DeviceConnectionError(self._device_name, "Device connected but failed identity verification")
128
228
 
129
229
  async def disconnect(self) -> None:
130
230
  """Disconnect from the device asynchronously.
@@ -135,22 +235,20 @@ class AsyncSCPIInterface(AsyncDeviceTransport):
135
235
  async with self._connect_lock:
136
236
  try:
137
237
  if self._is_connection_open and self._writer is not None:
138
- logger.debug(f"Disconnecting from {self.device_name} at {self.hostname}")
238
+ logger.debug(f"Disconnecting from {self._device_name} at {self.hostname}")
139
239
  self._writer.close()
140
240
  await self._writer.wait_closed()
141
241
  self._writer = None
142
242
  self._reader = None
143
243
  self._is_connection_open = False
144
244
  except Exception as exc:
145
- raise DeviceConnectionError(self.device_name, f"Could not close connection: {exc}") from exc
245
+ raise DeviceConnectionError(self._device_name, f"Could not close connection: {exc}") from exc
146
246
 
147
247
  async def reconnect(self) -> None:
148
248
  """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()
249
+ await self.disconnect()
250
+ await asyncio.sleep(0.1)
251
+ await self.connect()
154
252
 
155
253
  async def is_connected(self) -> bool:
156
254
  """Check if the device is connected and responds correctly to identification.
@@ -168,7 +266,7 @@ class AsyncSCPIInterface(AsyncDeviceTransport):
168
266
  # Validate the response if validation string is provided
169
267
  if self.id_validation and self.id_validation not in id_response:
170
268
  logger.error(
171
- f"{self.device_name}: Device did not respond correctly to identification query. "
269
+ f"{self._device_name}: Device did not respond correctly to identification query. "
172
270
  f'Expected "{self.id_validation}" in response, got: {id_response}'
173
271
  )
174
272
  await self.disconnect()
@@ -177,8 +275,7 @@ class AsyncSCPIInterface(AsyncDeviceTransport):
177
275
  return True
178
276
 
179
277
  except DeviceError as exc:
180
- logger.exception(exc)
181
- logger.error(f"{self.device_name}: Connection test failed")
278
+ logger.error(f"{self._device_name}: Connection test failed: {exc}", exc_info=True)
182
279
  await self.disconnect()
183
280
  return False
184
281
 
@@ -195,19 +292,21 @@ class AsyncSCPIInterface(AsyncDeviceTransport):
195
292
  async with self._io_lock:
196
293
  try:
197
294
  if not self._is_connection_open or self._writer is None:
198
- raise DeviceConnectionError(self.device_name, "Device not connected, use connect() first")
295
+ raise DeviceConnectionError(self._device_name, "Device not connected, use connect() first")
199
296
 
200
- # Ensure command ends with newline
201
- if not command.endswith("\n"):
202
- command += "\n"
297
+ # Ensure command ends with the proper separator or terminator
298
+ if not command.endswith(SEPARATOR_STR):
299
+ command += SEPARATOR_STR
203
300
 
301
+ if VERBOSE_DEBUG:
302
+ logger.debug(f"-----> {command}")
204
303
  self._writer.write(command.encode())
205
304
  await self._writer.drain()
206
305
 
207
306
  except asyncio.TimeoutError as exc:
208
- raise DeviceTimeoutError(self.device_name, "Write operation timed out") from exc
307
+ raise DeviceTimeoutError(self._device_name, "Write operation timed out") from exc
209
308
  except (ConnectionError, OSError) as exc:
210
- raise DeviceConnectionError(self.device_name, f"Communication error: {exc}") from exc
309
+ raise DeviceConnectionError(self._device_name, f"Communication error: {exc}") from exc
211
310
 
212
311
  async def read(self) -> bytes:
213
312
  """
@@ -222,7 +321,7 @@ class AsyncSCPIInterface(AsyncDeviceTransport):
222
321
  """
223
322
  async with self._io_lock:
224
323
  if not self._is_connection_open or self._reader is None:
225
- raise DeviceConnectionError(self.device_name, "Device not connected, use connect() first")
324
+ raise DeviceConnectionError(self._device_name, "Device not connected, use connect() first")
226
325
 
227
326
  try:
228
327
  # First, small delay to allow device to prepare response
@@ -231,18 +330,20 @@ class AsyncSCPIInterface(AsyncDeviceTransport):
231
330
  # Try to read until newline (common SCPI terminator)
232
331
  try:
233
332
  response = await asyncio.wait_for(
234
- self._reader.readuntil(separator=b"\n"), timeout=self.read_timeout
333
+ self._reader.readuntil(separator=SEPARATOR), timeout=self.read_timeout
235
334
  )
335
+ if VERBOSE_DEBUG:
336
+ logger.debug(f"<----- {response}")
236
337
  return response
237
338
 
238
339
  except asyncio.IncompleteReadError as exc:
239
340
  # 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"
341
+ logger.warning(f"{self._device_name}: Incomplete read, got {len(exc.partial)} bytes")
342
+ return exc.partial if exc.partial else SEPARATOR
242
343
 
243
344
  except asyncio.LimitOverrunError:
244
345
  # Response too large for buffer
245
- logger.warning(f"{self.device_name}: Response exceeded buffer limits")
346
+ logger.warning(f"{self._device_name}: Response exceeded buffer limits")
246
347
  # Fall back to reading a large chunk
247
348
  return await asyncio.wait_for(
248
349
  self._reader.read(8192), # Larger buffer for exceptional cases
@@ -250,9 +351,9 @@ class AsyncSCPIInterface(AsyncDeviceTransport):
250
351
  )
251
352
 
252
353
  except asyncio.TimeoutError as exc:
253
- raise DeviceTimeoutError(self.device_name, "Read operation timed out") from exc
354
+ raise DeviceTimeoutError(self._device_name, "Read operation timed out") from exc
254
355
  except Exception as exc:
255
- raise DeviceConnectionError(self.device_name, f"Read error: {exc}") from exc
356
+ raise DeviceConnectionError(self._device_name, f"Read error: {exc}") from exc
256
357
 
257
358
  async def trans(self, command: str) -> bytes:
258
359
  """
@@ -272,34 +373,39 @@ class AsyncSCPIInterface(AsyncDeviceTransport):
272
373
 
273
374
  async with self._io_lock:
274
375
  try:
275
- if not self._is_connection_open or self._writer is None:
276
- raise DeviceConnectionError(self.device_name, "Device not connected, use connect() first")
376
+ if not self._is_connection_open or self._writer is None or self._reader is None:
377
+ raise DeviceConnectionError(self._device_name, "Device not connected, use connect() first")
378
+
379
+ # Ensure command ends with the required terminator
380
+ if not command.endswith(SEPARATOR_STR):
381
+ command += SEPARATOR_STR
277
382
 
278
- # Ensure command ends with newline
279
- if not command.endswith("\n"):
280
- command += "\n"
383
+ if VERBOSE_DEBUG:
384
+ logger.debug(f"-----> {command=}")
281
385
 
282
386
  self._writer.write(command.encode())
283
387
  await self._writer.drain()
284
388
 
285
- # First, small delay to allow device to prepare response
389
+ # First, small delay to allow the device to prepare a response
286
390
  await asyncio.sleep(0.01)
287
391
 
288
- # Try to read until newline (common SCPI terminator)
392
+ # Try to read until the required separator (common SCPI terminator)
289
393
  try:
290
394
  response = await asyncio.wait_for(
291
- self._reader.readuntil(separator=b"\n"), timeout=self.read_timeout
395
+ self._reader.readuntil(separator=SEPARATOR), timeout=self.read_timeout
292
396
  )
397
+ if VERBOSE_DEBUG:
398
+ logger.debug(f"<----- {response=}")
293
399
  return response
294
400
 
295
401
  except asyncio.IncompleteReadError as exc:
296
402
  # 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"
403
+ logger.warning(f"{self._device_name}: Incomplete read, got {len(exc.partial)} bytes")
404
+ return exc.partial if exc.partial else SEPARATOR
299
405
 
300
406
  except asyncio.LimitOverrunError:
301
407
  # Response too large for buffer
302
- logger.warning(f"{self.device_name}: Response exceeded buffer limits")
408
+ logger.warning(f"{self._device_name}: Response exceeded buffer limits")
303
409
  # Fall back to reading a large chunk
304
410
  return await asyncio.wait_for(
305
411
  self._reader.read(8192), # Larger buffer for exceptional cases
@@ -307,11 +413,11 @@ class AsyncSCPIInterface(AsyncDeviceTransport):
307
413
  )
308
414
 
309
415
  except asyncio.TimeoutError as exc:
310
- raise DeviceTimeoutError(self.device_name, "Communication timed out") from exc
416
+ raise DeviceTimeoutError(self._device_name, "Communication timed out") from exc
311
417
  except (ConnectionError, OSError) as exc:
312
- raise DeviceConnectionError(self.device_name, f"Communication error: {exc}") from exc
418
+ raise DeviceConnectionError(self._device_name, f"Communication error: {exc}") from exc
313
419
  except Exception as exc:
314
- raise DeviceConnectionError(self.device_name, f"Transaction error: {exc}") from exc
420
+ raise DeviceConnectionError(self._device_name, f"Transaction error: {exc}") from exc
315
421
 
316
422
  async def __aenter__(self):
317
423
  """Async context manager entry."""
@@ -321,3 +427,156 @@ class AsyncSCPIInterface(AsyncDeviceTransport):
321
427
  async def __aexit__(self, exc_type, exc_val, exc_tb):
322
428
  """Async context manager exit."""
323
429
  await self.disconnect()
430
+
431
+
432
+ def create_channel_list(*args) -> str:
433
+ """
434
+ Create a channel list that is understood by SCPI commands.
435
+
436
+ Channel names are device-specific.
437
+
438
+ For the DAQ6510: Channel names contain both the slot number and the channel number.
439
+ The slot number is the number of the slot where the card is installed at the back of
440
+ the device.
441
+
442
+ When addressing multiple individual channels, add each of them as a separate argument,
443
+ e.g. to include channels 1, 3, and 7 from slot 1, use the following command:
444
+
445
+ >>> create_channel_list(101, 103, 107)
446
+ '(@101, 103, 107)'
447
+
448
+ To designate a range of channels, only one argument should be given, i.e. a tuple containing
449
+ two channel representing the range. The following tuple `(101, 110)` will create the
450
+ following response: `"(@101:110)"`. The range is inclusive, so this will define a range of
451
+ 10 channels in slot 1.
452
+
453
+ >>> create_channel_list((201, 205))
454
+ '(@201:205)'
455
+
456
+ See reference manual for the Keithley DAQ6510 [DAQ6510-901-01 Rev. B / September 2019],
457
+ chapter 11: Introduction to SCPI commands, SCPI command formatting, channel naming.
458
+
459
+ Args:
460
+ *args: a tuple or a list of channels
461
+
462
+ Returns:
463
+ A string containing the channel list as understood by the SCPI device.
464
+
465
+ """
466
+ if not args:
467
+ return ""
468
+
469
+ # If only one argument is given, I expect either a tuple defining a range
470
+ # or just one channel. When several arguments are given, I expect them all
471
+ # to be individual channels.
472
+
473
+ ch_list = []
474
+ for arg in args:
475
+ if isinstance(arg, (tuple, list)):
476
+ match len(arg):
477
+ case 2:
478
+ ch_list.append(f"{arg[0]}:{arg[1]}")
479
+ case 1:
480
+ ch_list.append(f"{arg[0]}")
481
+ case _:
482
+ raise ValueError(
483
+ "Invalid argument: when providing a tuple or list, it must contain one or two elements."
484
+ )
485
+ else:
486
+ ch_list.append(f"{arg}")
487
+
488
+ # else:
489
+ # ch_list = "(@" + ",".join([str(arg) for arg in args]) + ")"
490
+
491
+ return "(@" + ",".join(x for x in ch_list if x.isdigit() or ":" in x) + ")"
492
+
493
+
494
+ def count_number_of_channels(channel_list: str) -> int:
495
+ """
496
+ Given a proper channel list, this function counts the number of channels.
497
+ For ranges, it returns the actual number of channels that are included in the range.
498
+
499
+ >>> count_number_of_channels("(@1,2,3,4,5)")
500
+ 5
501
+ >>> count_number_of_channels("(@1, 3, 5)")
502
+ 3
503
+ >>> count_number_of_channels("(@2:7)")
504
+ 6
505
+
506
+ Args:
507
+ channel_list: a channel list as understood by the SCPI commands of DAQ6510.
508
+
509
+ Returns:
510
+ The number of channels in the list.
511
+ """
512
+
513
+ match = re.match(r"\(@(.*)\)", channel_list)
514
+ if match is None:
515
+ logger.error(f"Invalid channel specification in '{channel_list}'")
516
+ return 0
517
+ group = match.groups()[0]
518
+
519
+ parts = group.replace(" ", "").split(",")
520
+
521
+ try:
522
+ count = 0
523
+ for part in parts:
524
+ if ":" in part:
525
+ channels = part.split(":")
526
+ if len(channels) != 2:
527
+ raise ValueError()
528
+ count += int(channels[1]) - int(channels[0]) + 1
529
+ else:
530
+ if not part.isdigit():
531
+ raise ValueError()
532
+ count += 1
533
+ except ValueError:
534
+ logger.error(f"Invalid channel specification in '{channel_list}'")
535
+ return 0
536
+
537
+ return count
538
+
539
+
540
+ def get_channel_names(channel_list: str) -> list[str]:
541
+ """
542
+ Generate a list of channel names from a given channel list.
543
+
544
+ Args:
545
+ channel_list: a channel list as understood by the SCPI.
546
+
547
+ Returns:
548
+ A list of channel names.
549
+ """
550
+
551
+ match = re.match(r"\(@(.*)\)", channel_list)
552
+ if match is None:
553
+ logger.error(f"Invalid channel specification in '{channel_list}'")
554
+ return []
555
+
556
+ group = match.groups()[0]
557
+
558
+ parts = group.replace(" ", "").split(",")
559
+
560
+ try:
561
+ names: list[str] = []
562
+
563
+ for part in parts:
564
+ if ":" in part:
565
+ channels = part.split(":")
566
+ if len(channels) != 2:
567
+ raise ValueError()
568
+ names.extend(str(ch) for ch in range(int(channels[0]), int(channels[1]) + 1))
569
+ else:
570
+ if not part.isdigit():
571
+ raise ValueError()
572
+ names.append(part)
573
+
574
+ # If there are still any invalid names, raise an error
575
+ if not all(True if x and x.isdigit() else False for x in names):
576
+ raise ValueError()
577
+
578
+ except ValueError:
579
+ logger.error(f"Invalid channel specification in '{channel_list}'")
580
+ return []
581
+
582
+ return names
egse/setup.py CHANGED
@@ -936,9 +936,13 @@ class SetupManager:
936
936
  def set_default_source(self, source: str):
937
937
  self._default_source = source
938
938
 
939
- def load_setup(self, setup_id: int = None, **kwargs):
940
- source = kwargs.get("source") or self._default_source
939
+ def get_default_source(self):
940
+ # Trigger provider discovery because they can change the default source if they handle core-services
941
+ _ = self.providers
942
+ return self._default_source
941
943
 
944
+ def load_setup(self, setup_id: int = None, **kwargs):
945
+ source = kwargs.get("source") or self.get_default_source()
942
946
  for provider in self.providers:
943
947
  if provider.can_handle(source):
944
948
  return provider.load_setup(setup_id, **kwargs)
@@ -947,7 +951,7 @@ class SetupManager:
947
951
  return LocalSetupProvider().load_setup(setup_id, **kwargs)
948
952
 
949
953
  def submit_setup(self, setup: Setup, description: str, **kwargs):
950
- source = kwargs.get("source") or self._default_source
954
+ source = kwargs.get("source") or self.get_default_source()
951
955
  for provider in self.providers:
952
956
  if provider.can_handle(source):
953
957
  return provider.submit_setup(setup, description, **kwargs)
@@ -957,7 +961,6 @@ class SetupManager:
957
961
 
958
962
  _setup_manager = SetupManager()
959
963
 
960
-
961
964
  if __name__ == "__main__":
962
965
  from egse.env import setup_env
963
966
 
egse/socketdevice.py ADDED
@@ -0,0 +1,379 @@
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.separator_str = separator.decode()
49
+ self.socket = None
50
+
51
+ @property
52
+ def device_name(self):
53
+ """The name of the device that this interface connects to."""
54
+ return f"SocketDevice({self.hostname}:{self.port})"
55
+
56
+ def connect(self):
57
+ """
58
+ Connect the device.
59
+
60
+ Raises:
61
+ ConnectionError: When the connection could not be established.
62
+ Check the logging messages for more detail.
63
+ TimeoutError: When the connection timed out.
64
+ ValueError: When hostname or port number are not provided.
65
+ """
66
+
67
+ # Sanity checks
68
+
69
+ if self.is_connection_open:
70
+ logger.warning(f"{self.device_name}: trying to connect to an already connected socket.")
71
+ return
72
+
73
+ if self.hostname in (None, ""):
74
+ raise ValueError(f"{self.device_name}: hostname is not initialized.")
75
+
76
+ if self.port in (None, 0):
77
+ raise ValueError(f"{self.device_name}: port number is not initialized.")
78
+
79
+ # Create a new socket instance
80
+
81
+ try:
82
+ self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
83
+ except socket.error as exc:
84
+ raise ConnectionError(f"{self.device_name}: Failed to create socket.") from exc
85
+
86
+ try:
87
+ logger.debug(f'Connecting a socket to host "{self.hostname}" using port {self.port}')
88
+ self.socket.settimeout(self.connect_timeout)
89
+ self.socket.connect((self.hostname, self.port))
90
+ self.socket.settimeout(None)
91
+ except ConnectionRefusedError as exc:
92
+ raise ConnectionError(f"{self.device_name}: Connection refused to {self.hostname}:{self.port}.") from exc
93
+ except TimeoutError as exc:
94
+ raise TimeoutError(f"{self.device_name}: Connection to {self.hostname}:{self.port} timed out.") from exc
95
+ except socket.gaierror as exc:
96
+ raise ConnectionError(f"{self.device_name}: Socket address info error for {self.hostname}") from exc
97
+ except socket.herror as exc:
98
+ raise ConnectionError(f"{self.device_name}: Socket host address error for {self.hostname}") from exc
99
+ except OSError as exc:
100
+ raise ConnectionError(f"{self.device_name}: OSError caught ({exc}).") from exc
101
+
102
+ self.is_connection_open = True
103
+
104
+ def disconnect(self):
105
+ """
106
+ Disconnect from the Ethernet connection.
107
+
108
+ Raises:
109
+ ConnectionError when the socket could not be closed.
110
+ """
111
+
112
+ assert self.socket is not None # extra check + for mypy type checking
113
+
114
+ try:
115
+ if self.is_connection_open:
116
+ logger.debug(f"Disconnecting from {self.hostname}")
117
+ self.socket.close()
118
+ self.is_connection_open = False
119
+ except Exception as e_exc:
120
+ raise ConnectionError(f"{self.device_name}: Could not close socket to {self.hostname}") from e_exc
121
+
122
+ def is_connected(self) -> bool:
123
+ """
124
+ Check if the device is connected.
125
+
126
+ Returns:
127
+ True is the device is connected, False otherwise.
128
+ """
129
+
130
+ return bool(self.is_connection_open)
131
+
132
+ def reconnect(self):
133
+ """
134
+ Reconnect to the device. If the connection is open, this function will first disconnect
135
+ and then connect again.
136
+ """
137
+
138
+ if self.is_connection_open:
139
+ self.disconnect()
140
+ self.connect()
141
+
142
+ def read(self) -> bytes:
143
+ """
144
+ Read until ETX (b'\x03') or until `self.read_timeout` elapses.
145
+ Uses `select` to avoid blocking indefinitely when no data is available.
146
+ If `self.read_timeout` was set to None in the constructor, this will block anyway.
147
+ """
148
+ if not self.socket:
149
+ raise DeviceConnectionError(self.device_name, "The device is not connected, connect before reading.")
150
+
151
+ buf_size = 1024 * 4
152
+ response = bytearray()
153
+
154
+ # If read_timeout is None we preserve blocking behavior; otherwise enforce overall timeout.
155
+ if self.read_timeout is None:
156
+ end_time = None
157
+ else:
158
+ end_time = time.monotonic() + self.read_timeout
159
+
160
+ try:
161
+ while True:
162
+ # compute the remaining timeout for select, this is needed because we read in different parts
163
+ # until ETX is received, and we want to receive the complete messages including ETX within
164
+ # the read timeout.
165
+ if end_time is None:
166
+ timeout = None
167
+ else:
168
+ remaining = end_time - time.monotonic()
169
+ if remaining <= 0.0:
170
+ raise DeviceTimeoutError(self.device_name, "Socket read timed out")
171
+ timeout = remaining
172
+
173
+ ready, _, _ = select.select([self.socket], [], [], timeout)
174
+
175
+ if not ready:
176
+ # no socket ready within timeout
177
+ raise DeviceTimeoutError(self.device_name, "Socket read timed out")
178
+
179
+ try:
180
+ data = self.socket.recv(buf_size)
181
+ except OSError as exc:
182
+ raise DeviceConnectionError(self.device_name, f"Caught {type_name(exc)}: {exc}") from exc
183
+
184
+ if not data:
185
+ # remote closed connection (EOF)
186
+ raise DeviceConnectionError(self.device_name, "Connection closed by peer")
187
+
188
+ response.extend(data)
189
+
190
+ if self.separator in response:
191
+ break
192
+
193
+ except DeviceTimeoutError:
194
+ raise
195
+ except DeviceConnectionError:
196
+ raise
197
+ except Exception as exc:
198
+ # unexpected errors - translate to DeviceConnectionError
199
+ raise DeviceConnectionError(self.device_name, "Socket read error") from exc
200
+
201
+ return bytes(response)
202
+
203
+ def write(self, command: str):
204
+ """
205
+ Send a command to the device.
206
+
207
+ No processing is done on the command string, except for the encoding into a bytes object.
208
+
209
+ Args:
210
+ command: the command string including terminators.
211
+
212
+ Raises:
213
+ A DeviceTimeoutError when the send timed out, and a DeviceConnectionError if
214
+ there was a socket related error.
215
+ """
216
+
217
+ if not self.socket:
218
+ raise DeviceConnectionError(self.device_name, "The device is not connected, connect before writing.")
219
+
220
+ try:
221
+ command += self.separator_str if not command.endswith(self.separator_str) else ""
222
+ self.socket.sendall(command.encode())
223
+ except socket.timeout as exc:
224
+ raise DeviceTimeoutError(self.device_name, "Socket timeout error") from exc
225
+ except socket.error as exc:
226
+ # Interpret any socket-related error as an I/O error
227
+ raise DeviceConnectionError(self.device_name, "Socket communication error.") from exc
228
+
229
+ def trans(self, command: str) -> bytes:
230
+ """
231
+ Send a command to the device and wait for the response.
232
+
233
+ No processing is done on the command string, except for the encoding into a bytes object.
234
+
235
+ Args:
236
+ command: the command string including terminators.
237
+
238
+ Returns:
239
+ A bytes object containing the response from the device. No processing is done
240
+ on the response.
241
+
242
+ Raises:
243
+ A DeviceTimeoutError when the send timed out, and a DeviceConnectionError if
244
+ there was a socket related error.
245
+ """
246
+
247
+ if not self.socket:
248
+ raise DeviceConnectionError(self.device_name, "The device is not connected, connect before writing.")
249
+
250
+ self.write(command)
251
+
252
+ response = self.read()
253
+
254
+ return response
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: bytes = SEPARATOR,
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
egse/system.py CHANGED
@@ -379,7 +379,7 @@ def ignore_m_warning(modules=None):
379
379
  pass
380
380
 
381
381
 
382
- def now(utc: bool = True):
382
+ def now(utc: bool = True) -> datetime.datetime:
383
383
  """Returns a datetime object for the current time in UTC or local time."""
384
384
  if utc:
385
385
  return datetime.datetime.now(tz=datetime.timezone.utc)
@@ -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()