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.
- {cgse_common-0.17.3.dist-info → cgse_common-0.18.0.dist-info}/METADATA +1 -1
- {cgse_common-0.17.3.dist-info → cgse_common-0.18.0.dist-info}/RECORD +12 -11
- egse/device.py +71 -1
- egse/env.py +1 -1
- egse/hk.py +2 -2
- egse/log.py +10 -0
- egse/scpi.py +312 -53
- egse/setup.py +7 -4
- egse/socketdevice.py +379 -0
- egse/system.py +6 -1
- {cgse_common-0.17.3.dist-info → cgse_common-0.18.0.dist-info}/WHEEL +0 -0
- {cgse_common-0.17.3.dist-info → cgse_common-0.18.0.dist-info}/entry_points.txt +0 -0
|
@@ -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=
|
|
9
|
+
egse/device.py,sha256=hHDOWUpjLU86SIMiQAH9m8P8BvdgfXoPexISOKjAWy4,13612
|
|
10
10
|
egse/dicts.py,sha256=dUAq7PTPvs73OrZb2Fh3loxvYv4ifUiK6bBcgrFU77Y,3972
|
|
11
|
-
egse/env.py,sha256=
|
|
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=
|
|
15
|
-
egse/log.py,sha256=
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
39
|
-
cgse_common-0.
|
|
40
|
-
cgse_common-0.
|
|
41
|
-
cgse_common-0.
|
|
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
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 = "
|
|
55
|
-
ORIGINAL_EGSE_HK_NAMES = "Original name in
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
184
|
+
raise ValueError(f"{self._device_name}: Hostname is not initialized.")
|
|
92
185
|
|
|
93
186
|
if not self.port:
|
|
94
|
-
raise ValueError(f"{self.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
295
|
+
raise DeviceConnectionError(self._device_name, "Device not connected, use connect() first")
|
|
199
296
|
|
|
200
|
-
# Ensure command ends with
|
|
201
|
-
if not command.endswith(
|
|
202
|
-
command +=
|
|
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.
|
|
307
|
+
raise DeviceTimeoutError(self._device_name, "Write operation timed out") from exc
|
|
209
308
|
except (ConnectionError, OSError) as exc:
|
|
210
|
-
raise DeviceConnectionError(self.
|
|
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.
|
|
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=
|
|
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.
|
|
241
|
-
return exc.partial if exc.partial else
|
|
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.
|
|
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.
|
|
354
|
+
raise DeviceTimeoutError(self._device_name, "Read operation timed out") from exc
|
|
254
355
|
except Exception as exc:
|
|
255
|
-
raise DeviceConnectionError(self.
|
|
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.
|
|
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
|
-
|
|
279
|
-
|
|
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
|
|
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=
|
|
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.
|
|
298
|
-
return exc.partial if exc.partial else
|
|
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.
|
|
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.
|
|
416
|
+
raise DeviceTimeoutError(self._device_name, "Communication timed out") from exc
|
|
311
417
|
except (ConnectionError, OSError) as exc:
|
|
312
|
-
raise DeviceConnectionError(self.
|
|
418
|
+
raise DeviceConnectionError(self._device_name, f"Communication error: {exc}") from exc
|
|
313
419
|
except Exception as exc:
|
|
314
|
-
raise DeviceConnectionError(self.
|
|
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
|
|
940
|
-
|
|
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.
|
|
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()
|
|
File without changes
|
|
File without changes
|