python-snap7 3.1.2__py3-none-any.whl → 3.2.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.
- python_snap7-3.2.0.dist-info/METADATA +103 -0
- python_snap7-3.2.0.dist-info/RECORD +34 -0
- {python_snap7-3.1.2.dist-info → python_snap7-3.2.0.dist-info}/top_level.txt +1 -0
- s7/__init__.py +52 -0
- snap7/__init__.py +15 -4
- snap7/async_client.py +159 -19
- snap7/cli.py +2 -3
- snap7/client.py +244 -43
- snap7/client_base.py +27 -24
- snap7/connection.py +21 -10
- snap7/datatypes.py +24 -3
- snap7/error.py +6 -0
- snap7/logo.py +48 -6
- snap7/ppi.py +421 -0
- snap7/rate_limiter.py +110 -0
- snap7/s7protocol.py +200 -16
- snap7/server/__init__.py +242 -90
- snap7/szl.py +106 -9
- snap7/tags.py +6 -6
- snap7/type.py +14 -1
- snap7/util/getters.py +21 -4
- snap7/util/setters.py +4 -1
- python_snap7-3.1.2.dist-info/METADATA +0 -131
- python_snap7-3.1.2.dist-info/RECORD +0 -32
- snap7/util/symbols.py +0 -547
- {python_snap7-3.1.2.dist-info → python_snap7-3.2.0.dist-info}/WHEEL +0 -0
- {python_snap7-3.1.2.dist-info → python_snap7-3.2.0.dist-info}/entry_points.txt +0 -0
- {python_snap7-3.1.2.dist-info → python_snap7-3.2.0.dist-info}/licenses/LICENSE +0 -0
snap7/datatypes.py
CHANGED
|
@@ -44,6 +44,8 @@ class S7WordLen(IntEnum):
|
|
|
44
44
|
REAL = 0x08 # 32-bit IEEE float
|
|
45
45
|
COUNTER = 0x1C # Counter value
|
|
46
46
|
TIMER = 0x1D # Timer value
|
|
47
|
+
COUNTER_200 = 0x1E # S7-200 counter value
|
|
48
|
+
TIMER_200 = 0x1F # S7-200 timer value
|
|
47
49
|
|
|
48
50
|
|
|
49
51
|
class S7DataTypes:
|
|
@@ -61,6 +63,8 @@ class S7DataTypes:
|
|
|
61
63
|
S7WordLen.REAL: 4, # 4 bytes
|
|
62
64
|
S7WordLen.COUNTER: 2, # 2 bytes
|
|
63
65
|
S7WordLen.TIMER: 2, # 2 bytes
|
|
66
|
+
S7WordLen.COUNTER_200: 2, # 2 bytes
|
|
67
|
+
S7WordLen.TIMER_200: 2, # 2 bytes
|
|
64
68
|
}
|
|
65
69
|
|
|
66
70
|
@staticmethod
|
|
@@ -89,11 +93,16 @@ class S7DataTypes:
|
|
|
89
93
|
raise ValueError(f"Start address must be non-negative, got {start}")
|
|
90
94
|
|
|
91
95
|
# Convert start address to byte.bit format
|
|
92
|
-
if word_len
|
|
96
|
+
if word_len in (S7WordLen.TIMER, S7WordLen.COUNTER):
|
|
97
|
+
address = start # Timer/counter element index, not a bit address.
|
|
98
|
+
elif word_len == S7WordLen.BIT:
|
|
93
99
|
# For bit access: byte address + bit offset
|
|
94
100
|
byte_addr = start // 8
|
|
95
101
|
bit_addr = start % 8
|
|
96
102
|
address = (byte_addr << 3) | bit_addr
|
|
103
|
+
elif word_len in (S7WordLen.COUNTER_200, S7WordLen.TIMER_200):
|
|
104
|
+
# S7-200 timer/counter addresses are item indexes, not bit addresses.
|
|
105
|
+
address = start
|
|
97
106
|
else:
|
|
98
107
|
# For word access: convert to bit address
|
|
99
108
|
address = start * 8
|
|
@@ -134,7 +143,13 @@ class S7DataTypes:
|
|
|
134
143
|
values.append(data[offset])
|
|
135
144
|
offset += 1
|
|
136
145
|
|
|
137
|
-
elif
|
|
146
|
+
elif (
|
|
147
|
+
word_len == S7WordLen.WORD
|
|
148
|
+
or word_len == S7WordLen.COUNTER
|
|
149
|
+
or word_len == S7WordLen.TIMER
|
|
150
|
+
or word_len == S7WordLen.COUNTER_200
|
|
151
|
+
or word_len == S7WordLen.TIMER_200
|
|
152
|
+
):
|
|
138
153
|
# 16-bit unsigned values (big-endian)
|
|
139
154
|
value = struct.unpack(">H", data[offset : offset + 2])[0]
|
|
140
155
|
values.append(value)
|
|
@@ -187,7 +202,13 @@ class S7DataTypes:
|
|
|
187
202
|
# 8-bit values
|
|
188
203
|
data.append(int(value) & 0xFF)
|
|
189
204
|
|
|
190
|
-
elif
|
|
205
|
+
elif (
|
|
206
|
+
word_len == S7WordLen.WORD
|
|
207
|
+
or word_len == S7WordLen.COUNTER
|
|
208
|
+
or word_len == S7WordLen.TIMER
|
|
209
|
+
or word_len == S7WordLen.COUNTER_200
|
|
210
|
+
or word_len == S7WordLen.TIMER_200
|
|
211
|
+
):
|
|
191
212
|
# 16-bit unsigned values (big-endian)
|
|
192
213
|
data.extend(struct.pack(">H", int(value) & 0xFFFF))
|
|
193
214
|
|
snap7/error.py
CHANGED
|
@@ -40,6 +40,12 @@ class S7AuthenticationError(S7Error):
|
|
|
40
40
|
pass
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
class S7RateLimitError(S7Error):
|
|
44
|
+
"""Raised when a non-blocking request rate limit is reached."""
|
|
45
|
+
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
43
49
|
# S7 client error codes
|
|
44
50
|
s7_client_errors = {
|
|
45
51
|
0x00100000: "errNegotiatingPDU",
|
snap7/logo.py
CHANGED
|
@@ -7,9 +7,11 @@ Pure Python implementation without C library dependency.
|
|
|
7
7
|
import re
|
|
8
8
|
import struct
|
|
9
9
|
import logging
|
|
10
|
+
from collections.abc import Callable
|
|
10
11
|
from typing import Optional
|
|
11
12
|
|
|
12
13
|
from .type import WordLen, Area
|
|
14
|
+
from .rate_limiter import RateLimitAlgorithm, RateLimitBehavior
|
|
13
15
|
from .client import Client
|
|
14
16
|
|
|
15
17
|
logger = logging.getLogger(__name__)
|
|
@@ -67,14 +69,55 @@ class Logo(Client):
|
|
|
67
69
|
For more information see examples for Siemens Logo 7 and 8
|
|
68
70
|
"""
|
|
69
71
|
|
|
70
|
-
def __init__(
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
*,
|
|
75
|
+
auto_reconnect: bool = False,
|
|
76
|
+
max_retries: int = 3,
|
|
77
|
+
retry_delay: float = 1.0,
|
|
78
|
+
backoff_factor: float = 2.0,
|
|
79
|
+
max_delay: float = 30.0,
|
|
80
|
+
heartbeat_interval: float = 0,
|
|
81
|
+
max_requests_per_second: float = 0,
|
|
82
|
+
rate_limit_algorithm: RateLimitAlgorithm = "fixed",
|
|
83
|
+
rate_limit_behavior: RateLimitBehavior = "block",
|
|
84
|
+
rate_limit_burst: int | None = None,
|
|
85
|
+
on_disconnect: Optional[Callable[[], None]] = None,
|
|
86
|
+
on_reconnect: Optional[Callable[[], None]] = None,
|
|
87
|
+
**kwargs: object,
|
|
88
|
+
) -> None:
|
|
71
89
|
"""
|
|
72
90
|
Initialize Logo client.
|
|
73
91
|
|
|
74
92
|
Args:
|
|
93
|
+
auto_reconnect: Reconnect automatically after connection loss.
|
|
94
|
+
max_retries: Maximum number of reconnection attempts.
|
|
95
|
+
retry_delay: Initial reconnection delay in seconds.
|
|
96
|
+
backoff_factor: Multiplier for successive retry delays.
|
|
97
|
+
max_delay: Maximum reconnection delay in seconds.
|
|
98
|
+
heartbeat_interval: Heartbeat interval in seconds (0 disables it).
|
|
99
|
+
max_requests_per_second: Maximum outbound requests per second (0 disables it).
|
|
100
|
+
rate_limit_algorithm: ``fixed`` for even spacing or ``token_bucket`` for bursts.
|
|
101
|
+
rate_limit_behavior: ``block`` to wait or ``raise`` to reject immediately.
|
|
102
|
+
rate_limit_burst: Token bucket capacity. Defaults to one second of requests.
|
|
103
|
+
on_disconnect: Callback invoked when the connection is lost.
|
|
104
|
+
on_reconnect: Callback invoked after successful reconnection.
|
|
75
105
|
**kwargs: Ignored. Kept for backwards compatibility.
|
|
76
106
|
"""
|
|
77
|
-
super().__init__(
|
|
107
|
+
super().__init__(
|
|
108
|
+
auto_reconnect=auto_reconnect,
|
|
109
|
+
max_retries=max_retries,
|
|
110
|
+
retry_delay=retry_delay,
|
|
111
|
+
backoff_factor=backoff_factor,
|
|
112
|
+
max_delay=max_delay,
|
|
113
|
+
heartbeat_interval=heartbeat_interval,
|
|
114
|
+
max_requests_per_second=max_requests_per_second,
|
|
115
|
+
rate_limit_algorithm=rate_limit_algorithm,
|
|
116
|
+
rate_limit_behavior=rate_limit_behavior,
|
|
117
|
+
rate_limit_burst=rate_limit_burst,
|
|
118
|
+
on_disconnect=on_disconnect,
|
|
119
|
+
on_reconnect=on_reconnect,
|
|
120
|
+
)
|
|
78
121
|
self._logo_tsap_snap7: Optional[int] = None
|
|
79
122
|
self._logo_tsap_logo: Optional[int] = None
|
|
80
123
|
|
|
@@ -105,10 +148,9 @@ class Logo(Client):
|
|
|
105
148
|
self.host = ip_address
|
|
106
149
|
self.port = tcp_port
|
|
107
150
|
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
|
|
111
|
-
super().connect(ip_address, 0, 0, tcp_port)
|
|
151
|
+
# Share connection/heartbeat setup without replacing the explicit
|
|
152
|
+
# LOGO destination TSAP with an S7 rack/slot-derived address.
|
|
153
|
+
self._connect(ip_address, 0, 0, tcp_port)
|
|
112
154
|
|
|
113
155
|
return self
|
|
114
156
|
|
snap7/ppi.py
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
"""Siemens S7-200 PPI serial transport and client.
|
|
2
|
+
|
|
3
|
+
PPI carries ordinary S7 PDUs inside PROFIBUS-style SD1/SD2 frames. The
|
|
4
|
+
implementation follows the request/acknowledgement exchange used by libnodave:
|
|
5
|
+
an SD2 request, an E5 acknowledgement, an SD1 request-data poll, and an SD2
|
|
6
|
+
response.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import importlib
|
|
10
|
+
import logging
|
|
11
|
+
import threading
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from enum import IntEnum
|
|
14
|
+
from types import TracebackType
|
|
15
|
+
from typing import Any, Callable, Protocol, cast
|
|
16
|
+
|
|
17
|
+
from .datatypes import S7Area, S7WordLen
|
|
18
|
+
from .error import S7ConnectionError, S7ProtocolError
|
|
19
|
+
from .s7protocol import S7Protocol
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PPIFrameType(IntEnum):
|
|
25
|
+
"""PPI/PROFIBUS frame delimiters."""
|
|
26
|
+
|
|
27
|
+
SD1 = 0x10
|
|
28
|
+
SD2 = 0x68
|
|
29
|
+
SD3 = 0xA2
|
|
30
|
+
SC = 0xE5
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PPIArea(IntEnum):
|
|
34
|
+
"""S7-200 memory area identifiers."""
|
|
35
|
+
|
|
36
|
+
S = 0x03
|
|
37
|
+
SM = 0x05
|
|
38
|
+
AI = 0x06
|
|
39
|
+
AQ = 0x07
|
|
40
|
+
I = 0x81 # noqa: E741 - Siemens names the input area I.
|
|
41
|
+
Q = 0x82
|
|
42
|
+
M = 0x83
|
|
43
|
+
V = 0x84 # V memory is addressed as DB1 on the wire.
|
|
44
|
+
C = 0x1E
|
|
45
|
+
T = 0x1F
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class PPIFrame:
|
|
50
|
+
"""Decoded PPI frame."""
|
|
51
|
+
|
|
52
|
+
frame_type: PPIFrameType
|
|
53
|
+
destination: int | None = None
|
|
54
|
+
source: int | None = None
|
|
55
|
+
control: int | None = None
|
|
56
|
+
payload: bytes = b""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SerialPort(Protocol):
|
|
60
|
+
"""Minimal serial-port interface used by :class:`PPITransport`."""
|
|
61
|
+
|
|
62
|
+
def read(self, size: int = 1) -> bytes: ...
|
|
63
|
+
|
|
64
|
+
def write(self, data: bytes) -> int | None: ...
|
|
65
|
+
|
|
66
|
+
def close(self) -> None: ...
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class PPIExchangeTransport(Protocol):
|
|
70
|
+
"""Transport interface consumed by :class:`PPIClient`."""
|
|
71
|
+
|
|
72
|
+
def open(self) -> None: ...
|
|
73
|
+
|
|
74
|
+
def close(self) -> None: ...
|
|
75
|
+
|
|
76
|
+
def exchange(self, pdu: bytes) -> bytes: ...
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _validate_station(address: int) -> None:
|
|
80
|
+
if not 0 <= address <= 126:
|
|
81
|
+
raise ValueError(f"PPI station address must be between 0 and 126, got {address}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _checksum(data: bytes) -> int:
|
|
85
|
+
return sum(data) & 0xFF
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def encode_sd1(destination: int, source: int, control: int) -> bytes:
|
|
89
|
+
"""Encode a fixed-length SD1 frame."""
|
|
90
|
+
_validate_station(destination)
|
|
91
|
+
_validate_station(source)
|
|
92
|
+
body = bytes((destination, source, control))
|
|
93
|
+
return bytes((PPIFrameType.SD1,)) + body + bytes((_checksum(body), 0x16))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def encode_sd2(destination: int, source: int, control: int, payload: bytes) -> bytes:
|
|
97
|
+
"""Encode a variable-length SD2 frame."""
|
|
98
|
+
_validate_station(destination)
|
|
99
|
+
_validate_station(source)
|
|
100
|
+
body = bytes((destination, source, control)) + payload
|
|
101
|
+
if len(body) > 249:
|
|
102
|
+
raise ValueError(f"SD2 body exceeds the 249-byte limit: {len(body)}")
|
|
103
|
+
length = len(body)
|
|
104
|
+
return bytes((PPIFrameType.SD2, length, length, PPIFrameType.SD2)) + body + bytes((_checksum(body), 0x16))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def encode_sd3(destination: int, source: int, control: int, payload: bytes) -> bytes:
|
|
108
|
+
"""Encode an SD3 frame with its fixed eight-byte data field."""
|
|
109
|
+
_validate_station(destination)
|
|
110
|
+
_validate_station(source)
|
|
111
|
+
if len(payload) != 8:
|
|
112
|
+
raise ValueError("SD3 payload must contain exactly 8 bytes")
|
|
113
|
+
body = bytes((destination, source, control)) + payload
|
|
114
|
+
return bytes((PPIFrameType.SD3,)) + body + bytes((_checksum(body), 0x16))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def decode_frame(data: bytes) -> PPIFrame:
|
|
118
|
+
"""Decode and validate one complete PPI frame."""
|
|
119
|
+
if not data:
|
|
120
|
+
raise S7ProtocolError("Empty PPI frame")
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
frame_type = PPIFrameType(data[0])
|
|
124
|
+
except ValueError as exc:
|
|
125
|
+
raise S7ProtocolError(f"Unknown PPI start delimiter: 0x{data[0]:02x}") from exc
|
|
126
|
+
|
|
127
|
+
if frame_type == PPIFrameType.SC:
|
|
128
|
+
if data != bytes((PPIFrameType.SC,)):
|
|
129
|
+
raise S7ProtocolError("SC acknowledgement must be exactly one byte")
|
|
130
|
+
return PPIFrame(frame_type)
|
|
131
|
+
|
|
132
|
+
if frame_type == PPIFrameType.SD1:
|
|
133
|
+
if len(data) != 6:
|
|
134
|
+
raise S7ProtocolError(f"SD1 frame must be 6 bytes, got {len(data)}")
|
|
135
|
+
body = data[1:4]
|
|
136
|
+
elif frame_type == PPIFrameType.SD2:
|
|
137
|
+
if len(data) < 9:
|
|
138
|
+
raise S7ProtocolError("SD2 frame is too short")
|
|
139
|
+
if data[1] != data[2] or data[3] != PPIFrameType.SD2:
|
|
140
|
+
raise S7ProtocolError("Invalid SD2 repeated length or delimiter")
|
|
141
|
+
if len(data) != data[1] + 6:
|
|
142
|
+
raise S7ProtocolError(f"SD2 length mismatch: header says {data[1]}, frame has {len(data)} bytes")
|
|
143
|
+
body = data[4:-2]
|
|
144
|
+
else:
|
|
145
|
+
if len(data) != 14:
|
|
146
|
+
raise S7ProtocolError(f"SD3 frame must be 14 bytes, got {len(data)}")
|
|
147
|
+
body = data[1:-2]
|
|
148
|
+
|
|
149
|
+
if data[-1] != 0x16:
|
|
150
|
+
raise S7ProtocolError("Invalid PPI end delimiter")
|
|
151
|
+
if data[-2] != _checksum(body):
|
|
152
|
+
raise S7ProtocolError("Invalid PPI frame checksum")
|
|
153
|
+
|
|
154
|
+
return PPIFrame(frame_type, body[0], body[1], body[2], bytes(body[3:]))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class PPITransport:
|
|
158
|
+
"""Serial PPI master transport for a single S7-200 slave."""
|
|
159
|
+
|
|
160
|
+
def __init__(
|
|
161
|
+
self,
|
|
162
|
+
port: str,
|
|
163
|
+
*,
|
|
164
|
+
station: int = 2,
|
|
165
|
+
local_station: int = 0,
|
|
166
|
+
baudrate: int = 9600,
|
|
167
|
+
timeout: float = 0.15,
|
|
168
|
+
retries: int = 3,
|
|
169
|
+
serial_port: SerialPort | None = None,
|
|
170
|
+
) -> None:
|
|
171
|
+
_validate_station(station)
|
|
172
|
+
_validate_station(local_station)
|
|
173
|
+
if baudrate <= 0:
|
|
174
|
+
raise ValueError("baudrate must be positive")
|
|
175
|
+
if timeout <= 0:
|
|
176
|
+
raise ValueError("timeout must be positive")
|
|
177
|
+
if retries < 1:
|
|
178
|
+
raise ValueError("retries must be at least 1")
|
|
179
|
+
|
|
180
|
+
self.port = port
|
|
181
|
+
self.station = station
|
|
182
|
+
self.local_station = local_station
|
|
183
|
+
self.baudrate = baudrate
|
|
184
|
+
self.timeout = timeout
|
|
185
|
+
self.retries = retries
|
|
186
|
+
self._serial = serial_port
|
|
187
|
+
self._owns_serial = serial_port is None
|
|
188
|
+
self._lock = threading.Lock()
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def is_open(self) -> bool:
|
|
192
|
+
return self._serial is not None
|
|
193
|
+
|
|
194
|
+
def open(self) -> None:
|
|
195
|
+
"""Open the configured serial port using PPI's 8E1 settings."""
|
|
196
|
+
if self._serial is not None:
|
|
197
|
+
return
|
|
198
|
+
try:
|
|
199
|
+
serial = importlib.import_module("serial")
|
|
200
|
+
except ImportError as exc:
|
|
201
|
+
raise ImportError("PPI support requires pyserial; install python-snap7[ppi]") from exc
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
self._serial = cast(
|
|
205
|
+
SerialPort,
|
|
206
|
+
serial.Serial(
|
|
207
|
+
port=self.port,
|
|
208
|
+
baudrate=self.baudrate,
|
|
209
|
+
bytesize=serial.EIGHTBITS,
|
|
210
|
+
parity=serial.PARITY_EVEN,
|
|
211
|
+
stopbits=serial.STOPBITS_ONE,
|
|
212
|
+
timeout=self.timeout,
|
|
213
|
+
write_timeout=self.timeout,
|
|
214
|
+
),
|
|
215
|
+
)
|
|
216
|
+
except Exception as exc:
|
|
217
|
+
raise S7ConnectionError(f"Could not open PPI serial port {self.port}: {exc}") from exc
|
|
218
|
+
|
|
219
|
+
def close(self) -> None:
|
|
220
|
+
"""Close a serial port opened by this transport."""
|
|
221
|
+
if self._serial is not None and self._owns_serial:
|
|
222
|
+
self._serial.close()
|
|
223
|
+
self._serial = None
|
|
224
|
+
|
|
225
|
+
def _read_exact(self, size: int) -> bytes:
|
|
226
|
+
assert self._serial is not None
|
|
227
|
+
data = bytearray()
|
|
228
|
+
while len(data) < size:
|
|
229
|
+
chunk = self._serial.read(size - len(data))
|
|
230
|
+
if not chunk:
|
|
231
|
+
raise S7ConnectionError(f"Timeout while reading PPI frame ({len(data)}/{size} bytes)")
|
|
232
|
+
data.extend(chunk)
|
|
233
|
+
return bytes(data)
|
|
234
|
+
|
|
235
|
+
def _read_frame(self) -> PPIFrame:
|
|
236
|
+
start = self._read_exact(1)
|
|
237
|
+
delimiter = start[0]
|
|
238
|
+
if delimiter == PPIFrameType.SC:
|
|
239
|
+
return decode_frame(start)
|
|
240
|
+
if delimiter == PPIFrameType.SD1:
|
|
241
|
+
return decode_frame(start + self._read_exact(5))
|
|
242
|
+
if delimiter == PPIFrameType.SD2:
|
|
243
|
+
prefix = self._read_exact(3)
|
|
244
|
+
if prefix[0] != prefix[1] or prefix[2] != PPIFrameType.SD2:
|
|
245
|
+
raise S7ProtocolError("Invalid SD2 repeated length or delimiter")
|
|
246
|
+
return decode_frame(start + prefix + self._read_exact(prefix[0] + 2))
|
|
247
|
+
if delimiter == PPIFrameType.SD3:
|
|
248
|
+
return decode_frame(start + self._read_exact(13))
|
|
249
|
+
raise S7ProtocolError(f"Unknown PPI start delimiter: 0x{delimiter:02x}")
|
|
250
|
+
|
|
251
|
+
def _write_frame(self, frame: bytes) -> None:
|
|
252
|
+
assert self._serial is not None
|
|
253
|
+
written = self._serial.write(frame)
|
|
254
|
+
if written is not None and written != len(frame):
|
|
255
|
+
raise S7ConnectionError(f"Short PPI serial write ({written}/{len(frame)} bytes)")
|
|
256
|
+
|
|
257
|
+
def exchange(self, pdu: bytes) -> bytes:
|
|
258
|
+
"""Exchange one S7 PDU with the configured S7-200 station."""
|
|
259
|
+
if self._serial is None:
|
|
260
|
+
raise S7ConnectionError("PPI serial port is not open")
|
|
261
|
+
|
|
262
|
+
request = encode_sd2(self.station, self.local_station, 0x6C, pdu)
|
|
263
|
+
with self._lock:
|
|
264
|
+
for attempt in range(self.retries):
|
|
265
|
+
self._write_frame(request)
|
|
266
|
+
try:
|
|
267
|
+
acknowledgement = self._read_frame()
|
|
268
|
+
except (S7ConnectionError, S7ProtocolError):
|
|
269
|
+
if attempt + 1 == self.retries:
|
|
270
|
+
raise
|
|
271
|
+
continue
|
|
272
|
+
if acknowledgement.frame_type != PPIFrameType.SC:
|
|
273
|
+
raise S7ProtocolError("Expected E5 acknowledgement after PPI request")
|
|
274
|
+
break
|
|
275
|
+
|
|
276
|
+
poll_control = 0x5C
|
|
277
|
+
self._write_frame(encode_sd1(self.station, self.local_station, poll_control))
|
|
278
|
+
for _ in range(self.retries * 2):
|
|
279
|
+
try:
|
|
280
|
+
response = self._read_frame()
|
|
281
|
+
except S7ProtocolError:
|
|
282
|
+
# A corrupt frame can precede a valid response already
|
|
283
|
+
# buffered by the serial adapter. Keep scanning within the
|
|
284
|
+
# bounded poll window instead of discarding that response.
|
|
285
|
+
continue
|
|
286
|
+
if response.frame_type == PPIFrameType.SC:
|
|
287
|
+
poll_control = 0x7C if poll_control == 0x5C else 0x5C
|
|
288
|
+
self._write_frame(encode_sd1(self.station, self.local_station, poll_control))
|
|
289
|
+
continue
|
|
290
|
+
if response.frame_type != PPIFrameType.SD2:
|
|
291
|
+
raise S7ProtocolError(f"Expected SD2 PPI response, got {response.frame_type.name}")
|
|
292
|
+
if response.destination != self.local_station or response.source != self.station:
|
|
293
|
+
raise S7ProtocolError(f"Unexpected PPI response addresses: {response.source} -> {response.destination}")
|
|
294
|
+
return response.payload
|
|
295
|
+
|
|
296
|
+
raise S7ConnectionError("PPI response was not available after polling")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class PPIClient:
|
|
300
|
+
"""Minimal S7-200 client using PPI over a serial port."""
|
|
301
|
+
|
|
302
|
+
def __init__(
|
|
303
|
+
self,
|
|
304
|
+
port: str,
|
|
305
|
+
*,
|
|
306
|
+
station: int = 2,
|
|
307
|
+
local_station: int = 0,
|
|
308
|
+
baudrate: int = 9600,
|
|
309
|
+
timeout: float = 0.15,
|
|
310
|
+
transport: PPIExchangeTransport | None = None,
|
|
311
|
+
) -> None:
|
|
312
|
+
self.transport = transport or PPITransport(
|
|
313
|
+
port,
|
|
314
|
+
station=station,
|
|
315
|
+
local_station=local_station,
|
|
316
|
+
baudrate=baudrate,
|
|
317
|
+
timeout=timeout,
|
|
318
|
+
)
|
|
319
|
+
self.protocol = S7Protocol()
|
|
320
|
+
self.connected = False
|
|
321
|
+
self.pdu_length = 240
|
|
322
|
+
self._lock = threading.Lock()
|
|
323
|
+
|
|
324
|
+
def connect(self) -> "PPIClient":
|
|
325
|
+
"""Open the serial port and negotiate the S7 PDU length."""
|
|
326
|
+
self.transport.open()
|
|
327
|
+
try:
|
|
328
|
+
response = self._exchange(lambda: self.protocol.build_setup_communication_request(pdu_length=self.pdu_length))
|
|
329
|
+
parameters = response.get("parameters") or {}
|
|
330
|
+
negotiated = int(parameters.get("pdu_length", self.pdu_length))
|
|
331
|
+
# SD2's one-byte length field permits at most 249 body bytes;
|
|
332
|
+
# three of those are the PPI destination/source/control fields.
|
|
333
|
+
self.pdu_length = min(negotiated, 246)
|
|
334
|
+
self.connected = True
|
|
335
|
+
except Exception:
|
|
336
|
+
self.transport.close()
|
|
337
|
+
raise
|
|
338
|
+
return self
|
|
339
|
+
|
|
340
|
+
def disconnect(self) -> None:
|
|
341
|
+
"""Close the PPI transport."""
|
|
342
|
+
self.transport.close()
|
|
343
|
+
self.connected = False
|
|
344
|
+
|
|
345
|
+
def _exchange(self, request_builder: Callable[[], bytes]) -> dict[str, Any]:
|
|
346
|
+
"""Build and exchange one request while owning the sequence lock."""
|
|
347
|
+
with self._lock:
|
|
348
|
+
request = request_builder()
|
|
349
|
+
response = self.protocol.parse_response(self.transport.exchange(request))
|
|
350
|
+
self.protocol.validate_pdu_reference(int(response["sequence"]))
|
|
351
|
+
return response
|
|
352
|
+
|
|
353
|
+
@staticmethod
|
|
354
|
+
def _validate_start(start: int, word_len: S7WordLen) -> None:
|
|
355
|
+
# S7 item specifications carry a 24-bit address. Most areas convert
|
|
356
|
+
# byte offsets to bit addresses; S7-200 timers/counters use item indexes.
|
|
357
|
+
max_start = (1 << 24) - 1 if word_len in (S7WordLen.COUNTER_200, S7WordLen.TIMER_200) else (1 << 21) - 1
|
|
358
|
+
if not 0 <= start <= max_start:
|
|
359
|
+
raise ValueError(f"start must be between 0 and {max_start}, got {start}")
|
|
360
|
+
|
|
361
|
+
@staticmethod
|
|
362
|
+
def _area_spec(area: PPIArea) -> tuple[S7Area, int, S7WordLen]:
|
|
363
|
+
if area == PPIArea.V:
|
|
364
|
+
return S7Area.DB, 1, S7WordLen.BYTE
|
|
365
|
+
if area in (PPIArea.AI, PPIArea.AQ):
|
|
366
|
+
return cast(S7Area, area), 0, S7WordLen.WORD
|
|
367
|
+
if area == PPIArea.C:
|
|
368
|
+
return cast(S7Area, area), 0, S7WordLen.COUNTER_200
|
|
369
|
+
if area == PPIArea.T:
|
|
370
|
+
return cast(S7Area, area), 0, S7WordLen.TIMER_200
|
|
371
|
+
return cast(S7Area, area), 0, S7WordLen.BYTE
|
|
372
|
+
|
|
373
|
+
def read_area(self, area: PPIArea, start: int, count: int) -> bytearray:
|
|
374
|
+
"""Read items from an S7-200 memory area.
|
|
375
|
+
|
|
376
|
+
``count`` is bytes for S/SM/I/Q/M/V and 16-bit items for AI/AQ/C/T.
|
|
377
|
+
"""
|
|
378
|
+
if not self.connected:
|
|
379
|
+
raise S7ConnectionError("PPI client is not connected")
|
|
380
|
+
if count < 1:
|
|
381
|
+
raise ValueError("count must be at least 1")
|
|
382
|
+
wire_area, db_number, word_len = self._area_spec(area)
|
|
383
|
+
self._validate_start(start, word_len)
|
|
384
|
+
item_size = 2 if word_len in (S7WordLen.WORD, S7WordLen.COUNTER_200, S7WordLen.TIMER_200) else 1
|
|
385
|
+
if count * item_size > self.pdu_length - 18:
|
|
386
|
+
raise ValueError("PPI read exceeds the negotiated PDU size")
|
|
387
|
+
response = self._exchange(lambda: self.protocol.build_read_request(wire_area, db_number, start, word_len, count))
|
|
388
|
+
return bytearray(self.protocol.extract_read_data(response, word_len, count))
|
|
389
|
+
|
|
390
|
+
def write_area(self, area: PPIArea, start: int, data: bytes | bytearray) -> None:
|
|
391
|
+
"""Write data to an S7-200 memory area."""
|
|
392
|
+
if not self.connected:
|
|
393
|
+
raise S7ConnectionError("PPI client is not connected")
|
|
394
|
+
wire_area, db_number, word_len = self._area_spec(area)
|
|
395
|
+
self._validate_start(start, word_len)
|
|
396
|
+
item_size = 2 if word_len in (S7WordLen.WORD, S7WordLen.COUNTER_200, S7WordLen.TIMER_200) else 1
|
|
397
|
+
if not data or len(data) % item_size:
|
|
398
|
+
raise ValueError(f"data length must be a non-zero multiple of {item_size}")
|
|
399
|
+
if len(data) > self.pdu_length - 35:
|
|
400
|
+
raise ValueError("PPI write exceeds the negotiated PDU size")
|
|
401
|
+
response = self._exchange(lambda: self.protocol.build_write_request(wire_area, db_number, start, word_len, bytes(data)))
|
|
402
|
+
self.protocol.check_write_response(response)
|
|
403
|
+
|
|
404
|
+
def v_read(self, start: int, size: int) -> bytearray:
|
|
405
|
+
"""Read bytes from S7-200 V memory (wire-level DB1)."""
|
|
406
|
+
return self.read_area(PPIArea.V, start, size)
|
|
407
|
+
|
|
408
|
+
def v_write(self, start: int, data: bytes | bytearray) -> None:
|
|
409
|
+
"""Write bytes to S7-200 V memory (wire-level DB1)."""
|
|
410
|
+
self.write_area(PPIArea.V, start, data)
|
|
411
|
+
|
|
412
|
+
def __enter__(self) -> "PPIClient":
|
|
413
|
+
return self.connect()
|
|
414
|
+
|
|
415
|
+
def __exit__(
|
|
416
|
+
self,
|
|
417
|
+
exc_type: type[BaseException] | None,
|
|
418
|
+
exc_value: BaseException | None,
|
|
419
|
+
traceback: TracebackType | None,
|
|
420
|
+
) -> None:
|
|
421
|
+
self.disconnect()
|
snap7/rate_limiter.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Request rate limiting for synchronous and asynchronous S7 clients."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import math
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from .error import S7RateLimitError
|
|
11
|
+
|
|
12
|
+
RateLimitAlgorithm = Literal["fixed", "token_bucket"]
|
|
13
|
+
RateLimitBehavior = Literal["block", "raise"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RequestRateLimiter:
|
|
17
|
+
"""Per-client request rate limiter with thread-safe accounting.
|
|
18
|
+
|
|
19
|
+
``fixed`` spaces requests evenly. ``token_bucket`` permits bursts up to
|
|
20
|
+
``burst_capacity`` and then refills continuously at the configured rate.
|
|
21
|
+
A rate of zero disables limiting. This lock protects only limiter state;
|
|
22
|
+
it does not make the synchronous client safe for concurrent use.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
max_requests_per_second: float = 0,
|
|
28
|
+
*,
|
|
29
|
+
algorithm: RateLimitAlgorithm = "fixed",
|
|
30
|
+
behavior: RateLimitBehavior = "block",
|
|
31
|
+
burst_capacity: int | None = None,
|
|
32
|
+
_clock: Callable[[], float] = time.monotonic,
|
|
33
|
+
_sleep: Callable[[float], None] = time.sleep,
|
|
34
|
+
) -> None:
|
|
35
|
+
if not math.isfinite(max_requests_per_second) or max_requests_per_second < 0:
|
|
36
|
+
raise ValueError("max_requests_per_second must be a finite non-negative number")
|
|
37
|
+
if algorithm not in ("fixed", "token_bucket"):
|
|
38
|
+
raise ValueError("rate_limit_algorithm must be 'fixed' or 'token_bucket'")
|
|
39
|
+
if behavior not in ("block", "raise"):
|
|
40
|
+
raise ValueError("rate_limit_behavior must be 'block' or 'raise'")
|
|
41
|
+
if burst_capacity is not None and burst_capacity < 1:
|
|
42
|
+
raise ValueError("rate_limit_burst must be at least 1")
|
|
43
|
+
|
|
44
|
+
self.rate = float(max_requests_per_second)
|
|
45
|
+
self.algorithm = algorithm
|
|
46
|
+
self.behavior = behavior
|
|
47
|
+
self.burst_capacity = burst_capacity or max(1, math.ceil(self.rate))
|
|
48
|
+
self._clock = _clock
|
|
49
|
+
self._sleep = _sleep
|
|
50
|
+
self._lock = threading.Lock()
|
|
51
|
+
|
|
52
|
+
now = self._clock()
|
|
53
|
+
self._next_request = now
|
|
54
|
+
self._tokens = float(self.burst_capacity)
|
|
55
|
+
self._last_refill = now
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def enabled(self) -> bool:
|
|
59
|
+
"""Whether rate limiting is active."""
|
|
60
|
+
return self.rate > 0
|
|
61
|
+
|
|
62
|
+
def _reserve(self) -> float:
|
|
63
|
+
"""Reserve one request and return the required delay in seconds."""
|
|
64
|
+
if not self.enabled:
|
|
65
|
+
return 0.0
|
|
66
|
+
|
|
67
|
+
with self._lock:
|
|
68
|
+
now = self._clock()
|
|
69
|
+
if self.algorithm == "fixed":
|
|
70
|
+
delay = max(0.0, self._next_request - now)
|
|
71
|
+
if delay > 0 and self.behavior != "block":
|
|
72
|
+
self._reject()
|
|
73
|
+
slot = now + delay
|
|
74
|
+
self._next_request = slot + (1.0 / self.rate)
|
|
75
|
+
return delay
|
|
76
|
+
|
|
77
|
+
# Refill only through the current time. A future _last_refill
|
|
78
|
+
# represents capacity reserved by another acquire call whose
|
|
79
|
+
# sleep happens after it releases this accounting lock.
|
|
80
|
+
if now > self._last_refill:
|
|
81
|
+
elapsed = now - self._last_refill
|
|
82
|
+
self._tokens = min(float(self.burst_capacity), self._tokens + elapsed * self.rate)
|
|
83
|
+
self._last_refill = now
|
|
84
|
+
|
|
85
|
+
if self._tokens >= 1.0:
|
|
86
|
+
self._tokens -= 1.0
|
|
87
|
+
return 0.0
|
|
88
|
+
|
|
89
|
+
queued_delay = self._last_refill - now
|
|
90
|
+
delay = queued_delay + ((1.0 - self._tokens) / self.rate)
|
|
91
|
+
if self.behavior != "block":
|
|
92
|
+
self._reject()
|
|
93
|
+
self._tokens = 0.0
|
|
94
|
+
self._last_refill = now + delay
|
|
95
|
+
return delay
|
|
96
|
+
|
|
97
|
+
def _reject(self) -> None:
|
|
98
|
+
raise S7RateLimitError(f"Request rejected: rate limit of {self.rate:g} requests/second exceeded")
|
|
99
|
+
|
|
100
|
+
def acquire(self) -> None:
|
|
101
|
+
"""Wait for or reserve permission to send one synchronous request."""
|
|
102
|
+
delay = self._reserve()
|
|
103
|
+
if delay > 0:
|
|
104
|
+
self._sleep(delay)
|
|
105
|
+
|
|
106
|
+
async def acquire_async(self) -> None:
|
|
107
|
+
"""Wait for or reserve permission to send one asynchronous request."""
|
|
108
|
+
delay = self._reserve()
|
|
109
|
+
if delay > 0:
|
|
110
|
+
await asyncio.sleep(delay)
|