conson-xp 0.11.21__py3-none-any.whl → 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,148 +0,0 @@
1
- """Connection pooling implementation for Conbus TCP connections.
2
-
3
- This module provides a singleton connection pool for managing TCP socket connections
4
- to Conbus servers with automatic lifecycle management, health checking, and reconnection.
5
- """
6
-
7
- import logging
8
- import socket
9
- import threading
10
- import time
11
- from typing import Any, Optional
12
-
13
- from xp.models import ConbusClientConfig
14
-
15
-
16
- class ConbusSocketConnectionManager:
17
- """Connection manager for TCP socket connections to Conbus servers"""
18
-
19
- def __init__(self, cli_config: ConbusClientConfig):
20
- self.config = cli_config.conbus
21
- self.logger = logging.getLogger(__name__)
22
-
23
- def create(self) -> socket.socket:
24
- """Create and configure a new TCP socket connection"""
25
- sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
26
- sock.settimeout(self.config.timeout)
27
- sock.connect((self.config.ip, self.config.port))
28
- self.logger.info(
29
- f"Created new connection to {self.config.ip}:{self.config.port} (timeout: {self.config.timeout})"
30
- )
31
- return sock
32
-
33
- def dispose(self, connection: socket.socket) -> None:
34
- """Close and cleanup socket connection"""
35
- try:
36
- connection.close()
37
- self.logger.info("Disposed socket connection")
38
- except Exception as e:
39
- self.logger.warning(f"Error disposing connection: {e}")
40
-
41
- @staticmethod
42
- def check_aliveness(connection: socket.socket) -> bool:
43
- """Verify if connection is still alive"""
44
- try:
45
- # Use socket error checking rather than sending empty data
46
- # to avoid potential protocol issues
47
- error = connection.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR)
48
- return error == 0
49
- except (socket.error, OSError):
50
- return False
51
-
52
-
53
- class ConbusConnectionPool:
54
- """Singleton connection pool for Conbus TCP connections"""
55
-
56
- _lock = threading.Lock()
57
-
58
- def __init__(self, connection_manager: ConbusSocketConnectionManager) -> None:
59
- if hasattr(self, "_initialized"):
60
- return
61
-
62
- self._connection_manager = connection_manager
63
- self._connection: Optional[socket.socket] = None
64
- self._current_connection: Optional[socket.socket] = None
65
- self._connection_created_at: Optional[float] = None
66
- self._lock = threading.Lock()
67
- self.logger = logging.getLogger(__name__)
68
-
69
- # Configuration
70
- self.idle_timeout = 21600 # 6 hours
71
- self.max_lifetime = 21600 # 6 hours
72
- self._initialized = True
73
-
74
- def _is_connection_expired(self) -> bool:
75
- """Check if the current connection has expired"""
76
- if self._connection_created_at is None:
77
- return True
78
-
79
- age = time.time() - self._connection_created_at
80
- return age > self.max_lifetime
81
-
82
- def _is_connection_alive(self) -> bool:
83
- """Check if connection is still alive"""
84
- if self._connection is None or self._connection_manager is None:
85
- return False
86
-
87
- return self._connection_manager.check_aliveness(self._connection)
88
-
89
- def acquire_connection(self) -> socket.socket:
90
- """Acquire a connection from the pool"""
91
- if self._connection_manager is None:
92
- raise RuntimeError("Connection pool not initialized")
93
-
94
- with self._lock:
95
- # Check if we need a new connection
96
- if (
97
- self._connection is None
98
- or self._is_connection_expired()
99
- or not self._is_connection_alive()
100
- ):
101
-
102
- # Close existing connection if any
103
- if self._connection:
104
- self._connection_manager.dispose(self._connection)
105
- self._connection = None
106
-
107
- # Create new connection
108
- self._connection = self._connection_manager.create()
109
- self._connection_created_at = time.time()
110
- self.logger.debug("Created new connection")
111
-
112
- self.logger.debug("Acquired connection from pool")
113
- return self._connection
114
-
115
- # noinspection PyUnusedLocal
116
- def release_connection(self, connection: socket.socket) -> None:
117
- """Release a connection back to the pool (no-op for single connection pool)"""
118
- self.logger.debug("Released connection back to pool")
119
- # For single connection pool, we just log but don't actually close the connection
120
-
121
- def __enter__(self) -> socket.socket:
122
- """Context manager entry - acquire connection"""
123
- self._current_connection = self.acquire_connection()
124
- return self._current_connection
125
-
126
- def __exit__(
127
- self,
128
- _exc_type: Optional[type],
129
- _exc_val: Optional[Exception],
130
- _exc_tb: Optional[Any],
131
- ) -> None:
132
- """Context manager exit - release connection"""
133
- if hasattr(self, "_current_connection") and self._current_connection:
134
- self.release_connection(self._current_connection)
135
- self._current_connection = None
136
-
137
- def close(self) -> None:
138
- """Close the connection pool and cleanup resources"""
139
- with self._lock:
140
- if self._connection and self._connection_manager is not None:
141
- try:
142
- self._connection_manager.dispose(self._connection)
143
- self.logger.info("Connection pool closed")
144
- except Exception as e:
145
- self.logger.error(f"Error closing connection pool: {e}")
146
- finally:
147
- self._connection = None
148
- self._connection_created_at = None
@@ -1,197 +0,0 @@
1
- """Conbus Link Number Service for setting module link numbers.
2
-
3
- This service handles setting link numbers for modules through Conbus telegrams.
4
- """
5
-
6
- import logging
7
- from typing import Any, Optional
8
-
9
- from xp.models.conbus.conbus_linknumber import ConbusLinknumberResponse
10
- from xp.models.telegram.datapoint_type import DataPointType
11
- from xp.models.telegram.reply_telegram import ReplyTelegram
12
- from xp.services.conbus.conbus_datapoint_service import ConbusDatapointService
13
- from xp.services.conbus.conbus_service import ConbusService
14
- from xp.services.telegram.telegram_link_number_service import (
15
- LinkNumberError,
16
- LinkNumberService,
17
- )
18
- from xp.services.telegram.telegram_service import TelegramService
19
-
20
-
21
- class ConbusLinknumberService:
22
- """
23
- Service for setting and getting module link numbers via Conbus telegrams.
24
-
25
- Handles link number assignment by sending F04D04 telegrams and processing
26
- ACK/NAK responses from modules. Also handles link number reading using
27
- datapoint queries.
28
- """
29
-
30
- def __init__(
31
- self,
32
- conbus_service: ConbusService,
33
- datapoint_service: ConbusDatapointService,
34
- link_number_service: LinkNumberService,
35
- telegram_service: TelegramService,
36
- ):
37
- """Initialize the Conbus link number service"""
38
-
39
- # Service dependencies
40
- self.conbus_service = conbus_service
41
- self.datapoint_service = datapoint_service
42
- self.link_number_service = link_number_service
43
- self.telegram_service = telegram_service
44
-
45
- # Set up logging
46
- self.logger = logging.getLogger(__name__)
47
-
48
- def __enter__(self) -> "ConbusLinknumberService":
49
- return self
50
-
51
- def __exit__(
52
- self,
53
- _exc_type: Optional[type],
54
- _exc_val: Optional[Exception],
55
- _exc_tb: Optional[Any],
56
- ) -> None:
57
- # Cleanup logic if needed
58
- pass
59
-
60
- def set_linknumber(
61
- self, serial_number: str, link_number: int
62
- ) -> ConbusLinknumberResponse:
63
- """
64
- Set the link number for a specific module.
65
-
66
- Args:
67
- serial_number: 10-digit module serial number
68
- link_number: Link number to set (0-99)
69
-
70
- Returns:
71
- ConbusLinknumberResponse with operation result
72
-
73
- Raises:
74
- LinkNumberError: If parameters are invalid
75
- """
76
- try:
77
- # Generate the link number setting telegram
78
- telegram = self.link_number_service.generate_set_link_number_telegram(
79
- serial_number, link_number
80
- )
81
-
82
- # Send telegram using ConbusService
83
- with self.conbus_service:
84
- response = self.conbus_service.send_raw_telegram(telegram)
85
-
86
- # Determine result based on response
87
- result = "NAK" # Default to NAK
88
- if response.success and response.received_telegrams:
89
- # Try to parse the first received telegram
90
- if len(response.received_telegrams) > 0:
91
- received_telegram = response.received_telegrams[0]
92
- try:
93
- parsed_telegram = self.telegram_service.parse_telegram(
94
- received_telegram
95
- )
96
- if isinstance(parsed_telegram, ReplyTelegram):
97
- if self.link_number_service.is_ack_response(
98
- parsed_telegram
99
- ):
100
- result = "ACK"
101
- elif self.link_number_service.is_nak_response(
102
- parsed_telegram
103
- ):
104
- result = "NAK"
105
- except Exception as e:
106
- self.logger.warning(f"Failed to parse reply telegram: {e}")
107
-
108
- return ConbusLinknumberResponse(
109
- success=response.success and result == "ACK",
110
- result=result,
111
- link_number=link_number,
112
- serial_number=serial_number,
113
- sent_telegram=telegram,
114
- received_telegrams=response.received_telegrams,
115
- error=response.error,
116
- timestamp=response.timestamp,
117
- )
118
-
119
- except LinkNumberError as e:
120
- return ConbusLinknumberResponse(
121
- success=False,
122
- result="NAK",
123
- serial_number=serial_number,
124
- error=str(e),
125
- )
126
- except Exception as e:
127
- return ConbusLinknumberResponse(
128
- success=False,
129
- result="NAK",
130
- serial_number=serial_number,
131
- error=f"Unexpected error: {e}",
132
- )
133
-
134
- def get_linknumber(self, serial_number: str) -> ConbusLinknumberResponse:
135
- """
136
- Get the current link number for a specific module.
137
-
138
- Args:
139
- serial_number: 10-digit module serial number
140
-
141
- Returns:
142
- ConbusLinknumberResponse with operation result and link number
143
-
144
- Raises:
145
- Exception: If datapoint query fails
146
- """
147
- try:
148
- # TODO: Migrate to new ConbusDatapointService callback-based API
149
- # Query the LINK_NUMBER datapoint
150
- datapoint_response = self.datapoint_service.query_datapoint( # type: ignore[call-arg,func-returns-value]
151
- serial_number=serial_number,
152
- datapoint_type=DataPointType.LINK_NUMBER,
153
- )
154
-
155
- if datapoint_response.success and datapoint_response.datapoint_telegram:
156
- # Extract link number from datapoint response
157
- try:
158
- link_number_value = int(
159
- datapoint_response.datapoint_telegram.data_value
160
- )
161
- return ConbusLinknumberResponse(
162
- success=True,
163
- result="SUCCESS",
164
- serial_number=serial_number,
165
- link_number=link_number_value,
166
- sent_telegram=datapoint_response.sent_telegram,
167
- received_telegrams=datapoint_response.received_telegrams,
168
- timestamp=datapoint_response.timestamp,
169
- )
170
- except (ValueError, TypeError) as e:
171
- return ConbusLinknumberResponse(
172
- success=False,
173
- result="PARSE_ERROR",
174
- serial_number=serial_number,
175
- sent_telegram=datapoint_response.sent_telegram,
176
- received_telegrams=datapoint_response.received_telegrams,
177
- error=f"Failed to parse link number: {e}",
178
- timestamp=datapoint_response.timestamp,
179
- )
180
- else:
181
- return ConbusLinknumberResponse(
182
- success=False,
183
- result="QUERY_FAILED",
184
- serial_number=serial_number,
185
- sent_telegram=datapoint_response.sent_telegram,
186
- received_telegrams=datapoint_response.received_telegrams,
187
- error=datapoint_response.error or "Failed to query link number",
188
- timestamp=datapoint_response.timestamp,
189
- )
190
-
191
- except Exception as e:
192
- return ConbusLinknumberResponse(
193
- success=False,
194
- result="ERROR",
195
- serial_number=serial_number,
196
- error=f"Unexpected error: {e}",
197
- )
@@ -1,306 +0,0 @@
1
- """Conbus Client Send Service for TCP communication with Conbus servers.
2
-
3
- This service implements a TCP client that connects to Conbus servers and sends
4
- various types of telegrams including discover, version, and sensor data requests.
5
- """
6
-
7
- import logging
8
- import socket
9
- from datetime import datetime
10
- from typing import Any, List, Optional
11
-
12
- from typing_extensions import Callable
13
-
14
- from xp.models import (
15
- ConbusConnectionStatus,
16
- ConbusRequest,
17
- ConbusResponse,
18
- )
19
- from xp.models.conbus.conbus_client_config import ConbusClientConfig
20
- from xp.models.response import Response
21
- from xp.models.telegram.system_function import SystemFunction
22
- from xp.services.conbus.conbus_connection_pool import ConbusConnectionPool
23
- from xp.utils.checksum import calculate_checksum
24
-
25
-
26
- class ConbusError(Exception):
27
- """Raised when Conbus client send operations fail"""
28
-
29
- pass
30
-
31
-
32
- class ConbusService:
33
- """
34
- TCP client service for sending telegrams to Conbus servers.
35
-
36
- Manages TCP socket connections, handles telegram generation and transmission,
37
- and processes server responses.
38
- """
39
-
40
- def __init__(
41
- self,
42
- client_config: ConbusClientConfig,
43
- connection_pool: ConbusConnectionPool,
44
- ):
45
- """Initialize the Conbus client send service
46
-
47
- Args:
48
- client_config: ConbusClientConfig for dependency injection
49
- connection_pool: ConbusConnectionPool for dependency injection
50
- """
51
- self.client_config: ConbusClientConfig = client_config
52
- self.is_connected = False
53
- self.last_activity: Optional[datetime] = None
54
-
55
- # Set up logging
56
- self.logger = logging.getLogger(__name__)
57
-
58
- # Use injected connection pool
59
- self._connection_pool = connection_pool
60
-
61
- def get_config(self) -> ConbusClientConfig:
62
- """Get current client configuration"""
63
- return self.client_config
64
-
65
- def connect(self) -> Response:
66
- """Test connection using the connection pool"""
67
- try:
68
- # Test connection by acquiring and immediately releasing
69
- with self._connection_pool:
70
- self.is_connected = True
71
- self.last_activity = datetime.now()
72
-
73
- self.logger.info(
74
- f"Connection pool ready for {self.client_config.conbus.ip}:{self.client_config.conbus.port}"
75
- )
76
-
77
- return Response(
78
- success=True,
79
- data={
80
- "message": f"Connection pool ready for {self.client_config.conbus.ip}:{self.client_config.conbus.port}",
81
- },
82
- error=None,
83
- )
84
-
85
- except Exception as e:
86
- error_msg = f"Failed to establish connection pool to {self.client_config.conbus.ip}:{self.client_config.conbus.port}: {e}"
87
- self.logger.error(error_msg)
88
- self.is_connected = False
89
- return Response(success=False, data=None, error=error_msg)
90
-
91
- def disconnect(self) -> None:
92
- """Close connection pool (graceful shutdown)"""
93
- try:
94
- self._connection_pool.close()
95
- self.logger.info("Connection pool closed")
96
- except Exception as e:
97
- self.logger.error(f"Error closing connection pool: {e}")
98
- finally:
99
- self.is_connected = False
100
-
101
- def get_connection_status(self) -> ConbusConnectionStatus:
102
- """Get current connection status"""
103
- return ConbusConnectionStatus(
104
- connected=self.is_connected,
105
- ip=self.client_config.conbus.ip,
106
- port=self.client_config.conbus.port,
107
- last_activity=self.last_activity,
108
- )
109
-
110
- @staticmethod
111
- def _parse_telegrams(raw_data: str) -> List[str]:
112
- """Parse raw data and extract telegrams using < and > delimiters"""
113
- telegrams: list[str] = []
114
- if not raw_data:
115
- return telegrams
116
-
117
- # Find all telegram patterns <...>
118
- start_pos = 0
119
- while True:
120
- # Find the start of next telegram
121
- start_idx = raw_data.find("<", start_pos)
122
- if start_idx == -1:
123
- break
124
-
125
- # Find the end of this telegram
126
- end_idx = raw_data.find(">", start_idx)
127
- if end_idx == -1:
128
- # Incomplete telegram at the end
129
- break
130
-
131
- # Extract telegram including < and >
132
- telegram = raw_data[start_idx : end_idx + 1]
133
- if telegram.strip():
134
- telegrams.append(telegram.strip())
135
-
136
- start_pos = end_idx + 1
137
-
138
- return telegrams
139
-
140
- def receive_responses(
141
- self,
142
- timeout: float = 0.1,
143
- receive_callback: Optional[Callable[[list[str]], None]] = None,
144
- ) -> List[str]:
145
- """Receive responses from the server and properly split telegrams"""
146
- import time
147
-
148
- all_telegrams = []
149
- start_time = time.time()
150
-
151
- try:
152
- with self._connection_pool as connection:
153
- while time.time() - start_time < timeout:
154
- # Call _receive_responses_with_connection with a short timeout for each iteration
155
- telegrams = self._receive_responses_with_connection(
156
- connection, timeout=0.1
157
- )
158
- if receive_callback is not None:
159
- receive_callback(telegrams)
160
- all_telegrams.extend(telegrams)
161
-
162
- # Small sleep to avoid busy waiting when no data
163
- time.sleep(0.01)
164
- except Exception as e:
165
- self.logger.error(f"Error receiving responses: {e}")
166
- return []
167
-
168
- return all_telegrams
169
-
170
- def _receive_responses_with_connection(
171
- self,
172
- connection: socket.socket,
173
- timeout: float = 1.0,
174
- receive_callback: Optional[Callable[[list[str]], None]] = None,
175
- ) -> List[str]:
176
- """Receive responses from the server using a specific connection"""
177
- accumulated_data = ""
178
-
179
- try:
180
- # Set a shorter timeout for receiving responses
181
- original_timeout = connection.gettimeout()
182
- connection.settimeout(timeout) # 1 second timeout for responses
183
-
184
- while True:
185
- try:
186
- data = connection.recv(1024)
187
- if not data:
188
- break
189
-
190
- # Accumulate all received data
191
- message = data.decode("latin-1")
192
- if receive_callback is not None:
193
- parsed_telegrams = self._parse_telegrams(message)
194
- receive_callback(parsed_telegrams)
195
- accumulated_data += message
196
- self.last_activity = datetime.now()
197
- connection.settimeout(0.1) # 2 second timeout for responses
198
-
199
- except socket.timeout:
200
- # No more data available
201
- break
202
-
203
- # Restore original timeout
204
- connection.settimeout(original_timeout)
205
-
206
- except Exception as e:
207
- self.logger.error(f"Error receiving responses: {e}")
208
-
209
- # Parse telegrams from accumulated data
210
- telegrams = self._parse_telegrams(accumulated_data)
211
- for telegram in telegrams:
212
- self.logger.info(f"Received telegram: {telegram}")
213
-
214
- return telegrams
215
-
216
- def send_telegram(
217
- self,
218
- serial_number: str,
219
- system_function: SystemFunction,
220
- data: str,
221
- receive_callback: Optional[Callable[[list[str]], None]] = None,
222
- ) -> ConbusResponse:
223
- """Send custom telegram with specified function and data point codes"""
224
- # Generate custom system telegram: <S{serial}F{function}{data_point}{checksum}>
225
- function_code = system_function.value
226
- telegram_body = f"S{serial_number}F{function_code}D{data}"
227
- checksum = calculate_checksum(telegram_body)
228
- telegram = f"<{telegram_body}{checksum}>"
229
-
230
- return self.send_raw_telegram(telegram, receive_callback)
231
-
232
- def send_telegram_body(
233
- self,
234
- telegram_body: str,
235
- receive_callback: Optional[Callable[[list[str]], None]] = None,
236
- ) -> ConbusResponse:
237
- """Send custom telegram with specified function and data point codes"""
238
- checksum = calculate_checksum(telegram_body)
239
- telegram = f"<{telegram_body}{checksum}>"
240
-
241
- return self.send_raw_telegram(telegram, receive_callback)
242
-
243
- def send_raw_telegram(
244
- self,
245
- telegram: Optional[str] = None,
246
- receive_callback: Optional[Callable[[list[str]], None]] = None,
247
- ) -> ConbusResponse:
248
- """Send telegram using connection pool with automatic acquire/release"""
249
- request = ConbusRequest(telegram=telegram)
250
-
251
- try:
252
- # Use context manager for automatic connection management
253
- with self._connection_pool as connection:
254
-
255
- # Draining event waiting to be read (wait time 0)
256
- responses = self._receive_responses_with_connection(connection, 0.001)
257
- self.logger.info(f"Purged telegram: {responses}")
258
-
259
- # Send telegram
260
- if telegram is not None:
261
- connection.send(telegram.encode("latin-1"))
262
- self.logger.info(f"Sent telegram: {telegram}")
263
-
264
- self.last_activity = datetime.now()
265
- self.is_connected = True # Update connection status
266
-
267
- # Receive responses
268
- responses = self._receive_responses_with_connection(
269
- connection, 0.1, receive_callback
270
- )
271
-
272
- return ConbusResponse(
273
- success=True,
274
- request=request,
275
- sent_telegram=telegram,
276
- received_telegrams=responses,
277
- )
278
- # Connection automatically released here
279
-
280
- except Exception as e:
281
- error_msg = f"Failed to send telegram: {e}"
282
- self.logger.error(error_msg)
283
- self.is_connected = False # Update connection status on error
284
- return ConbusResponse(
285
- success=False,
286
- request=request,
287
- error=error_msg,
288
- )
289
-
290
- def send_raw_telegrams(self, telegrams: List[str]) -> ConbusResponse:
291
- self.logger.info(f"send_raw_telegrams: {telegrams}")
292
- all_telegrams = "".join(telegrams)
293
- return self.send_raw_telegram(all_telegrams)
294
-
295
- def __enter__(self) -> "ConbusService":
296
- """Context manager entry"""
297
- return self
298
-
299
- def __exit__(
300
- self,
301
- _exc_type: Optional[type],
302
- _exc_val: Optional[Exception],
303
- _exc_tb: Optional[Any],
304
- ) -> None:
305
- """Context manager exit - ensure connection is closed"""
306
- self.disconnect()