cgse-common 0.17.2__py3-none-any.whl → 0.17.3__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.2.dist-info → cgse_common-0.17.3.dist-info}/METADATA +1 -1
- {cgse_common-0.17.2.dist-info → cgse_common-0.17.3.dist-info}/RECORD +6 -10
- egse/device.py +0 -70
- egse/scpi.py +52 -123
- egse/plugins/metrics/duckdb.py +0 -442
- egse/plugins/metrics/timescaledb.py +0 -596
- egse/ratelimit.py +0 -275
- egse/socketdevice.py +0 -379
- {cgse_common-0.17.2.dist-info → cgse_common-0.17.3.dist-info}/WHEEL +0 -0
- {cgse_common-0.17.2.dist-info → cgse_common-0.17.3.dist-info}/entry_points.txt +0 -0
egse/ratelimit.py
DELETED
|
@@ -1,275 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Rate limiting utilities for controlling execution frequency of functions and code blocks.
|
|
3
|
-
|
|
4
|
-
This module provides decorators, context managers, and utility functions to limit how
|
|
5
|
-
often code executes, which is particularly useful for:
|
|
6
|
-
|
|
7
|
-
- Reducing noisy output in loops (print statements, logging)
|
|
8
|
-
- Throttling expensive operations
|
|
9
|
-
- Controlling debug message frequency
|
|
10
|
-
- Implementing backpressure in data processing pipelines
|
|
11
|
-
|
|
12
|
-
The rate limiting tracks execution counts per unique key and shows suppressed message
|
|
13
|
-
counts similar to system loggers (e.g., "... 5 more similar messages suppressed").
|
|
14
|
-
|
|
15
|
-
Basic Usage:
|
|
16
|
-
# Decorator - limit function execution
|
|
17
|
-
@rate_limit(every_n=10)
|
|
18
|
-
def debug_function():
|
|
19
|
-
print("Debug info")
|
|
20
|
-
|
|
21
|
-
# Context manager - limit code block execution
|
|
22
|
-
for i in range(100):
|
|
23
|
-
with rate_limited(every_n=10, key="progress") as should_execute:
|
|
24
|
-
if should_execute:
|
|
25
|
-
print(f"Progress: {i}")
|
|
26
|
-
|
|
27
|
-
# Convenience functions
|
|
28
|
-
for i in range(100):
|
|
29
|
-
rate_limited_print(f"Processing item {i}", every_n=10)
|
|
30
|
-
|
|
31
|
-
Advanced Usage:
|
|
32
|
-
# Custom rate limiter instances for isolation
|
|
33
|
-
debug_limiter = RateLimiter()
|
|
34
|
-
|
|
35
|
-
@rate_limit(every_n=5, limiter=debug_limiter)
|
|
36
|
-
def isolated_debug():
|
|
37
|
-
print("This uses a separate counter")
|
|
38
|
-
|
|
39
|
-
# Rate-limited logging with suppression tracking
|
|
40
|
-
import logging
|
|
41
|
-
logger = logging.getLogger(__name__)
|
|
42
|
-
|
|
43
|
-
for i in range(50):
|
|
44
|
-
rate_limited_log(logger, logging.WARNING,
|
|
45
|
-
"High CPU usage", every_n=10)
|
|
46
|
-
|
|
47
|
-
Classes:
|
|
48
|
-
RateLimiter: Core rate limiting logic with per-key counters
|
|
49
|
-
|
|
50
|
-
Functions:
|
|
51
|
-
rate_limit: Decorator for rate-limiting function calls
|
|
52
|
-
rate_limited: Context manager for rate-limiting code blocks
|
|
53
|
-
rate_limited_print: Convenience function for rate-limited printing
|
|
54
|
-
rate_limited_log: Convenience function for rate-limited logging
|
|
55
|
-
|
|
56
|
-
Author: Generated by Claude
|
|
57
|
-
License: MIT
|
|
58
|
-
"""
|
|
59
|
-
|
|
60
|
-
import time
|
|
61
|
-
import logging
|
|
62
|
-
from functools import wraps
|
|
63
|
-
from collections import defaultdict
|
|
64
|
-
from contextlib import contextmanager
|
|
65
|
-
from typing import Dict, Any, Optional, Callable
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
class RateLimiter:
|
|
69
|
-
"""A rate limiter that tracks execution counts and provides rate limiting functionality."""
|
|
70
|
-
|
|
71
|
-
def __init__(self):
|
|
72
|
-
self.counters: Dict[str, int] = defaultdict(int)
|
|
73
|
-
self.last_executed: Dict[str, int] = defaultdict(int)
|
|
74
|
-
self.suppressed_count: Dict[str, int] = defaultdict(int)
|
|
75
|
-
|
|
76
|
-
def should_execute(self, key: str, every_n: int) -> tuple[bool, int]:
|
|
77
|
-
"""
|
|
78
|
-
Check if an operation should be executed based on rate limiting.
|
|
79
|
-
|
|
80
|
-
Returns:
|
|
81
|
-
tuple: (should_execute: bool, suppressed_count: int)
|
|
82
|
-
"""
|
|
83
|
-
self.counters[key] += 1
|
|
84
|
-
|
|
85
|
-
# Execute on 1st call, then every every_n calls after that
|
|
86
|
-
if self.counters[key] == 1 or (self.counters[key] - 1) % every_n == 0:
|
|
87
|
-
# For the first call, no suppressed count to return
|
|
88
|
-
if self.counters[key] == 1:
|
|
89
|
-
return True, 0
|
|
90
|
-
else:
|
|
91
|
-
# For subsequent executions, return the suppressed count and reset it
|
|
92
|
-
suppressed = self.suppressed_count[key]
|
|
93
|
-
self.suppressed_count[key] = 0
|
|
94
|
-
return True, suppressed
|
|
95
|
-
else:
|
|
96
|
-
self.suppressed_count[key] += 1
|
|
97
|
-
return False, 0
|
|
98
|
-
|
|
99
|
-
def reset(self, key: Optional[str] = None):
|
|
100
|
-
"""Reset counters for a specific key or all keys."""
|
|
101
|
-
if key:
|
|
102
|
-
self.counters[key] = 0
|
|
103
|
-
self.last_executed[key] = 0
|
|
104
|
-
self.suppressed_count[key] = 0
|
|
105
|
-
else:
|
|
106
|
-
self.counters.clear()
|
|
107
|
-
self.last_executed.clear()
|
|
108
|
-
self.suppressed_count.clear()
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
# Global rate limiter instance
|
|
112
|
-
_global_limiter = RateLimiter()
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def rate_limit(
|
|
116
|
-
every_n: int = 10, key: Optional[str] = None, show_suppressed: bool = True, limiter: Optional[RateLimiter] = None
|
|
117
|
-
):
|
|
118
|
-
"""
|
|
119
|
-
Decorator that limits function execution to every N calls.
|
|
120
|
-
|
|
121
|
-
Args:
|
|
122
|
-
every_n: Execute only every N calls
|
|
123
|
-
key: Unique identifier for this rate limiter (auto-generated if None)
|
|
124
|
-
show_suppressed: Whether to show "... N more suppressed" message
|
|
125
|
-
limiter: Custom RateLimiter instance (uses global one if None)
|
|
126
|
-
"""
|
|
127
|
-
|
|
128
|
-
def decorator(func: Callable) -> Callable:
|
|
129
|
-
nonlocal key
|
|
130
|
-
if key is None:
|
|
131
|
-
key = f"{func.__module__}.{func.__qualname__}"
|
|
132
|
-
|
|
133
|
-
rate_limiter = limiter or _global_limiter
|
|
134
|
-
|
|
135
|
-
@wraps(func)
|
|
136
|
-
def wrapper(*args, **kwargs):
|
|
137
|
-
should_execute, suppressed = rate_limiter.should_execute(key, every_n)
|
|
138
|
-
|
|
139
|
-
if should_execute:
|
|
140
|
-
# Show suppressed count BEFORE executing (except for first execution)
|
|
141
|
-
if show_suppressed and suppressed > 0:
|
|
142
|
-
print(f"... {suppressed} more similar messages suppressed")
|
|
143
|
-
|
|
144
|
-
result = func(*args, **kwargs)
|
|
145
|
-
return result
|
|
146
|
-
|
|
147
|
-
return None # Suppressed execution
|
|
148
|
-
|
|
149
|
-
# Add utility methods to the wrapper
|
|
150
|
-
wrapper.reset = lambda: rate_limiter.reset(key)
|
|
151
|
-
wrapper.get_count = lambda: rate_limiter.counters[key]
|
|
152
|
-
|
|
153
|
-
return wrapper
|
|
154
|
-
|
|
155
|
-
return decorator
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
@contextmanager
|
|
159
|
-
def rate_limited(
|
|
160
|
-
every_n: int = 10, key: Optional[str] = None, show_suppressed: bool = True, limiter: Optional[RateLimiter] = None
|
|
161
|
-
):
|
|
162
|
-
"""
|
|
163
|
-
Context manager that limits execution of code block to every N calls.
|
|
164
|
-
|
|
165
|
-
Args:
|
|
166
|
-
every_n: Execute only every N calls
|
|
167
|
-
key: Unique identifier for this rate limiter (required for context manager)
|
|
168
|
-
show_suppressed: Whether to show "... N more suppressed" message
|
|
169
|
-
limiter: Custom RateLimiter instance (uses global one if None)
|
|
170
|
-
|
|
171
|
-
Yields:
|
|
172
|
-
bool: True if code should execute, False if suppressed
|
|
173
|
-
"""
|
|
174
|
-
if key is None:
|
|
175
|
-
raise ValueError("Context manager requires a unique 'key' parameter")
|
|
176
|
-
|
|
177
|
-
rate_limiter = limiter or _global_limiter
|
|
178
|
-
should_execute, suppressed = rate_limiter.should_execute(key, every_n)
|
|
179
|
-
|
|
180
|
-
if should_execute:
|
|
181
|
-
# Show suppressed count BEFORE yielding (except for first execution)
|
|
182
|
-
if show_suppressed and suppressed > 0:
|
|
183
|
-
print(f"... {suppressed} more similar operations suppressed")
|
|
184
|
-
|
|
185
|
-
try:
|
|
186
|
-
yield should_execute
|
|
187
|
-
finally:
|
|
188
|
-
pass # Nothing to do in finally
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
# Convenience functions for common use cases
|
|
192
|
-
def rate_limited_print(message: str, every_n: int = 10, key: Optional[str] = None):
|
|
193
|
-
"""Print a message only every N calls."""
|
|
194
|
-
if key is None:
|
|
195
|
-
key = f"print_{hash(message) % 10000}"
|
|
196
|
-
|
|
197
|
-
with rate_limited(every_n, key=key) as should_execute:
|
|
198
|
-
if should_execute:
|
|
199
|
-
print(message)
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
def rate_limited_log(
|
|
203
|
-
logger: logging.Logger, level: int, message: str, every_n: int = 10, key: Optional[str] = None, *args
|
|
204
|
-
):
|
|
205
|
-
"""Log a message only every N calls."""
|
|
206
|
-
if key is None:
|
|
207
|
-
key = f"log_{hash(message) % 10000}"
|
|
208
|
-
|
|
209
|
-
with rate_limited(every_n, key=key, show_suppressed=False) as should_execute:
|
|
210
|
-
if should_execute:
|
|
211
|
-
suppressed = _global_limiter.suppressed_count[key]
|
|
212
|
-
if suppressed > 0:
|
|
213
|
-
message += f" (... {suppressed} more similar messages suppressed)"
|
|
214
|
-
logger.log(level, message, *args)
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
# Example usage and demonstrations
|
|
218
|
-
if __name__ == "__main__":
|
|
219
|
-
print("=== Decorator Example ===")
|
|
220
|
-
|
|
221
|
-
@rate_limit(every_n=3, show_suppressed=True)
|
|
222
|
-
def noisy_function(i):
|
|
223
|
-
print(f"Processing item {i}")
|
|
224
|
-
return f"result_{i}"
|
|
225
|
-
|
|
226
|
-
# This will only print every 3rd call
|
|
227
|
-
for i in range(10):
|
|
228
|
-
noisy_function(i)
|
|
229
|
-
|
|
230
|
-
print(f"\nTotal calls to noisy_function: {noisy_function.get_count()}")
|
|
231
|
-
|
|
232
|
-
print("\n=== Context Manager Example ===")
|
|
233
|
-
|
|
234
|
-
# Using context manager in a loop
|
|
235
|
-
for i in range(15):
|
|
236
|
-
with rate_limited(every_n=5, key="loop_progress") as should_execute:
|
|
237
|
-
if should_execute:
|
|
238
|
-
print(f"Progress update: processed {i + 1} items")
|
|
239
|
-
|
|
240
|
-
print("\n=== Convenience Functions Example ===")
|
|
241
|
-
|
|
242
|
-
# Using convenience functions
|
|
243
|
-
for i in range(8):
|
|
244
|
-
rate_limited_print(f"Item {i} processed", every_n=3, key="item_processing")
|
|
245
|
-
|
|
246
|
-
print("\n=== Logging Example ===")
|
|
247
|
-
|
|
248
|
-
# Setup logging
|
|
249
|
-
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
|
250
|
-
logger = logging.getLogger(__name__)
|
|
251
|
-
|
|
252
|
-
# Rate-limited logging
|
|
253
|
-
for i in range(12):
|
|
254
|
-
rate_limited_log(
|
|
255
|
-
logger, logging.WARNING, f"High memory usage detected: {i * 10}%", every_n=4, key="memory_warning"
|
|
256
|
-
)
|
|
257
|
-
|
|
258
|
-
print("\n=== Multiple Rate Limiters Example ===")
|
|
259
|
-
|
|
260
|
-
# Using separate rate limiter instances for different purposes
|
|
261
|
-
debug_limiter = RateLimiter()
|
|
262
|
-
error_limiter = RateLimiter()
|
|
263
|
-
|
|
264
|
-
@rate_limit(every_n=2, key="debug_messages", limiter=debug_limiter)
|
|
265
|
-
def debug_message(msg):
|
|
266
|
-
print(f"DEBUG: {msg}")
|
|
267
|
-
|
|
268
|
-
@rate_limit(every_n=4, key="error_messages", limiter=error_limiter)
|
|
269
|
-
def error_message(msg):
|
|
270
|
-
print(f"ERROR: {msg}")
|
|
271
|
-
|
|
272
|
-
for i in range(10):
|
|
273
|
-
debug_message(f"Debug info {i}")
|
|
274
|
-
if i % 2 == 0:
|
|
275
|
-
error_message(f"Error condition {i}")
|
egse/socketdevice.py
DELETED
|
@@ -1,379 +0,0 @@
|
|
|
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
|
-
|
|
21
|
-
class SocketDevice(DeviceConnectionInterface, DeviceTransport):
|
|
22
|
-
"""Base class that implements the socket interface."""
|
|
23
|
-
|
|
24
|
-
# We set a default connect timeout of 3.0 sec before connecting and reset
|
|
25
|
-
# to None (=blocking) after connecting. The reason for this is that when no
|
|
26
|
-
# device is available, e.g. during testing, the timeout will take about
|
|
27
|
-
# two minutes which is way too long. It needs to be evaluated if this
|
|
28
|
-
# approach is acceptable and not causing problems during production.
|
|
29
|
-
|
|
30
|
-
def __init__(
|
|
31
|
-
self,
|
|
32
|
-
hostname: str,
|
|
33
|
-
port: int,
|
|
34
|
-
connect_timeout: float = 3.0,
|
|
35
|
-
read_timeout: float | None = 1.0,
|
|
36
|
-
separator: str = b"\x03",
|
|
37
|
-
):
|
|
38
|
-
super().__init__()
|
|
39
|
-
self.is_connection_open = False
|
|
40
|
-
self.hostname = hostname
|
|
41
|
-
self.port = port
|
|
42
|
-
self.connect_timeout = connect_timeout
|
|
43
|
-
self.read_timeout = read_timeout
|
|
44
|
-
self.separator = separator
|
|
45
|
-
self.socket = None
|
|
46
|
-
|
|
47
|
-
@property
|
|
48
|
-
def device_name(self):
|
|
49
|
-
"""The name of the device that this interface connects to."""
|
|
50
|
-
return f"SocketDevice({self.hostname}:{self.port})"
|
|
51
|
-
|
|
52
|
-
def connect(self):
|
|
53
|
-
"""
|
|
54
|
-
Connect the device.
|
|
55
|
-
|
|
56
|
-
Raises:
|
|
57
|
-
ConnectionError: When the connection could not be established. Check the logging
|
|
58
|
-
messages for more detail.
|
|
59
|
-
TimeoutError: When the connection timed out.
|
|
60
|
-
ValueError: When hostname or port number are not provided.
|
|
61
|
-
"""
|
|
62
|
-
|
|
63
|
-
# Sanity checks
|
|
64
|
-
|
|
65
|
-
if self.is_connection_open:
|
|
66
|
-
logger.warning(f"{self.device_name}: trying to connect to an already connected socket.")
|
|
67
|
-
return
|
|
68
|
-
|
|
69
|
-
if self.hostname in (None, ""):
|
|
70
|
-
raise ValueError(f"{self.device_name}: hostname is not initialized.")
|
|
71
|
-
|
|
72
|
-
if self.port in (None, 0):
|
|
73
|
-
raise ValueError(f"{self.device_name}: port number is not initialized.")
|
|
74
|
-
|
|
75
|
-
# Create a new socket instance
|
|
76
|
-
|
|
77
|
-
try:
|
|
78
|
-
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
79
|
-
except socket.error as exc:
|
|
80
|
-
raise ConnectionError(f"{self.device_name}: Failed to create socket.") from exc
|
|
81
|
-
|
|
82
|
-
try:
|
|
83
|
-
logger.debug(f'Connecting a socket to host "{self.hostname}" using port {self.port}')
|
|
84
|
-
self.socket.settimeout(self.connect_timeout)
|
|
85
|
-
self.socket.connect((self.hostname, self.port))
|
|
86
|
-
self.socket.settimeout(None)
|
|
87
|
-
except ConnectionRefusedError as exc:
|
|
88
|
-
raise ConnectionError(f"{self.device_name}: Connection refused to {self.hostname}:{self.port}.") from exc
|
|
89
|
-
except TimeoutError as exc:
|
|
90
|
-
raise TimeoutError(f"{self.device_name}: Connection to {self.hostname}:{self.port} timed out.") from exc
|
|
91
|
-
except socket.gaierror as exc:
|
|
92
|
-
raise ConnectionError(f"{self.device_name}: socket address info error for {self.hostname}") from exc
|
|
93
|
-
except socket.herror as exc:
|
|
94
|
-
raise ConnectionError(f"{self.device_name}: socket host address error for {self.hostname}") from exc
|
|
95
|
-
except socket.timeout as exc:
|
|
96
|
-
raise TimeoutError(f"{self.device_name}: socket timeout error for {self.hostname}:{self.port}") from exc
|
|
97
|
-
except OSError as exc:
|
|
98
|
-
raise ConnectionError(f"{self.device_name}: OSError caught ({exc}).") from exc
|
|
99
|
-
|
|
100
|
-
self.is_connection_open = True
|
|
101
|
-
|
|
102
|
-
def disconnect(self):
|
|
103
|
-
"""
|
|
104
|
-
Disconnect from the Ethernet connection.
|
|
105
|
-
|
|
106
|
-
Raises:
|
|
107
|
-
ConnectionError when the socket could not be closed.
|
|
108
|
-
"""
|
|
109
|
-
|
|
110
|
-
try:
|
|
111
|
-
if self.is_connection_open:
|
|
112
|
-
logger.debug(f"Disconnecting from {self.hostname}")
|
|
113
|
-
self.socket.close()
|
|
114
|
-
self.is_connection_open = False
|
|
115
|
-
except Exception as e_exc:
|
|
116
|
-
raise ConnectionError(f"{self.device_name}: Could not close socket to {self.hostname}") from e_exc
|
|
117
|
-
|
|
118
|
-
def is_connected(self) -> bool:
|
|
119
|
-
"""
|
|
120
|
-
Check if the device is connected.
|
|
121
|
-
|
|
122
|
-
Returns:
|
|
123
|
-
True is the device is connected, False otherwise.
|
|
124
|
-
"""
|
|
125
|
-
|
|
126
|
-
return bool(self.is_connection_open)
|
|
127
|
-
|
|
128
|
-
def reconnect(self):
|
|
129
|
-
"""
|
|
130
|
-
Reconnect to the device. If the connection is open, this function will first disconnect
|
|
131
|
-
and then connect again.
|
|
132
|
-
"""
|
|
133
|
-
|
|
134
|
-
if self.is_connection_open:
|
|
135
|
-
self.disconnect()
|
|
136
|
-
self.connect()
|
|
137
|
-
|
|
138
|
-
def read(self) -> bytes:
|
|
139
|
-
"""
|
|
140
|
-
Read until ETX (b'\x03') or until `self.read_timeout` elapses.
|
|
141
|
-
Uses `select` to avoid blocking indefinitely when no data is available.
|
|
142
|
-
If `self.read_timeout` was set to None in the constructor, this will block anyway.
|
|
143
|
-
"""
|
|
144
|
-
if not self.socket:
|
|
145
|
-
raise DeviceConnectionError(self.device_name, "Not connected")
|
|
146
|
-
|
|
147
|
-
buf_size = 1024 * 4
|
|
148
|
-
response = bytearray()
|
|
149
|
-
|
|
150
|
-
# If read_timeout is None we preserve blocking behaviour; otherwise enforce overall timeout.
|
|
151
|
-
if self.read_timeout is None:
|
|
152
|
-
end_time = None
|
|
153
|
-
else:
|
|
154
|
-
end_time = time.monotonic() + self.read_timeout
|
|
155
|
-
|
|
156
|
-
try:
|
|
157
|
-
while True:
|
|
158
|
-
# compute the remaining timeout for select, this is needed because we read in different parts
|
|
159
|
-
# until ETX is received, and we want to receive the complete messages including ETX within
|
|
160
|
-
# the read timeout.
|
|
161
|
-
if end_time is None:
|
|
162
|
-
timeout = None
|
|
163
|
-
else:
|
|
164
|
-
remaining = end_time - time.monotonic()
|
|
165
|
-
if remaining <= 0.0:
|
|
166
|
-
raise DeviceTimeoutError(self.device_name, "Socket read timed out")
|
|
167
|
-
timeout = remaining
|
|
168
|
-
|
|
169
|
-
ready, _, _ = select.select([self.socket], [], [], timeout)
|
|
170
|
-
|
|
171
|
-
if not ready:
|
|
172
|
-
# no socket ready within timeout
|
|
173
|
-
raise DeviceTimeoutError(self.device_name, "Socket read timed out")
|
|
174
|
-
|
|
175
|
-
try:
|
|
176
|
-
data = self.socket.recv(buf_size)
|
|
177
|
-
except OSError as exc:
|
|
178
|
-
raise DeviceConnectionError(self.device_name, f"Caught {type_name(exc)}: {exc}") from exc
|
|
179
|
-
|
|
180
|
-
if not data:
|
|
181
|
-
# remote closed connection (EOF)
|
|
182
|
-
raise DeviceConnectionError(self.device_name, "Connection closed by peer")
|
|
183
|
-
|
|
184
|
-
response.extend(data)
|
|
185
|
-
|
|
186
|
-
if self.separator in response:
|
|
187
|
-
break
|
|
188
|
-
|
|
189
|
-
except DeviceTimeoutError:
|
|
190
|
-
raise
|
|
191
|
-
except DeviceConnectionError:
|
|
192
|
-
raise
|
|
193
|
-
except Exception as exc:
|
|
194
|
-
# unexpected errors - translate to DeviceConnectionError
|
|
195
|
-
raise DeviceConnectionError(self.device_name, "Socket read error") from exc
|
|
196
|
-
|
|
197
|
-
return bytes(response)
|
|
198
|
-
|
|
199
|
-
def write(self, command: str):
|
|
200
|
-
"""
|
|
201
|
-
Send a command to the device.
|
|
202
|
-
|
|
203
|
-
No processing is done on the command string, except for the encoding into a bytes object.
|
|
204
|
-
|
|
205
|
-
Args:
|
|
206
|
-
command: the command string including terminators.
|
|
207
|
-
|
|
208
|
-
Raises:
|
|
209
|
-
A DeviceTimeoutError when the send timed out, and a DeviceConnectionError if
|
|
210
|
-
there was a socket related error.
|
|
211
|
-
"""
|
|
212
|
-
|
|
213
|
-
try:
|
|
214
|
-
self.socket.sendall(command.encode())
|
|
215
|
-
except socket.timeout as exc:
|
|
216
|
-
raise DeviceTimeoutError(self.device_name, "Socket timeout error") from exc
|
|
217
|
-
except socket.error as exc:
|
|
218
|
-
# Interpret any socket-related error as an I/O error
|
|
219
|
-
raise DeviceConnectionError(self.device_name, "Socket communication error.") from exc
|
|
220
|
-
|
|
221
|
-
def trans(self, command: str) -> bytes:
|
|
222
|
-
"""
|
|
223
|
-
Send a command to the device and wait for the response.
|
|
224
|
-
|
|
225
|
-
No processing is done on the command string, except for the encoding into a bytes object.
|
|
226
|
-
|
|
227
|
-
Args:
|
|
228
|
-
command: the command string including terminators.
|
|
229
|
-
|
|
230
|
-
Returns:
|
|
231
|
-
A bytes object containing the response from the device. No processing is done
|
|
232
|
-
on the response.
|
|
233
|
-
|
|
234
|
-
Raises:
|
|
235
|
-
A DeviceTimeoutError when the send timed out, and a DeviceConnectionError if
|
|
236
|
-
there was a socket related error.
|
|
237
|
-
"""
|
|
238
|
-
|
|
239
|
-
try:
|
|
240
|
-
# Attempt to send the complete command
|
|
241
|
-
|
|
242
|
-
self.socket.sendall(command.encode())
|
|
243
|
-
|
|
244
|
-
# wait for, read and return the response (will be at most TBD chars)
|
|
245
|
-
|
|
246
|
-
return_string = self.read()
|
|
247
|
-
|
|
248
|
-
return return_string
|
|
249
|
-
|
|
250
|
-
except socket.timeout as exc:
|
|
251
|
-
raise DeviceTimeoutError(self.device_name, "Socket timeout error") from exc
|
|
252
|
-
except socket.error as exc:
|
|
253
|
-
# Interpret any socket-related error as an I/O error
|
|
254
|
-
raise DeviceConnectionError(self.device_name, "Socket communication error.") from exc
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
class AsyncSocketDevice(AsyncDeviceInterface, AsyncDeviceTransport):
|
|
258
|
-
"""
|
|
259
|
-
Async socket-backed device using asyncio streams.
|
|
260
|
-
|
|
261
|
-
- async connect() / disconnect()
|
|
262
|
-
- async read() reads until ETX (b'\\x03') or timeout
|
|
263
|
-
- async write() and async trans()
|
|
264
|
-
"""
|
|
265
|
-
|
|
266
|
-
def __init__(
|
|
267
|
-
self,
|
|
268
|
-
hostname: str,
|
|
269
|
-
port: int,
|
|
270
|
-
connect_timeout: float = 3.0,
|
|
271
|
-
read_timeout: float | None = 1.0,
|
|
272
|
-
separator: str = b"\x03",
|
|
273
|
-
):
|
|
274
|
-
super().__init__()
|
|
275
|
-
self.hostname = hostname
|
|
276
|
-
self.port = port
|
|
277
|
-
self.connect_timeout = connect_timeout
|
|
278
|
-
self.read_timeout = read_timeout
|
|
279
|
-
self.separator = separator
|
|
280
|
-
self.reader: Optional[asyncio.StreamReader] = None
|
|
281
|
-
self.writer: Optional[asyncio.StreamWriter] = None
|
|
282
|
-
self.is_connection_open = False
|
|
283
|
-
|
|
284
|
-
@property
|
|
285
|
-
def device_name(self) -> str:
|
|
286
|
-
# Override this property for a decent name
|
|
287
|
-
return f"AsyncSocketDevice({self.hostname}:{self.port})"
|
|
288
|
-
|
|
289
|
-
async def connect(self) -> None:
|
|
290
|
-
if self.is_connection_open:
|
|
291
|
-
logger.debug(f"{self.device_name}: already connected")
|
|
292
|
-
return
|
|
293
|
-
|
|
294
|
-
if not self.hostname:
|
|
295
|
-
raise ValueError(f"{self.device_name}: hostname is not initialized.")
|
|
296
|
-
if not self.port:
|
|
297
|
-
raise ValueError(f"{self.device_name}: port is not initialized.")
|
|
298
|
-
|
|
299
|
-
try:
|
|
300
|
-
logger.debug(f"{self.device_name}: connect() called; is_connection_open={self.is_connection_open}")
|
|
301
|
-
coro = asyncio.open_connection(self.hostname, self.port)
|
|
302
|
-
self.reader, self.writer = await asyncio.wait_for(coro, timeout=self.connect_timeout)
|
|
303
|
-
self.is_connection_open = True
|
|
304
|
-
logger.debug(f"{self.device_name}: connected -> peer={self.writer.get_extra_info('peername')}")
|
|
305
|
-
|
|
306
|
-
except asyncio.TimeoutError as exc:
|
|
307
|
-
await self._cleanup()
|
|
308
|
-
logger.warning(f"{self.device_name}: connect timed out")
|
|
309
|
-
raise DeviceTimeoutError(self.device_name, f"Connection to {self.hostname}:{self.port} timed out.") from exc
|
|
310
|
-
except Exception as exc:
|
|
311
|
-
await self._cleanup()
|
|
312
|
-
logger.warning(f"{self.device_name}: connect failed: {type_name(exc)} – {exc}")
|
|
313
|
-
raise DeviceConnectionError(self.device_name, f"Failed to connect to {self.hostname}:{self.port}") from exc
|
|
314
|
-
|
|
315
|
-
async def disconnect(self) -> None:
|
|
316
|
-
logger.debug(f"{self.device_name}: disconnect() called; writer_exists={bool(self.writer)}")
|
|
317
|
-
peer = None
|
|
318
|
-
try:
|
|
319
|
-
if self.writer and not self.writer.is_closing():
|
|
320
|
-
peer = self.writer.get_extra_info("peername")
|
|
321
|
-
self.writer.close()
|
|
322
|
-
# wait for close, but don't hang forever
|
|
323
|
-
try:
|
|
324
|
-
await asyncio.wait_for(self.writer.wait_closed(), timeout=1.0)
|
|
325
|
-
except asyncio.TimeoutError:
|
|
326
|
-
logger.debug(f"{self.device_name}: wait_closed() timed out for peer={peer}")
|
|
327
|
-
|
|
328
|
-
finally:
|
|
329
|
-
await self._cleanup()
|
|
330
|
-
logger.debug(f"{self.device_name}: disconnected ({peer=})")
|
|
331
|
-
|
|
332
|
-
def is_connected(self) -> bool:
|
|
333
|
-
return bool(self.is_connection_open and self.writer and not self.writer.is_closing())
|
|
334
|
-
|
|
335
|
-
async def _cleanup(self) -> None:
|
|
336
|
-
self.reader = None
|
|
337
|
-
self.writer = None
|
|
338
|
-
self.is_connection_open = False
|
|
339
|
-
|
|
340
|
-
async def read(self) -> bytes:
|
|
341
|
-
if not self.reader:
|
|
342
|
-
raise DeviceConnectionError(self.device_name, "Not connected")
|
|
343
|
-
try:
|
|
344
|
-
# readuntil includes the separator; we keep it for parity with existing code
|
|
345
|
-
data = await asyncio.wait_for(self.reader.readuntil(separator=self.separator), timeout=self.read_timeout)
|
|
346
|
-
return data
|
|
347
|
-
except asyncio.IncompleteReadError as exc:
|
|
348
|
-
# EOF before separator
|
|
349
|
-
await self._cleanup()
|
|
350
|
-
raise DeviceConnectionError(self.device_name, "Connection closed while reading") from exc
|
|
351
|
-
except asyncio.TimeoutError as exc:
|
|
352
|
-
raise DeviceTimeoutError(self.device_name, "Socket read timed out") from exc
|
|
353
|
-
except Exception as exc:
|
|
354
|
-
await self._cleanup()
|
|
355
|
-
raise DeviceConnectionError(self.device_name, "Socket read error") from exc
|
|
356
|
-
|
|
357
|
-
async def write(self, command: str) -> None:
|
|
358
|
-
if not self.writer:
|
|
359
|
-
raise DeviceConnectionError(self.device_name, "Not connected")
|
|
360
|
-
try:
|
|
361
|
-
self.writer.write(command.encode())
|
|
362
|
-
await asyncio.wait_for(self.writer.drain(), timeout=self.read_timeout)
|
|
363
|
-
except asyncio.TimeoutError as exc:
|
|
364
|
-
raise DeviceTimeoutError(self.device_name, "Socket write timed out") from exc
|
|
365
|
-
except Exception as exc:
|
|
366
|
-
await self._cleanup()
|
|
367
|
-
raise DeviceConnectionError(self.device_name, "Socket write error") from exc
|
|
368
|
-
|
|
369
|
-
async def trans(self, command: str) -> bytes:
|
|
370
|
-
if not self.writer or not self.reader:
|
|
371
|
-
raise DeviceConnectionError(self.device_name, "Not connected")
|
|
372
|
-
try:
|
|
373
|
-
await self.write(command)
|
|
374
|
-
return await self.read()
|
|
375
|
-
except (DeviceTimeoutError, DeviceConnectionError):
|
|
376
|
-
raise
|
|
377
|
-
except Exception as exc:
|
|
378
|
-
await self._cleanup()
|
|
379
|
-
raise DeviceConnectionError(self.device_name, "Socket trans error") from exc
|
|
File without changes
|
|
File without changes
|