python-snap7 3.0.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.
snap7/connection.py ADDED
@@ -0,0 +1,456 @@
1
+ """
2
+ ISO on TCP connection management (RFC 1006).
3
+
4
+ Implements TPKT (Transport Service on top of TCP) and COTP (Connection Oriented
5
+ Transport Protocol) layers for S7 communication.
6
+ """
7
+
8
+ import socket
9
+ import struct
10
+ import logging
11
+ from enum import IntEnum
12
+ from typing import Optional, Type
13
+ from types import TracebackType
14
+
15
+ from .error import S7ConnectionError, S7TimeoutError
16
+
17
+
18
+ class TPDUSize(IntEnum):
19
+ """TPDU sizes per ISO 8073 / RFC 905.
20
+
21
+ The value is the exponent: actual size = 2^value bytes.
22
+ """
23
+
24
+ S_128 = 0x07
25
+ S_256 = 0x08
26
+ S_512 = 0x09
27
+ S_1024 = 0x0A
28
+ S_2048 = 0x0B
29
+ S_4096 = 0x0C
30
+ S_8192 = 0x0D
31
+
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ class ISOTCPConnection:
37
+ """
38
+ ISO on TCP connection implementation.
39
+
40
+ Handles the transport layer for S7 communication including:
41
+ - TCP socket management
42
+ - TPKT framing (RFC 1006)
43
+ - COTP connection setup and data transfer
44
+ - PDU size negotiation
45
+ """
46
+
47
+ # COTP PDU types
48
+ COTP_CR = 0xE0 # Connection Request
49
+ COTP_CC = 0xD0 # Connection Confirm
50
+ COTP_DR = 0x80 # Disconnect Request
51
+ COTP_DC = 0xC0 # Disconnect Confirm
52
+ COTP_DT = 0xF0 # Data Transfer
53
+ COTP_ED = 0x10 # Expedited Data
54
+ COTP_AK = 0x60 # Data Acknowledgment
55
+ COTP_EA = 0x20 # Expedited Acknowledgment
56
+ COTP_RJ = 0x50 # Reject
57
+ COTP_ER = 0x70 # Error
58
+
59
+ # COTP parameter codes (ISO 8073)
60
+ COTP_PARAM_PDU_SIZE = 0xC0
61
+ COTP_PARAM_CALLING_TSAP = 0xC1
62
+ COTP_PARAM_CALLED_TSAP = 0xC2
63
+
64
+ def __init__(
65
+ self,
66
+ host: str,
67
+ port: int = 102,
68
+ local_tsap: int = 0x0100,
69
+ remote_tsap: int = 0x0102,
70
+ tpdu_size: TPDUSize = TPDUSize.S_1024,
71
+ ):
72
+ """
73
+ Initialize ISO TCP connection.
74
+
75
+ Args:
76
+ host: Target PLC IP address
77
+ port: TCP port (default 102 for S7)
78
+ local_tsap: Local Transport Service Access Point
79
+ remote_tsap: Remote Transport Service Access Point
80
+ tpdu_size: TPDU size to request during COTP negotiation
81
+ """
82
+ self.host = host
83
+ self.port = port
84
+ self.local_tsap = local_tsap
85
+ self.remote_tsap = remote_tsap
86
+ self.tpdu_size = tpdu_size
87
+ self.socket: Optional[socket.socket] = None
88
+ self.connected = False
89
+ self.pdu_size = 240 # Default PDU size, negotiated during connection
90
+ self.timeout = 5.0 # Default timeout in seconds
91
+
92
+ # Connection parameters
93
+ self.src_ref = 0x0001 # Source reference
94
+ self.dst_ref = 0x0000 # Destination reference (assigned by peer)
95
+
96
+ def connect(self, timeout: float = 5.0) -> None:
97
+ """
98
+ Establish ISO on TCP connection.
99
+
100
+ Args:
101
+ timeout: Connection timeout in seconds
102
+ """
103
+ self.timeout = timeout
104
+
105
+ try:
106
+ # Step 1: TCP connection
107
+ self._tcp_connect()
108
+
109
+ # Step 2: ISO connection (COTP handshake)
110
+ self._iso_connect()
111
+
112
+ self.connected = True
113
+ logger.info(f"Connected to {self.host}:{self.port}, PDU size: {self.pdu_size}")
114
+
115
+ except Exception as e:
116
+ self.disconnect()
117
+ if isinstance(e, (S7ConnectionError, S7TimeoutError)):
118
+ raise
119
+ else:
120
+ raise S7ConnectionError(f"Connection failed: {e}")
121
+
122
+ def disconnect(self) -> None:
123
+ """Disconnect from S7 device."""
124
+ if self.socket:
125
+ try:
126
+ if self.connected:
127
+ # Send COTP disconnect request
128
+ self._send_cotp_disconnect()
129
+ self.socket.close()
130
+ except Exception:
131
+ pass # Ignore errors during disconnect
132
+ finally:
133
+ self.socket = None
134
+ self.connected = False
135
+ logger.info(f"Disconnected from {self.host}:{self.port}")
136
+
137
+ def send_data(self, data: bytes) -> None:
138
+ """
139
+ Send data over ISO connection.
140
+
141
+ Args:
142
+ data: S7 PDU data to send
143
+ """
144
+ if not self.connected or self.socket is None:
145
+ raise S7ConnectionError("Not connected")
146
+
147
+ # Wrap data in COTP Data Transfer PDU
148
+ cotp_data = self._build_cotp_dt(data)
149
+
150
+ # Wrap in TPKT frame
151
+ tpkt_frame = self._build_tpkt(cotp_data)
152
+
153
+ # Send over TCP
154
+ try:
155
+ self.socket.sendall(tpkt_frame)
156
+ logger.debug(f"Sent {len(tpkt_frame)} bytes")
157
+ except socket.error as e:
158
+ self.connected = False
159
+ raise S7ConnectionError(f"Send failed: {e}")
160
+
161
+ def receive_data(self) -> bytes:
162
+ """
163
+ Receive data from ISO connection.
164
+
165
+ Returns:
166
+ S7 PDU data
167
+ """
168
+ if not self.connected:
169
+ raise S7ConnectionError("Not connected")
170
+
171
+ try:
172
+ # Receive TPKT header (4 bytes)
173
+ tpkt_header = self._recv_exact(4)
174
+
175
+ # Parse TPKT header
176
+ version, reserved, length = struct.unpack(">BBH", tpkt_header)
177
+
178
+ if version != 3:
179
+ raise S7ConnectionError(f"Invalid TPKT version: {version}")
180
+
181
+ # Receive remaining data
182
+ remaining = length - 4
183
+ if remaining <= 0:
184
+ raise S7ConnectionError("Invalid TPKT length")
185
+
186
+ payload = self._recv_exact(remaining)
187
+
188
+ # Parse COTP header and extract data
189
+ return self._parse_cotp_data(payload)
190
+
191
+ except socket.timeout:
192
+ self.connected = False
193
+ raise S7TimeoutError("Receive timeout")
194
+ except socket.error as e:
195
+ self.connected = False
196
+ raise S7ConnectionError(f"Receive failed: {e}")
197
+
198
+ def _tcp_connect(self) -> None:
199
+ """Establish TCP connection."""
200
+ self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
201
+ self.socket.settimeout(self.timeout)
202
+
203
+ try:
204
+ self.socket.connect((self.host, self.port))
205
+ logger.debug(f"TCP connected to {self.host}:{self.port}")
206
+ except socket.error as e:
207
+ raise S7ConnectionError(f"TCP connection failed: {e}")
208
+
209
+ def _iso_connect(self) -> None:
210
+ """Establish ISO connection using COTP handshake."""
211
+ if self.socket is None:
212
+ raise S7ConnectionError("Socket not initialized")
213
+
214
+ # Send Connection Request
215
+ cr_pdu = self._build_cotp_cr()
216
+ tpkt_frame = self._build_tpkt(cr_pdu)
217
+
218
+ self.socket.sendall(tpkt_frame)
219
+ logger.debug("Sent COTP Connection Request")
220
+
221
+ # Receive Connection Confirm
222
+ tpkt_header = self._recv_exact(4)
223
+ version, reserved, length = struct.unpack(">BBH", tpkt_header)
224
+
225
+ if version != 3:
226
+ raise S7ConnectionError(f"Invalid TPKT version in response: {version}")
227
+
228
+ payload = self._recv_exact(length - 4)
229
+ self._parse_cotp_cc(payload)
230
+
231
+ logger.debug("Received COTP Connection Confirm")
232
+
233
+ def _build_tpkt(self, payload: bytes) -> bytes:
234
+ """
235
+ Build TPKT frame.
236
+
237
+ TPKT Header (4 bytes):
238
+ - Version (1 byte): Always 3
239
+ - Reserved (1 byte): Always 0
240
+ - Length (2 bytes): Total frame length including header
241
+ """
242
+ length = len(payload) + 4
243
+ return struct.pack(">BBH", 3, 0, length) + payload
244
+
245
+ def _build_cotp_cr(self) -> bytes:
246
+ """
247
+ Build COTP Connection Request PDU.
248
+
249
+ COTP CR format:
250
+ - PDU Length: Length of COTP header (excluding this byte)
251
+ - PDU Type: 0xE0 (Connection Request)
252
+ - Destination Reference: 2 bytes
253
+ - Source Reference: 2 bytes
254
+ - Class/Option: 1 byte
255
+ - Parameters: Variable length
256
+ """
257
+ # Basic COTP CR without parameters
258
+ base_pdu = struct.pack(
259
+ ">BBHHB",
260
+ 6, # PDU length (header without parameters)
261
+ self.COTP_CR, # PDU type
262
+ 0x0000, # Destination reference (0 for CR)
263
+ self.src_ref, # Source reference
264
+ 0x00, # Class/option (Class 0, no extended formats)
265
+ )
266
+
267
+ # Add TSAP parameters
268
+ tsap_length = 2 # TSAP values are 2 bytes (unsigned short)
269
+ # Calling TSAP (local)
270
+ calling_tsap = struct.pack(">BBH", self.COTP_PARAM_CALLING_TSAP, tsap_length, self.local_tsap)
271
+ # Called TSAP (remote)
272
+ called_tsap = struct.pack(">BBH", self.COTP_PARAM_CALLED_TSAP, tsap_length, self.remote_tsap)
273
+ # PDU Size parameter (ISO 8073 code, e.g. 0x0A = 1024 bytes)
274
+ pdu_size_param = struct.pack(">BBB", self.COTP_PARAM_PDU_SIZE, 1, self.tpdu_size)
275
+
276
+ parameters = calling_tsap + called_tsap + pdu_size_param
277
+
278
+ # Update PDU length to include parameters
279
+ total_length = 6 + len(parameters)
280
+ pdu = struct.pack(">B", total_length) + base_pdu[1:] + parameters
281
+
282
+ return pdu
283
+
284
+ def _parse_cotp_cc(self, data: bytes) -> None:
285
+ """
286
+ Parse COTP Connection Confirm PDU.
287
+
288
+ Extracts destination reference and negotiated PDU size.
289
+ """
290
+ if len(data) < 7:
291
+ raise S7ConnectionError("Invalid COTP CC: too short")
292
+
293
+ pdu_len, pdu_type, dst_ref, src_ref, class_opt = struct.unpack(">BBHHB", data[:7])
294
+
295
+ if pdu_type != self.COTP_CC:
296
+ raise S7ConnectionError(f"Expected COTP CC, got {pdu_type:#02x}")
297
+
298
+ self.dst_ref = dst_ref
299
+
300
+ # Parse parameters if present
301
+ if len(data) > 7:
302
+ self._parse_cotp_parameters(data[7:])
303
+
304
+ def _parse_cotp_parameters(self, params: bytes) -> None:
305
+ """Parse COTP parameters from Connection Confirm."""
306
+ offset = 0
307
+
308
+ while offset < len(params):
309
+ if offset + 2 > len(params):
310
+ break
311
+
312
+ param_code = params[offset]
313
+ param_len = params[offset + 1]
314
+
315
+ if offset + 2 + param_len > len(params):
316
+ break
317
+
318
+ param_data = params[offset + 2 : offset + 2 + param_len]
319
+
320
+ if param_code == self.COTP_PARAM_PDU_SIZE:
321
+ # PDU Size parameter
322
+ if param_len == 1:
323
+ # ISO 8073 code: size = 2^code
324
+ self.pdu_size = 1 << param_data[0]
325
+ elif param_len == 2:
326
+ # Raw 2-byte value
327
+ self.pdu_size = struct.unpack(">H", param_data)[0]
328
+ logger.debug(f"Negotiated PDU size: {self.pdu_size}")
329
+ else:
330
+ logger.debug(f"Unsupported COTP parameter: code={param_code:#04x}, length={param_len}")
331
+
332
+ offset += 2 + param_len
333
+
334
+ def _build_cotp_dt(self, data: bytes) -> bytes:
335
+ """
336
+ Build COTP Data Transfer PDU.
337
+
338
+ COTP DT format:
339
+ - PDU Length: 2 (fixed for DT)
340
+ - PDU Type: 0xF0 (Data Transfer)
341
+ - EOT + Number: 0x80 (End of TSDU, sequence number 0)
342
+ - Data: Variable length
343
+ """
344
+ header = struct.pack(">BBB", 2, self.COTP_DT, 0x80)
345
+ return header + data
346
+
347
+ def _parse_cotp_data(self, cotp_pdu: bytes) -> bytes:
348
+ """
349
+ Parse COTP Data Transfer PDU and extract S7 data.
350
+ """
351
+ if len(cotp_pdu) < 3:
352
+ raise S7ConnectionError("Invalid COTP DT: too short")
353
+
354
+ pdu_len, pdu_type, eot_num = struct.unpack(">BBB", cotp_pdu[:3])
355
+
356
+ if pdu_type != self.COTP_DT:
357
+ raise S7ConnectionError(f"Expected COTP DT, got {pdu_type:#02x}")
358
+
359
+ return cotp_pdu[3:] # Return data portion
360
+
361
+ def _send_cotp_disconnect(self) -> None:
362
+ """Send COTP Disconnect Request."""
363
+ if self.socket is None:
364
+ return # Nothing to disconnect
365
+
366
+ dr_pdu = struct.pack(
367
+ ">BBHHBB",
368
+ 6, # PDU length
369
+ self.COTP_DR, # PDU type
370
+ self.dst_ref, # Destination reference
371
+ self.src_ref, # Source reference
372
+ 0x00, # Reason (normal disconnect)
373
+ 0x00, # Additional info
374
+ )
375
+
376
+ tpkt_frame = self._build_tpkt(dr_pdu)
377
+ try:
378
+ self.socket.sendall(tpkt_frame)
379
+ except socket.error:
380
+ pass # Ignore errors during disconnect
381
+
382
+ def _recv_exact(self, size: int) -> bytes:
383
+ """
384
+ Receive exactly the specified number of bytes.
385
+
386
+ Args:
387
+ size: Number of bytes to receive
388
+
389
+ Returns:
390
+ Received data
391
+
392
+ Raises:
393
+ S7ConnectionError: If connection is lost
394
+ S7TimeoutError: If timeout occurs
395
+ """
396
+ if self.socket is None:
397
+ raise S7ConnectionError("Socket not initialized")
398
+
399
+ data = bytearray()
400
+
401
+ while len(data) < size:
402
+ try:
403
+ chunk = self.socket.recv(size - len(data))
404
+ if not chunk:
405
+ self.connected = False
406
+ raise S7ConnectionError("Connection closed by peer")
407
+ data.extend(chunk)
408
+ except socket.timeout:
409
+ self.connected = False
410
+ raise S7TimeoutError("Receive timeout")
411
+ except socket.error as e:
412
+ self.connected = False
413
+ raise S7ConnectionError(f"Receive error: {e}")
414
+
415
+ return bytes(data)
416
+
417
+ def check_connection(self) -> bool:
418
+ """Check if the TCP connection is still alive.
419
+
420
+ Uses a non-blocking socket peek to detect broken connections.
421
+ """
422
+ if not self.connected or self.socket is None:
423
+ return False
424
+
425
+ try:
426
+ original_timeout = self.socket.gettimeout()
427
+ self.socket.settimeout(0)
428
+ try:
429
+ data = self.socket.recv(1, socket.MSG_PEEK)
430
+ if not data:
431
+ self.connected = False
432
+ return False
433
+ return True
434
+ except BlockingIOError:
435
+ # No data available but connection is still alive
436
+ return True
437
+ except (socket.error, OSError):
438
+ self.connected = False
439
+ return False
440
+ finally:
441
+ self.socket.settimeout(original_timeout)
442
+ except Exception:
443
+ return False
444
+
445
+ def __enter__(self) -> "ISOTCPConnection":
446
+ """Context manager entry."""
447
+ return self
448
+
449
+ def __exit__(
450
+ self,
451
+ exc_type: Optional[Type[BaseException]],
452
+ exc_val: Optional[BaseException],
453
+ exc_tb: Optional[TracebackType],
454
+ ) -> None:
455
+ """Context manager exit."""
456
+ self.disconnect()