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.
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 == S7WordLen.BIT:
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 word_len == S7WordLen.WORD or word_len == S7WordLen.COUNTER or word_len == S7WordLen.TIMER:
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 word_len == S7WordLen.WORD or word_len == S7WordLen.COUNTER or word_len == S7WordLen.TIMER:
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__(self, **kwargs: object) -> None:
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
- # Connect using parent Client implementation
109
- # For Logo, rack and slot are not used in the standard way
110
- # but we still need to establish the connection
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)