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/partner.py ADDED
@@ -0,0 +1,680 @@
1
+ """
2
+ Pure Python S7 partner implementation.
3
+
4
+ S7 peer-to-peer communication for bidirectional data exchange.
5
+ Unlike client-server where client requests and server responds,
6
+ partners have equal rights and can send data asynchronously.
7
+ """
8
+
9
+ import socket
10
+ import struct
11
+ import logging
12
+ import threading
13
+ from typing import Optional, Tuple, Callable, Type
14
+ from queue import Queue, Empty
15
+ from typing import Any
16
+ from datetime import datetime
17
+ from types import TracebackType
18
+ from ctypes import c_int32, c_uint32
19
+
20
+ from .connection import ISOTCPConnection
21
+ from .error import S7Error, S7ConnectionError
22
+ from .type import Parameter
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class PartnerStatus:
28
+ """Partner status constants."""
29
+
30
+ STOPPED = 0
31
+ RUNNING = 1
32
+ CONNECTED = 2
33
+
34
+
35
+ class Partner:
36
+ """
37
+ Pure Python S7 partner implementation.
38
+
39
+ Implements peer-to-peer S7 communication where both partners can
40
+ send and receive data asynchronously. Supports both active (initiates
41
+ connection) and passive (waits for connection) modes.
42
+
43
+ Examples:
44
+ >>> import snap7
45
+ >>> partner = snap7.Partner(active=True)
46
+ >>> partner.start_to("0.0.0.0", "192.168.1.10", 0x0100, 0x0102)
47
+ >>> partner.set_send_data(b"Hello")
48
+ >>> partner.b_send()
49
+ >>> partner.stop()
50
+ """
51
+
52
+ def __init__(self, active: bool = False, **kwargs: object) -> None:
53
+ """
54
+ Initialize S7 partner.
55
+
56
+ Args:
57
+ active: If True, this partner initiates the connection.
58
+ If False, this partner waits for incoming connections.
59
+ **kwargs: Ignored. Kept for backwards compatibility.
60
+ """
61
+ self.active = active
62
+ self.connected = False
63
+ self.running = False
64
+
65
+ # Connection parameters
66
+ self.local_ip = "0.0.0.0"
67
+ self.remote_ip = ""
68
+ self.local_tsap = 0x0100
69
+ self.remote_tsap = 0x0102
70
+ self.port = 1102 # Non-privileged port (was 102)
71
+ self.local_port = 0 # Let OS choose
72
+ self.remote_port = 1102 # Non-privileged port (was 102)
73
+
74
+ # Socket and connection
75
+ self._socket: Optional[socket.socket] = None
76
+ self._server_socket: Optional[socket.socket] = None # For passive mode
77
+ self._connection: Optional[ISOTCPConnection] = None
78
+
79
+ # Statistics
80
+ self.bytes_sent = 0
81
+ self.bytes_recv = 0
82
+ self.send_errors = 0
83
+ self.recv_errors = 0
84
+
85
+ # Timing
86
+ self.last_send_time = 0
87
+ self.last_recv_time = 0
88
+
89
+ # Callbacks
90
+ self._recv_callback: Optional[Callable[[bytes], None]] = None
91
+ self._send_callback_fn: Optional[Callable[[int], None]] = None
92
+
93
+ # Async operation support
94
+ self._async_send_queue: Queue[Any] = Queue()
95
+ self._async_recv_queue: Queue[Any] = Queue()
96
+ self._async_thread: Optional[threading.Thread] = None
97
+ self._stop_event = threading.Event()
98
+
99
+ # Last error
100
+ self.last_error = 0
101
+
102
+ # Buffer for send/recv operations
103
+ self._send_data: Optional[bytes] = None
104
+ self._recv_data: Optional[bytes] = None
105
+ self._async_send_in_progress = False
106
+ self._async_send_result = 0
107
+
108
+ logger.info(f"S7 Partner initialized (active={active}, pure Python implementation)")
109
+
110
+ def create(self, active: bool = False) -> None:
111
+ """
112
+ Creates a Partner.
113
+
114
+ Note: For pure Python implementation, the partner is created in __init__.
115
+ This method exists for API compatibility.
116
+
117
+ Args:
118
+ active: If True, this partner initiates connections
119
+ """
120
+ pass
121
+
122
+ def destroy(self) -> int:
123
+ """
124
+ Destroy the Partner.
125
+
126
+ Returns:
127
+ 0 on success
128
+ """
129
+ self.stop()
130
+ return 0
131
+
132
+ def start(self) -> int:
133
+ """
134
+ Start the partner with default parameters.
135
+
136
+ Returns:
137
+ 0 on success
138
+ """
139
+ return self.start_to(self.local_ip, self.remote_ip, self.local_tsap, self.remote_tsap)
140
+
141
+ def start_to(self, local_ip: str, remote_ip: str, local_tsap: int, remote_tsap: int) -> int:
142
+ """
143
+ Start the partner with specific connection parameters.
144
+
145
+ Args:
146
+ local_ip: Local IP address to bind to
147
+ remote_ip: Remote partner IP address (for active mode)
148
+ local_tsap: Local TSAP
149
+ remote_tsap: Remote TSAP
150
+
151
+ Returns:
152
+ 0 on success
153
+ """
154
+ self.local_ip = local_ip
155
+ self.remote_ip = remote_ip
156
+ self.local_tsap = local_tsap
157
+ self.remote_tsap = remote_tsap
158
+
159
+ try:
160
+ if self.active:
161
+ # Active mode: initiate connection to remote partner
162
+ self._connect_to_remote()
163
+ else:
164
+ # Passive mode: start listening for incoming connections
165
+ self._start_listening()
166
+
167
+ self.running = True
168
+
169
+ # Start async processing thread
170
+ self._stop_event.clear()
171
+ self._async_thread = threading.Thread(target=self._async_processor, daemon=True)
172
+ self._async_thread.start()
173
+
174
+ logger.info(f"Partner started ({'active' if self.active else 'passive'} mode)")
175
+ return 0
176
+
177
+ except Exception as e:
178
+ self.last_error = -1
179
+ logger.error(f"Partner start failed: {e}")
180
+ raise S7ConnectionError(f"Partner start failed: {e}")
181
+
182
+ def stop(self) -> int:
183
+ """
184
+ Stop the partner and disconnect.
185
+
186
+ Returns:
187
+ 0 on success
188
+ """
189
+ self._stop_event.set()
190
+
191
+ if self._async_thread and self._async_thread.is_alive():
192
+ self._async_thread.join(timeout=2.0)
193
+
194
+ if self._connection:
195
+ self._connection.disconnect()
196
+ self._connection = None
197
+
198
+ if self._server_socket:
199
+ try:
200
+ self._server_socket.close()
201
+ except Exception:
202
+ pass
203
+ self._server_socket = None
204
+
205
+ if self._socket:
206
+ try:
207
+ self._socket.close()
208
+ except Exception:
209
+ pass
210
+ self._socket = None
211
+
212
+ self.connected = False
213
+ self.running = False
214
+
215
+ logger.info("Partner stopped")
216
+ return 0
217
+
218
+ def b_send(self) -> int:
219
+ """
220
+ Send data synchronously (blocking).
221
+
222
+ Note: Call set_send_data() first to set the data to send.
223
+
224
+ Returns:
225
+ 0 on success
226
+ """
227
+ if self._send_data is None:
228
+ return -1
229
+
230
+ if not self.connected or self._connection is None:
231
+ self.send_errors += 1
232
+ raise S7ConnectionError("Not connected")
233
+
234
+ start_time = datetime.now()
235
+
236
+ try:
237
+ # Build partner data PDU
238
+ pdu = self._build_partner_data_pdu(self._send_data)
239
+
240
+ # Send via ISO connection
241
+ self._connection.send_data(pdu)
242
+
243
+ # Wait for acknowledgment
244
+ ack_data = self._connection.receive_data()
245
+ self._parse_partner_ack(ack_data)
246
+
247
+ self.bytes_sent += len(self._send_data)
248
+ self.last_send_time = int((datetime.now() - start_time).total_seconds() * 1000)
249
+
250
+ logger.debug(f"Sent {len(self._send_data)} bytes synchronously")
251
+ return 0
252
+
253
+ except Exception as e:
254
+ self.send_errors += 1
255
+ self.last_error = -1
256
+ logger.error(f"Synchronous send failed: {e}")
257
+ raise S7ConnectionError(f"Send failed: {e}")
258
+
259
+ def b_recv(self) -> int:
260
+ """
261
+ Receive data synchronously (blocking).
262
+
263
+ Returns:
264
+ 0 on success
265
+ """
266
+ if not self.connected or self._connection is None:
267
+ self.recv_errors += 1
268
+ self._recv_data = None
269
+ return -1
270
+
271
+ start_time = datetime.now()
272
+
273
+ try:
274
+ # Receive partner data
275
+ data = self._connection.receive_data()
276
+ received = self._parse_partner_data_pdu(data)
277
+
278
+ # Send acknowledgment
279
+ ack = self._build_partner_ack()
280
+ self._connection.send_data(ack)
281
+
282
+ self.bytes_recv += len(received)
283
+ self.last_recv_time = int((datetime.now() - start_time).total_seconds() * 1000)
284
+ self._recv_data = received
285
+
286
+ # Call receive callback if set
287
+ if self._recv_callback:
288
+ self._recv_callback(received)
289
+
290
+ logger.debug(f"Received {len(received)} bytes synchronously")
291
+ return 0
292
+
293
+ except socket.timeout:
294
+ self._recv_data = None
295
+ return 1 # Timeout
296
+ except Exception as e:
297
+ self.recv_errors += 1
298
+ self.last_error = -1
299
+ self._recv_data = None
300
+ logger.error(f"Synchronous receive failed: {e}")
301
+ return -1
302
+
303
+ def as_b_send(self) -> int:
304
+ """
305
+ Send data asynchronously (non-blocking).
306
+
307
+ Note: Call set_send_data() first to set the data to send.
308
+
309
+ Returns:
310
+ 0 on success (send initiated)
311
+ """
312
+ if self._send_data is None:
313
+ return -1
314
+
315
+ if not self.connected:
316
+ self.send_errors += 1
317
+ return -1
318
+
319
+ self._async_send_in_progress = True
320
+ self._async_send_result = 1 # In progress
321
+
322
+ # Queue the send operation
323
+ self._async_send_queue.put(self._send_data)
324
+
325
+ logger.debug(f"Async send initiated for {len(self._send_data)} bytes")
326
+ return 0
327
+
328
+ def check_as_b_send_completion(self) -> Tuple[str, c_int32]:
329
+ """
330
+ Check if async send completed.
331
+
332
+ Returns:
333
+ Tuple of (status_string, operation_result)
334
+ """
335
+ if self._async_send_in_progress:
336
+ return "job in progress", c_int32(0)
337
+
338
+ return_values = {
339
+ 0: "job complete",
340
+ 1: "job in progress",
341
+ -2: "invalid handled supplied",
342
+ }
343
+
344
+ result = self._async_send_result
345
+ return return_values.get(0, "unknown"), c_int32(result)
346
+
347
+ def wait_as_b_send_completion(self, timeout: int = 0) -> int:
348
+ """
349
+ Wait for async send to complete.
350
+
351
+ Args:
352
+ timeout: Timeout in milliseconds (0 for infinite)
353
+
354
+ Returns:
355
+ 0 on success, non-zero on error/timeout
356
+
357
+ Raises:
358
+ RuntimeError: If no async operation is in progress
359
+ """
360
+ if not self._async_send_in_progress:
361
+ raise RuntimeError("No async send operation in progress")
362
+
363
+ # Wait for completion
364
+ wait_time = timeout / 1000.0 if timeout > 0 else None
365
+ start = datetime.now()
366
+
367
+ while self._async_send_in_progress:
368
+ if wait_time is not None:
369
+ elapsed = (datetime.now() - start).total_seconds()
370
+ if elapsed >= wait_time:
371
+ return -1 # Timeout
372
+ threading.Event().wait(0.01) # Small sleep
373
+
374
+ return self._async_send_result
375
+
376
+ def check_as_b_recv_completion(self) -> int:
377
+ """
378
+ Check if async receive completed.
379
+
380
+ Returns:
381
+ 0 if data available, 1 if in progress
382
+ """
383
+ try:
384
+ self._recv_data = self._async_recv_queue.get_nowait()
385
+ return 0 # Data available
386
+ except Empty:
387
+ return 1 # No data yet
388
+
389
+ def get_status(self) -> c_int32:
390
+ """
391
+ Get partner status.
392
+
393
+ Returns:
394
+ Status code (0=stopped, 1=running, 2=connected)
395
+ """
396
+ if self.connected:
397
+ return c_int32(PartnerStatus.CONNECTED)
398
+ elif self.running:
399
+ return c_int32(PartnerStatus.RUNNING)
400
+ else:
401
+ return c_int32(PartnerStatus.STOPPED)
402
+
403
+ def get_stats(self) -> Tuple[c_uint32, c_uint32, c_uint32, c_uint32]:
404
+ """
405
+ Get partner statistics.
406
+
407
+ Returns:
408
+ Tuple of (bytes_sent, bytes_recv, send_errors, recv_errors)
409
+ """
410
+ return (c_uint32(self.bytes_sent), c_uint32(self.bytes_recv), c_uint32(self.send_errors), c_uint32(self.recv_errors))
411
+
412
+ def get_times(self) -> Tuple[c_int32, c_int32]:
413
+ """
414
+ Get last operation times.
415
+
416
+ Returns:
417
+ Tuple of (last_send_time_ms, last_recv_time_ms)
418
+ """
419
+ return c_int32(self.last_send_time), c_int32(self.last_recv_time)
420
+
421
+ def get_last_error(self) -> c_int32:
422
+ """
423
+ Get last error code.
424
+
425
+ Returns:
426
+ Last error code
427
+ """
428
+ return c_int32(self.last_error)
429
+
430
+ def get_param(self, parameter: Parameter) -> int:
431
+ """
432
+ Get partner parameter.
433
+
434
+ Args:
435
+ parameter: Parameter to read
436
+
437
+ Returns:
438
+ Parameter value
439
+ """
440
+ param_values = {
441
+ Parameter.LocalPort: self.local_port,
442
+ Parameter.RemotePort: self.remote_port,
443
+ Parameter.PingTimeout: 750,
444
+ Parameter.SendTimeout: 10,
445
+ Parameter.RecvTimeout: 3000,
446
+ Parameter.SrcRef: 256,
447
+ Parameter.DstRef: 0,
448
+ Parameter.PDURequest: 480,
449
+ Parameter.WorkInterval: 100,
450
+ Parameter.BSendTimeout: 3000,
451
+ Parameter.BRecvTimeout: 3000,
452
+ Parameter.RecoveryTime: 500,
453
+ Parameter.KeepAliveTime: 5000,
454
+ }
455
+ value = param_values.get(parameter)
456
+ if value is None:
457
+ raise RuntimeError(f"Parameter {parameter} not supported")
458
+ logger.debug(f"Getting parameter {parameter} = {value}")
459
+ return value
460
+
461
+ def set_param(self, parameter: Parameter, value: int) -> int:
462
+ """
463
+ Set partner parameter.
464
+
465
+ Args:
466
+ parameter: Parameter to set
467
+ value: Value to set
468
+
469
+ Returns:
470
+ 0 on success
471
+ """
472
+ # Some parameters cannot be set
473
+ if parameter == Parameter.RemotePort:
474
+ raise RuntimeError(f"Cannot set parameter {parameter}")
475
+
476
+ if parameter == Parameter.LocalPort:
477
+ self.local_port = value
478
+ logger.debug(f"Setting parameter {parameter} to {value}")
479
+ return 0
480
+
481
+ def set_recv_callback(self) -> int:
482
+ """
483
+ Sets the user callback for incoming data.
484
+
485
+ Returns:
486
+ 0 on success
487
+ """
488
+ logger.debug("set_recv_callback called")
489
+ return 0
490
+
491
+ def set_send_callback(self) -> int:
492
+ """
493
+ Sets the user callback for completed async sends.
494
+
495
+ Returns:
496
+ 0 on success
497
+ """
498
+ logger.debug("set_send_callback called")
499
+ return 0
500
+
501
+ def set_send_data(self, data: bytes) -> None:
502
+ """
503
+ Set data to be sent by b_send() or as_b_send().
504
+
505
+ Args:
506
+ data: Data to send
507
+ """
508
+ self._send_data = data
509
+
510
+ def get_recv_data(self) -> Optional[bytes]:
511
+ """
512
+ Get data received by b_recv().
513
+
514
+ Returns:
515
+ Received data or None
516
+ """
517
+ return self._recv_data
518
+
519
+ def _connect_to_remote(self) -> None:
520
+ """Connect to remote partner (active mode)."""
521
+ if not self.remote_ip:
522
+ raise S7ConnectionError("Remote IP not specified for active partner")
523
+
524
+ self._connection = ISOTCPConnection(
525
+ host=self.remote_ip, port=self.port, local_tsap=self.local_tsap, remote_tsap=self.remote_tsap
526
+ )
527
+
528
+ self._connection.connect()
529
+ self._socket = self._connection.socket
530
+ self.connected = True
531
+
532
+ logger.info(f"Connected to remote partner at {self.remote_ip}:{self.port}")
533
+
534
+ def _start_listening(self) -> None:
535
+ """Start listening for incoming connections (passive mode)."""
536
+ self._server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
537
+ self._server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
538
+ # Try to use SO_REUSEPORT if available (Linux, macOS) for faster port reuse
539
+ if hasattr(socket, "SO_REUSEPORT"):
540
+ self._server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
541
+ self._server_socket.bind((self.local_ip, self.port))
542
+ self._server_socket.listen(1)
543
+ self._server_socket.settimeout(1.0) # Allow periodic check
544
+
545
+ logger.info(f"Partner listening on {self.local_ip}:{self.port}")
546
+
547
+ # Start accept thread
548
+ accept_thread = threading.Thread(target=self._accept_connection, daemon=True)
549
+ accept_thread.start()
550
+
551
+ def _accept_connection(self) -> None:
552
+ """Accept incoming connection in passive mode."""
553
+ if self._server_socket is None:
554
+ return
555
+
556
+ while self.running and not self._stop_event.is_set():
557
+ try:
558
+ client_sock, addr = self._server_socket.accept()
559
+
560
+ # Create connection object
561
+ self._socket = client_sock
562
+ self._connection = ISOTCPConnection(
563
+ host=addr[0], port=addr[1], local_tsap=self.local_tsap, remote_tsap=self.remote_tsap
564
+ )
565
+ self._connection.socket = client_sock
566
+ self._connection.connected = True
567
+ self.connected = True
568
+
569
+ logger.info(f"Partner connection accepted from {addr}")
570
+ break
571
+
572
+ except socket.timeout:
573
+ continue
574
+ except Exception as e:
575
+ if self.running:
576
+ logger.error(f"Accept failed: {e}")
577
+ break
578
+
579
+ def _async_processor(self) -> None:
580
+ """Background thread for processing async operations."""
581
+ while not self._stop_event.is_set():
582
+ # Process async sends
583
+ try:
584
+ data = self._async_send_queue.get(timeout=0.1)
585
+
586
+ try:
587
+ # Temporarily set send data and call b_send
588
+ old_data = self._send_data
589
+ self._send_data = data
590
+ result = self.b_send()
591
+ self._send_data = old_data
592
+ self._async_send_result = result
593
+
594
+ if self._send_callback_fn:
595
+ self._send_callback_fn(result)
596
+
597
+ except Exception as e:
598
+ self._async_send_result = -1
599
+ logger.error(f"Async send failed: {e}")
600
+ finally:
601
+ self._async_send_in_progress = False
602
+
603
+ except Empty:
604
+ pass
605
+ except Exception:
606
+ break
607
+
608
+ def _build_partner_data_pdu(self, data: bytes) -> bytes:
609
+ """
610
+ Build partner data PDU.
611
+
612
+ Args:
613
+ data: Data to send
614
+
615
+ Returns:
616
+ PDU bytes
617
+ """
618
+ # S7 partner data PDU format:
619
+ # Header + Data
620
+ header = struct.pack(
621
+ ">BBHH",
622
+ 0x32, # Protocol ID (S7)
623
+ 0x07, # Partner PDU type
624
+ len(data), # Data length high
625
+ 0x0000, # Reserved
626
+ )
627
+ return header + data
628
+
629
+ def _parse_partner_data_pdu(self, pdu: bytes) -> bytes:
630
+ """
631
+ Parse partner data PDU.
632
+
633
+ Args:
634
+ pdu: PDU bytes
635
+
636
+ Returns:
637
+ Extracted data
638
+ """
639
+ if len(pdu) < 6:
640
+ raise S7Error("Invalid partner PDU: too short")
641
+
642
+ # Skip header
643
+ return pdu[6:]
644
+
645
+ def _build_partner_ack(self) -> bytes:
646
+ """Build partner acknowledgment PDU."""
647
+ return struct.pack(
648
+ ">BBHH",
649
+ 0x32, # Protocol ID
650
+ 0x08, # ACK type
651
+ 0x0000, # Reserved
652
+ 0x0000, # Status OK
653
+ )
654
+
655
+ def _parse_partner_ack(self, pdu: bytes) -> None:
656
+ """Parse partner acknowledgment PDU."""
657
+ if len(pdu) < 6:
658
+ raise S7Error("Invalid partner ACK: too short")
659
+
660
+ protocol_id, pdu_type = struct.unpack(">BB", pdu[:2])
661
+
662
+ if pdu_type != 0x08:
663
+ raise S7Error(f"Expected partner ACK, got {pdu_type:#02x}")
664
+
665
+ def __enter__(self) -> "Partner":
666
+ """Context manager entry."""
667
+ return self
668
+
669
+ def __exit__(
670
+ self, exc_type: Optional[Type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType]
671
+ ) -> None:
672
+ """Context manager exit."""
673
+ self.destroy()
674
+
675
+ def __del__(self) -> None:
676
+ """Destructor."""
677
+ try:
678
+ self.stop()
679
+ except Exception:
680
+ pass
snap7/py.typed ADDED
File without changes