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