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/client.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Pure Python S7 client implementation.
|
|
1
|
+
"""Synchronous client for the classic S7 protocol.
|
|
3
2
|
|
|
4
|
-
|
|
3
|
+
``s7.Client`` and ``snap7.Client`` expose this same implementation. The
|
|
4
|
+
``s7`` import name is recommended for new projects.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import copy
|
|
@@ -11,6 +11,7 @@ import struct
|
|
|
11
11
|
import sys
|
|
12
12
|
import threading
|
|
13
13
|
import time
|
|
14
|
+
from collections.abc import Sequence
|
|
14
15
|
from typing import List, Any, Optional, Tuple, Union, Callable, cast
|
|
15
16
|
from datetime import datetime
|
|
16
17
|
from ctypes import (
|
|
@@ -21,11 +22,12 @@ from ctypes import (
|
|
|
21
22
|
|
|
22
23
|
from .connection import ISOTCPConnection
|
|
23
24
|
from .s7protocol import S7Protocol, get_return_code_description
|
|
24
|
-
from .datatypes import S7WordLen
|
|
25
|
+
from .datatypes import S7DataTypes, S7WordLen
|
|
25
26
|
from .error import S7Error, S7ConnectionError, S7ProtocolError, S7StalePacketError, S7TimeoutError
|
|
26
27
|
from .client_base import ClientMixin
|
|
27
28
|
from .log import PLCLoggerAdapter, OperationLogger
|
|
28
29
|
from .optimizer import ReadItem, ReadPacket, sort_items, merge_items, packetize, extract_results
|
|
30
|
+
from .rate_limiter import RateLimitAlgorithm, RateLimitBehavior, RequestRateLimiter
|
|
29
31
|
from .tags import Tag, _STRING_RE
|
|
30
32
|
from . import util
|
|
31
33
|
|
|
@@ -34,6 +36,7 @@ from .type import (
|
|
|
34
36
|
Area,
|
|
35
37
|
Block,
|
|
36
38
|
BlocksList,
|
|
39
|
+
ForceEntry,
|
|
37
40
|
S7CpuInfo,
|
|
38
41
|
TS7BlockInfo,
|
|
39
42
|
S7DataItem,
|
|
@@ -231,6 +234,31 @@ def _encode_scalar(datatype: str, buf: bytearray, offset: int, value: Any, bit:
|
|
|
231
234
|
raise ValueError(f"Unsupported tag datatype: {datatype}")
|
|
232
235
|
|
|
233
236
|
|
|
237
|
+
def _parse_force_szl(raw: bytes) -> list[ForceEntry]:
|
|
238
|
+
"""Parse SZL 0x0025 (force table) data into :class:`ForceEntry` items.
|
|
239
|
+
|
|
240
|
+
Each SZL 0x0025 entry is 8 bytes:
|
|
241
|
+
- bytes 0-1: area code (big-endian, 0x0081=PE, 0x0082=PA)
|
|
242
|
+
- bytes 2-3: byte offset (big-endian)
|
|
243
|
+
- byte 4: bit number (0-7)
|
|
244
|
+
- byte 5: force value (0x00=False, 0x01=True)
|
|
245
|
+
- bytes 6-7: reserved
|
|
246
|
+
|
|
247
|
+
Returns an empty list if *raw* is too short or contains no entries.
|
|
248
|
+
"""
|
|
249
|
+
entry_size = 8
|
|
250
|
+
entries: list[ForceEntry] = []
|
|
251
|
+
offset = 0
|
|
252
|
+
while offset + entry_size <= len(raw):
|
|
253
|
+
area_code = struct.unpack(">H", raw[offset : offset + 2])[0]
|
|
254
|
+
byte_off = struct.unpack(">H", raw[offset + 2 : offset + 4])[0]
|
|
255
|
+
bit_num = raw[offset + 4]
|
|
256
|
+
val = raw[offset + 5] != 0
|
|
257
|
+
entries.append(ForceEntry(area=area_code, byte_offset=byte_off, bit=bit_num, value=val))
|
|
258
|
+
offset += entry_size
|
|
259
|
+
return entries
|
|
260
|
+
|
|
261
|
+
|
|
234
262
|
class _OptimizationPlan:
|
|
235
263
|
"""Cached optimization plan for repeated read_multi_vars calls with the same layout."""
|
|
236
264
|
|
|
@@ -241,15 +269,14 @@ class _OptimizationPlan:
|
|
|
241
269
|
|
|
242
270
|
|
|
243
271
|
class Client(ClientMixin):
|
|
244
|
-
"""
|
|
245
|
-
Pure Python S7 client implementation.
|
|
272
|
+
"""Synchronous client for classic S7 communication.
|
|
246
273
|
|
|
247
|
-
|
|
248
|
-
|
|
274
|
+
Supports S7-300, S7-400, S7-1200 and S7-1500 PLCs via the classic S7
|
|
275
|
+
protocol. S7-1200 and S7-1500 access requires PUT/GET to be enabled.
|
|
249
276
|
|
|
250
277
|
Examples:
|
|
251
|
-
>>> import
|
|
252
|
-
>>> client =
|
|
278
|
+
>>> from s7 import Client
|
|
279
|
+
>>> client = Client()
|
|
253
280
|
>>> client.connect("192.168.1.10", 0, 1)
|
|
254
281
|
>>> data = client.db_read(1, 0, 4)
|
|
255
282
|
>>> client.disconnect()
|
|
@@ -267,6 +294,10 @@ class Client(ClientMixin):
|
|
|
267
294
|
backoff_factor: float = 2.0,
|
|
268
295
|
max_delay: float = 30.0,
|
|
269
296
|
heartbeat_interval: float = 0,
|
|
297
|
+
max_requests_per_second: float = 0,
|
|
298
|
+
rate_limit_algorithm: RateLimitAlgorithm = "fixed",
|
|
299
|
+
rate_limit_behavior: RateLimitBehavior = "block",
|
|
300
|
+
rate_limit_burst: int | None = None,
|
|
270
301
|
on_disconnect: Optional[Callable[[], None]] = None,
|
|
271
302
|
on_reconnect: Optional[Callable[[], None]] = None,
|
|
272
303
|
**kwargs: Any,
|
|
@@ -282,6 +313,10 @@ class Client(ClientMixin):
|
|
|
282
313
|
backoff_factor: Multiplier for exponential backoff between retries.
|
|
283
314
|
max_delay: Maximum delay between reconnection attempts in seconds.
|
|
284
315
|
heartbeat_interval: Interval in seconds for heartbeat probes (0=disabled).
|
|
316
|
+
max_requests_per_second: Maximum outbound PLC requests per second (0=disabled).
|
|
317
|
+
rate_limit_algorithm: ``fixed`` for even spacing or ``token_bucket`` for bursts.
|
|
318
|
+
rate_limit_behavior: ``block`` to wait or ``raise`` to reject immediately.
|
|
319
|
+
rate_limit_burst: Token bucket capacity. Defaults to one second of requests.
|
|
285
320
|
on_disconnect: Optional callback invoked when connection is lost.
|
|
286
321
|
on_reconnect: Optional callback invoked after successful reconnection.
|
|
287
322
|
**kwargs: Ignored. Kept for backwards compatibility.
|
|
@@ -341,6 +376,12 @@ class Client(ClientMixin):
|
|
|
341
376
|
self._max_delay = max_delay
|
|
342
377
|
self._on_disconnect = on_disconnect
|
|
343
378
|
self._on_reconnect = on_reconnect
|
|
379
|
+
self._rate_limiter = RequestRateLimiter(
|
|
380
|
+
max_requests_per_second,
|
|
381
|
+
algorithm=rate_limit_algorithm,
|
|
382
|
+
behavior=rate_limit_behavior,
|
|
383
|
+
burst_capacity=rate_limit_burst,
|
|
384
|
+
)
|
|
344
385
|
|
|
345
386
|
# Heartbeat settings
|
|
346
387
|
self._heartbeat_interval = heartbeat_interval
|
|
@@ -373,6 +414,11 @@ class Client(ClientMixin):
|
|
|
373
414
|
raise S7ConnectionError("Not connected to PLC")
|
|
374
415
|
return self.connection
|
|
375
416
|
|
|
417
|
+
def _send_data(self, conn: ISOTCPConnection, request: bytes) -> None:
|
|
418
|
+
"""Apply the per-client rate limit and send one S7 request PDU."""
|
|
419
|
+
self._rate_limiter.acquire()
|
|
420
|
+
conn.send_data(request)
|
|
421
|
+
|
|
376
422
|
def _send_receive(self, request: bytes, max_stale_retries: int = 3) -> dict[str, Any]:
|
|
377
423
|
"""Send a request and receive/parse the response with stale packet retry.
|
|
378
424
|
|
|
@@ -395,7 +441,7 @@ class Client(ClientMixin):
|
|
|
395
441
|
conn = self._get_connection()
|
|
396
442
|
|
|
397
443
|
with self._reconnect_lock:
|
|
398
|
-
|
|
444
|
+
self._send_data(conn, request)
|
|
399
445
|
|
|
400
446
|
for attempt in range(max_stale_retries + 1):
|
|
401
447
|
response_data = conn.receive_data()
|
|
@@ -571,16 +617,22 @@ class Client(ClientMixin):
|
|
|
571
617
|
Returns:
|
|
572
618
|
Self for method chaining
|
|
573
619
|
"""
|
|
620
|
+
# Remote TSAP: connection type, rack and slot encoded per S7.
|
|
621
|
+
self.remote_tsap = (self.connection_type << 8) | (rack << 5) | slot
|
|
622
|
+
return self._connect(address, rack, slot, tcp_port)
|
|
623
|
+
|
|
624
|
+
def _connect(self, address: str, rack: int, slot: int, tcp_port: int) -> "Client":
|
|
625
|
+
"""Establish a connection using the configured local and remote TSAPs.
|
|
626
|
+
|
|
627
|
+
LOGO clients supply explicit TSAPs instead of deriving them from a
|
|
628
|
+
rack and slot. Both paths share connection and heartbeat setup.
|
|
629
|
+
"""
|
|
574
630
|
self.host = address
|
|
575
631
|
self.port = tcp_port
|
|
576
632
|
self.rack = rack
|
|
577
633
|
self.slot = slot
|
|
578
634
|
self._params[Parameter.RemotePort] = tcp_port
|
|
579
635
|
|
|
580
|
-
# Calculate TSAP values from rack/slot
|
|
581
|
-
# Remote TSAP: rack and slot encoded as per S7 specification
|
|
582
|
-
self.remote_tsap = (self.connection_type << 8) | (rack << 5) | slot
|
|
583
|
-
|
|
584
636
|
try:
|
|
585
637
|
start_time = time.time()
|
|
586
638
|
|
|
@@ -804,9 +856,7 @@ class Client(ClientMixin):
|
|
|
804
856
|
"""
|
|
805
857
|
resolved = Tag.from_string(tag) if isinstance(tag, str) else tag
|
|
806
858
|
if resolved.is_symbolic:
|
|
807
|
-
raise NotImplementedError(
|
|
808
|
-
"Symbolic (LID-based) tag access requires S7CommPlus. Use s7.Client instead of snap7.Client."
|
|
809
|
-
)
|
|
859
|
+
raise NotImplementedError("Symbolic (LID-based) tag access is not supported by the classic S7 client.")
|
|
810
860
|
data = self.read_area(Area(resolved.area), resolved.db_number, resolved.byte_offset, resolved.size)
|
|
811
861
|
return _decode_tag(resolved, bytearray(data), encoding=encoding)
|
|
812
862
|
|
|
@@ -823,9 +873,7 @@ class Client(ClientMixin):
|
|
|
823
873
|
"""
|
|
824
874
|
resolved = Tag.from_string(tag) if isinstance(tag, str) else tag
|
|
825
875
|
if resolved.is_symbolic:
|
|
826
|
-
raise NotImplementedError(
|
|
827
|
-
"Symbolic (LID-based) tag access requires S7CommPlus. Use s7.Client instead of snap7.Client."
|
|
828
|
-
)
|
|
876
|
+
raise NotImplementedError("Symbolic (LID-based) tag access is not supported by the classic S7 client.")
|
|
829
877
|
size = resolved.size
|
|
830
878
|
buf = bytearray(size)
|
|
831
879
|
# For BOOL writes, we need the current byte to preserve other bits
|
|
@@ -835,14 +883,14 @@ class Client(ClientMixin):
|
|
|
835
883
|
_encode_tag(resolved, buf, value, encoding=encoding)
|
|
836
884
|
return self.write_area(Area(resolved.area), resolved.db_number, resolved.byte_offset, buf)
|
|
837
885
|
|
|
838
|
-
def read_tags(self, tags: "
|
|
886
|
+
def read_tags(self, tags: "Sequence[Union[Tag, str]]", encoding: str = "latin-1") -> list[Any]:
|
|
839
887
|
"""Read multiple tags in a single optimized request.
|
|
840
888
|
|
|
841
889
|
Uses the multi-variable read optimizer when available to batch
|
|
842
890
|
reads into minimal PDU exchanges.
|
|
843
891
|
|
|
844
892
|
Args:
|
|
845
|
-
tags:
|
|
893
|
+
tags: Sequence of :class:`~snap7.tags.Tag` instances or address strings.
|
|
846
894
|
encoding: Character encoding for STRING/FSTRING values (default ``"latin-1"``).
|
|
847
895
|
|
|
848
896
|
Returns:
|
|
@@ -956,7 +1004,8 @@ class Client(ClientMixin):
|
|
|
956
1004
|
area: Memory area to read from
|
|
957
1005
|
db_number: DB number (for DB area only)
|
|
958
1006
|
start: Start address
|
|
959
|
-
size: Number of
|
|
1007
|
+
size: Number of elements of the selected word length (bytes by default).
|
|
1008
|
+
BIT values are returned as one byte per bit; TM/CT use two-byte elements.
|
|
960
1009
|
word_len: Optional word length override. If None, defaults to area-based logic
|
|
961
1010
|
(TIMER for TM, COUNTER for CT, BYTE for others).
|
|
962
1011
|
|
|
@@ -978,7 +1027,7 @@ class Client(ClientMixin):
|
|
|
978
1027
|
else:
|
|
979
1028
|
s7_word_len = S7WordLen.BYTE
|
|
980
1029
|
|
|
981
|
-
max_chunk = self.
|
|
1030
|
+
max_chunk = self._read_chunk_count(s7_word_len)
|
|
982
1031
|
if size <= max_chunk:
|
|
983
1032
|
# Single request - use reconnect-aware send/receive
|
|
984
1033
|
def build_request() -> bytes:
|
|
@@ -997,7 +1046,7 @@ class Client(ClientMixin):
|
|
|
997
1046
|
remaining = size
|
|
998
1047
|
while remaining > 0:
|
|
999
1048
|
chunk_size = min(remaining, max_chunk)
|
|
1000
|
-
chunk_offset = offset
|
|
1049
|
+
chunk_offset = offset * self._element_address_step(s7_word_len)
|
|
1001
1050
|
|
|
1002
1051
|
def build_chunk_request(o: int = chunk_offset, cs: int = chunk_size) -> bytes:
|
|
1003
1052
|
return self.protocol.build_read_request(
|
|
@@ -1045,7 +1094,7 @@ class Client(ClientMixin):
|
|
|
1045
1094
|
else:
|
|
1046
1095
|
s7_word_len = S7WordLen.BYTE
|
|
1047
1096
|
|
|
1048
|
-
max_chunk = self.
|
|
1097
|
+
max_chunk = self._write_chunk_bytes(s7_word_len, len(data))
|
|
1049
1098
|
if len(data) <= max_chunk:
|
|
1050
1099
|
# Single request
|
|
1051
1100
|
def build_request() -> bytes:
|
|
@@ -1064,7 +1113,7 @@ class Client(ClientMixin):
|
|
|
1064
1113
|
while remaining > 0:
|
|
1065
1114
|
chunk_size = min(remaining, max_chunk)
|
|
1066
1115
|
chunk_data = data[offset : offset + chunk_size]
|
|
1067
|
-
chunk_offset = offset
|
|
1116
|
+
chunk_offset = offset // S7DataTypes.get_size_bytes(s7_word_len) * self._element_address_step(s7_word_len)
|
|
1068
1117
|
|
|
1069
1118
|
def build_chunk_request(o: int = chunk_offset, cd: bytes = bytes(chunk_data)) -> bytes:
|
|
1070
1119
|
return self.protocol.build_write_request(
|
|
@@ -1193,7 +1242,7 @@ class Client(ClientMixin):
|
|
|
1193
1242
|
|
|
1194
1243
|
# Send all requests back-to-back
|
|
1195
1244
|
for _, pdu in requests:
|
|
1196
|
-
|
|
1245
|
+
self._send_data(conn, pdu)
|
|
1197
1246
|
|
|
1198
1247
|
# Receive responses, matching by sequence number
|
|
1199
1248
|
results: dict[int, dict[str, Any]] = {}
|
|
@@ -1354,26 +1403,33 @@ class Client(ClientMixin):
|
|
|
1354
1403
|
raise ValueError(f"Too many items: {len(items)} exceeds MAX_VARS ({self.MAX_VARS})")
|
|
1355
1404
|
|
|
1356
1405
|
# Handle S7DataItem list (ctypes)
|
|
1357
|
-
if
|
|
1358
|
-
|
|
1359
|
-
|
|
1406
|
+
if isinstance(items[0], S7DataItem):
|
|
1407
|
+
for s7_item in items:
|
|
1408
|
+
if not isinstance(s7_item, S7DataItem):
|
|
1409
|
+
raise TypeError("items must contain either only S7DataItem objects or only dictionaries")
|
|
1360
1410
|
area = Area(s7_item.Area)
|
|
1361
1411
|
db_number = s7_item.DBNumber
|
|
1362
1412
|
start = s7_item.Start
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1413
|
+
word_len = WordLen(s7_item.WordLen)
|
|
1414
|
+
size = S7DataTypes.get_size_bytes(S7WordLen(word_len), s7_item.Amount)
|
|
1415
|
+
if s7_item.Amount < 0:
|
|
1416
|
+
raise ValueError("Item amount must be non-negative")
|
|
1417
|
+
if size and not s7_item.pData:
|
|
1418
|
+
raise ValueError("Write item requires a data pointer")
|
|
1419
|
+
|
|
1420
|
+
# Extract all elements from pData, retaining the request datatype.
|
|
1366
1421
|
data = bytearray(size)
|
|
1367
1422
|
if s7_item.pData:
|
|
1368
1423
|
for i in range(size):
|
|
1369
1424
|
data[i] = s7_item.pData[i]
|
|
1370
1425
|
|
|
1371
|
-
self.write_area(area, db_number, start, data)
|
|
1426
|
+
self.write_area(area, db_number, start, data, word_len)
|
|
1372
1427
|
return 0
|
|
1373
1428
|
|
|
1374
1429
|
# Handle dict list
|
|
1375
|
-
|
|
1376
|
-
|
|
1430
|
+
for dict_item in items:
|
|
1431
|
+
if not isinstance(dict_item, dict):
|
|
1432
|
+
raise TypeError("items must contain either only S7DataItem objects or only dictionaries")
|
|
1377
1433
|
area = dict_item["area"]
|
|
1378
1434
|
db_number = dict_item.get("db_number", 0)
|
|
1379
1435
|
start = dict_item["start"]
|
|
@@ -1466,7 +1522,7 @@ class Client(ClientMixin):
|
|
|
1466
1522
|
break
|
|
1467
1523
|
|
|
1468
1524
|
followup = self.protocol.build_userdata_followup_request(group, subfunction, sequence_number)
|
|
1469
|
-
|
|
1525
|
+
self._send_data(conn, followup)
|
|
1470
1526
|
|
|
1471
1527
|
response_data = conn.receive_data()
|
|
1472
1528
|
response = self.protocol.parse_response(response_data)
|
|
@@ -1644,7 +1700,7 @@ class Client(ClientMixin):
|
|
|
1644
1700
|
len(data_section), # Data length
|
|
1645
1701
|
)
|
|
1646
1702
|
|
|
1647
|
-
|
|
1703
|
+
self._send_data(conn, header + param_data + data_section)
|
|
1648
1704
|
|
|
1649
1705
|
response_data = conn.receive_data()
|
|
1650
1706
|
self.protocol.parse_response(response_data)
|
|
@@ -1662,7 +1718,7 @@ class Client(ClientMixin):
|
|
|
1662
1718
|
0x0000, # Data length
|
|
1663
1719
|
)
|
|
1664
1720
|
|
|
1665
|
-
|
|
1721
|
+
self._send_data(conn, header + param_data)
|
|
1666
1722
|
|
|
1667
1723
|
response_data = conn.receive_data()
|
|
1668
1724
|
self.protocol.parse_response(response_data)
|
|
@@ -1928,6 +1984,151 @@ class Client(ClientMixin):
|
|
|
1928
1984
|
raise S7ConnectionError("Not connected to PLC")
|
|
1929
1985
|
return parse_protection_szl(self.read_szl(0x0232, 0))
|
|
1930
1986
|
|
|
1987
|
+
# ---------------------------------------------------------------
|
|
1988
|
+
# Force I/O
|
|
1989
|
+
# ---------------------------------------------------------------
|
|
1990
|
+
|
|
1991
|
+
_FORCE_AREAS: frozenset[int] = frozenset({Area.PE, Area.PA})
|
|
1992
|
+
|
|
1993
|
+
def force_bit(self, area: Area, byte_offset: int, bit: int, value: bool) -> None:
|
|
1994
|
+
"""Force a single I/O bit in the process image.
|
|
1995
|
+
|
|
1996
|
+
Performs a read-modify-write on the specified byte to set or clear
|
|
1997
|
+
the target bit without affecting neighbouring bits. This writes
|
|
1998
|
+
directly to the process image, so the value may be overwritten by
|
|
1999
|
+
the PLC's scan cycle. It is equivalent to the approach used by
|
|
2000
|
+
rs-snap7.
|
|
2001
|
+
|
|
2002
|
+
Only the I/O areas (PE = inputs, PA = outputs) are supported.
|
|
2003
|
+
|
|
2004
|
+
Args:
|
|
2005
|
+
area: Memory area (:attr:`~snap7.type.Area.PE` or
|
|
2006
|
+
:attr:`~snap7.type.Area.PA`).
|
|
2007
|
+
byte_offset: Byte offset within the area.
|
|
2008
|
+
bit: Bit number (0-7) within the byte.
|
|
2009
|
+
value: Desired bit value.
|
|
2010
|
+
|
|
2011
|
+
Raises:
|
|
2012
|
+
ValueError: If *area* is not PE or PA, or *bit* is out of range.
|
|
2013
|
+
"""
|
|
2014
|
+
if area not in self._FORCE_AREAS:
|
|
2015
|
+
raise ValueError(f"Force is only supported for PE (inputs) and PA (outputs), got {area!r}")
|
|
2016
|
+
if not 0 <= bit <= 7:
|
|
2017
|
+
raise ValueError(f"Bit must be 0-7, got {bit}")
|
|
2018
|
+
|
|
2019
|
+
current = self.read_area(area, 0, byte_offset, 1)
|
|
2020
|
+
if value:
|
|
2021
|
+
current[0] |= 1 << bit
|
|
2022
|
+
else:
|
|
2023
|
+
current[0] &= ~(1 << bit)
|
|
2024
|
+
self.write_area(area, 0, byte_offset, current)
|
|
2025
|
+
logger.info(f"Forced {area.name} byte {byte_offset} bit {bit} = {value}")
|
|
2026
|
+
|
|
2027
|
+
def cancel_force(self, area: Area, byte_offset: int, bit: int) -> None:
|
|
2028
|
+
"""Cancel a forced I/O bit by clearing it in the process image.
|
|
2029
|
+
|
|
2030
|
+
Clears the specified bit. On a real PLC the scan cycle will
|
|
2031
|
+
restore the natural I/O state once the force is released; on the
|
|
2032
|
+
emulated server the bit stays at 0 until explicitly written.
|
|
2033
|
+
|
|
2034
|
+
Args:
|
|
2035
|
+
area: Memory area (:attr:`~snap7.type.Area.PE` or
|
|
2036
|
+
:attr:`~snap7.type.Area.PA`).
|
|
2037
|
+
byte_offset: Byte offset within the area.
|
|
2038
|
+
bit: Bit number (0-7) within the byte.
|
|
2039
|
+
|
|
2040
|
+
Raises:
|
|
2041
|
+
ValueError: If *area* is not PE or PA, or *bit* is out of range.
|
|
2042
|
+
"""
|
|
2043
|
+
if area not in self._FORCE_AREAS:
|
|
2044
|
+
raise ValueError(f"Cancel force is only supported for PE (inputs) and PA (outputs), got {area!r}")
|
|
2045
|
+
if not 0 <= bit <= 7:
|
|
2046
|
+
raise ValueError(f"Bit must be 0-7, got {bit}")
|
|
2047
|
+
|
|
2048
|
+
current = self.read_area(area, 0, byte_offset, 1)
|
|
2049
|
+
current[0] &= ~(1 << bit)
|
|
2050
|
+
self.write_area(area, 0, byte_offset, current)
|
|
2051
|
+
logger.info(f"Cancelled force on {area.name} byte {byte_offset} bit {bit}")
|
|
2052
|
+
|
|
2053
|
+
def read_force_table(self) -> list[ForceEntry]:
|
|
2054
|
+
"""Read the PLC force table via SZL 0x0025.
|
|
2055
|
+
|
|
2056
|
+
On a real PLC this returns bits that have been forced through the
|
|
2057
|
+
CPU's built-in force mechanism (e.g. via TIA Portal). Bits set
|
|
2058
|
+
through :meth:`force_bit` (process-image writes) do **not** appear
|
|
2059
|
+
here because they bypass the CPU force table.
|
|
2060
|
+
|
|
2061
|
+
Returns:
|
|
2062
|
+
List of :class:`~snap7.type.ForceEntry` instances describing
|
|
2063
|
+
each forced bit. Returns an empty list when no bits are
|
|
2064
|
+
currently forced or when the PLC does not support SZL 0x0025.
|
|
2065
|
+
"""
|
|
2066
|
+
if not self.get_connected():
|
|
2067
|
+
raise S7ConnectionError("Not connected to PLC")
|
|
2068
|
+
|
|
2069
|
+
try:
|
|
2070
|
+
szl = self.read_szl(0x0025, 0x0000)
|
|
2071
|
+
except (S7ProtocolError, RuntimeError):
|
|
2072
|
+
# PLC does not support force table SZL or returned an error
|
|
2073
|
+
logger.debug("SZL 0x0025 not available; returning empty force table")
|
|
2074
|
+
return []
|
|
2075
|
+
|
|
2076
|
+
raw = bytes(szl.Data[: szl.Header.LengthDR])
|
|
2077
|
+
return _parse_force_szl(raw)
|
|
2078
|
+
|
|
2079
|
+
def set_session_password(self, password: str) -> int:
|
|
2080
|
+
"""Set the session password to unlock a password-protected PLC.
|
|
2081
|
+
|
|
2082
|
+
Sends an S7 USERDATA request (function group 5, subfunction 1)
|
|
2083
|
+
with the encoded password. After a successful call the PLC grants
|
|
2084
|
+
higher-privilege access for the duration of this session.
|
|
2085
|
+
|
|
2086
|
+
Args:
|
|
2087
|
+
password: Plaintext password (max 8 ASCII characters).
|
|
2088
|
+
|
|
2089
|
+
Returns:
|
|
2090
|
+
0 on success.
|
|
2091
|
+
|
|
2092
|
+
Raises:
|
|
2093
|
+
~snap7.error.S7ConnectionError: If not connected.
|
|
2094
|
+
~snap7.error.S7ProtocolError: If the PLC rejects the password.
|
|
2095
|
+
"""
|
|
2096
|
+
if not self.get_connected():
|
|
2097
|
+
raise S7ConnectionError("Not connected to PLC")
|
|
2098
|
+
|
|
2099
|
+
encoded = self.protocol.encode_password(password)
|
|
2100
|
+
|
|
2101
|
+
def build_request() -> bytes:
|
|
2102
|
+
return self.protocol.build_set_session_password_request(encoded)
|
|
2103
|
+
|
|
2104
|
+
response = self._send_receive_with_reconnect(build_request)
|
|
2105
|
+
self.protocol.check_userdata_response(response)
|
|
2106
|
+
logger.info("Session password set successfully")
|
|
2107
|
+
return 0
|
|
2108
|
+
|
|
2109
|
+
def clear_session_password(self) -> int:
|
|
2110
|
+
"""Clear the session password, returning to the default protection level.
|
|
2111
|
+
|
|
2112
|
+
Sends an S7 USERDATA request (function group 5, subfunction 2).
|
|
2113
|
+
|
|
2114
|
+
Returns:
|
|
2115
|
+
0 on success.
|
|
2116
|
+
|
|
2117
|
+
Raises:
|
|
2118
|
+
~snap7.error.S7ConnectionError: If not connected.
|
|
2119
|
+
~snap7.error.S7ProtocolError: If the PLC rejects the request.
|
|
2120
|
+
"""
|
|
2121
|
+
if not self.get_connected():
|
|
2122
|
+
raise S7ConnectionError("Not connected to PLC")
|
|
2123
|
+
|
|
2124
|
+
def build_request() -> bytes:
|
|
2125
|
+
return self.protocol.build_clear_session_password_request()
|
|
2126
|
+
|
|
2127
|
+
response = self._send_receive_with_reconnect(build_request)
|
|
2128
|
+
self.protocol.check_userdata_response(response)
|
|
2129
|
+
logger.info("Session password cleared successfully")
|
|
2130
|
+
return 0
|
|
2131
|
+
|
|
1931
2132
|
def read_szl(self, ssl_id: int, index: int = 0) -> S7SZL:
|
|
1932
2133
|
"""
|
|
1933
2134
|
Read SZL (System Status List).
|
|
@@ -1975,7 +2176,7 @@ class Client(ClientMixin):
|
|
|
1975
2176
|
break
|
|
1976
2177
|
|
|
1977
2178
|
followup = self.protocol.build_userdata_followup_request(group, subfunction, sequence_number)
|
|
1978
|
-
|
|
2179
|
+
self._send_data(conn, followup)
|
|
1979
2180
|
|
|
1980
2181
|
response_data = conn.receive_data()
|
|
1981
2182
|
response = self.protocol.parse_response(response_data)
|
|
@@ -2095,7 +2296,7 @@ class Client(ClientMixin):
|
|
|
2095
2296
|
"""
|
|
2096
2297
|
conn = self._get_connection()
|
|
2097
2298
|
|
|
2098
|
-
|
|
2299
|
+
self._send_data(conn, bytes(data))
|
|
2099
2300
|
response = conn.receive_data()
|
|
2100
2301
|
return bytearray(response)
|
|
2101
2302
|
|
snap7/client_base.py
CHANGED
|
@@ -9,7 +9,7 @@ import logging
|
|
|
9
9
|
import struct
|
|
10
10
|
from typing import Optional
|
|
11
11
|
|
|
12
|
-
from .datatypes import S7Area
|
|
12
|
+
from .datatypes import S7Area, S7DataTypes, S7WordLen
|
|
13
13
|
from .error import S7ProtocolError
|
|
14
14
|
|
|
15
15
|
from .type import (
|
|
@@ -147,29 +147,6 @@ class ClientMixin:
|
|
|
147
147
|
self.connection_type = connection_type
|
|
148
148
|
logger.debug(f"Connection type set to {connection_type}")
|
|
149
149
|
|
|
150
|
-
def set_session_password(self, password: str) -> int:
|
|
151
|
-
"""Set session password.
|
|
152
|
-
|
|
153
|
-
Args:
|
|
154
|
-
password: Session password
|
|
155
|
-
|
|
156
|
-
Returns:
|
|
157
|
-
0 on success
|
|
158
|
-
"""
|
|
159
|
-
self.session_password = password
|
|
160
|
-
logger.debug("Session password set")
|
|
161
|
-
return 0
|
|
162
|
-
|
|
163
|
-
def clear_session_password(self) -> int:
|
|
164
|
-
"""Clear session password.
|
|
165
|
-
|
|
166
|
-
Returns:
|
|
167
|
-
0 on success
|
|
168
|
-
"""
|
|
169
|
-
self.session_password = None
|
|
170
|
-
logger.debug("Session password cleared")
|
|
171
|
-
return 0
|
|
172
|
-
|
|
173
150
|
def get_param(self, param: Parameter) -> int:
|
|
174
151
|
"""Get client parameter.
|
|
175
152
|
|
|
@@ -241,6 +218,32 @@ class ClientMixin:
|
|
|
241
218
|
"""
|
|
242
219
|
return max(1, self.pdu_length - 35)
|
|
243
220
|
|
|
221
|
+
def _read_chunk_count(self, word_len: S7WordLen) -> int:
|
|
222
|
+
"""Convert the response byte budget to whole requested elements."""
|
|
223
|
+
width = S7DataTypes.get_size_bytes(word_len)
|
|
224
|
+
count = min(self.pdu_length - 18, 8191) // width
|
|
225
|
+
if self.pdu_length < 24 or count < 1:
|
|
226
|
+
raise S7ProtocolError("Negotiated PDU cannot hold one read element")
|
|
227
|
+
# Represent each BIT as one byte in the public API, one bit per request.
|
|
228
|
+
return 1 if word_len == S7WordLen.BIT else count
|
|
229
|
+
|
|
230
|
+
def _write_chunk_bytes(self, word_len: S7WordLen, size: int) -> int:
|
|
231
|
+
"""Keep write chunks aligned to elements and within the PDU budget."""
|
|
232
|
+
width = S7DataTypes.get_size_bytes(word_len)
|
|
233
|
+
if size % width:
|
|
234
|
+
raise ValueError("Write data length must be a multiple of the element width")
|
|
235
|
+
chunk = min(self.pdu_length - 35, 8191) // width * width
|
|
236
|
+
if chunk < width:
|
|
237
|
+
raise S7ProtocolError("Negotiated PDU cannot hold one write element")
|
|
238
|
+
return 1 if word_len == S7WordLen.BIT else chunk
|
|
239
|
+
|
|
240
|
+
@staticmethod
|
|
241
|
+
def _element_address_step(word_len: S7WordLen) -> int:
|
|
242
|
+
"""BIT and timer/counter starts use indices; other starts use bytes."""
|
|
243
|
+
if word_len in (S7WordLen.BIT, S7WordLen.TIMER, S7WordLen.COUNTER):
|
|
244
|
+
return 1
|
|
245
|
+
return S7DataTypes.get_size_bytes(word_len)
|
|
246
|
+
|
|
244
247
|
def _map_area(self, area: Area) -> S7Area:
|
|
245
248
|
"""Map library area enum to native S7 area."""
|
|
246
249
|
area_mapping = {
|
snap7/connection.py
CHANGED
|
@@ -10,7 +10,7 @@ import socket
|
|
|
10
10
|
import struct
|
|
11
11
|
import logging
|
|
12
12
|
from enum import IntEnum
|
|
13
|
-
from typing import Optional, Type
|
|
13
|
+
from typing import Optional, Type, Union
|
|
14
14
|
from types import TracebackType
|
|
15
15
|
|
|
16
16
|
from .error import S7ConnectionError, S7TimeoutError
|
|
@@ -71,7 +71,7 @@ class ISOTCPConnection:
|
|
|
71
71
|
host: str,
|
|
72
72
|
port: int = 102,
|
|
73
73
|
local_tsap: int = 0x0100,
|
|
74
|
-
remote_tsap: int = 0x0102,
|
|
74
|
+
remote_tsap: Union[int, bytes] = 0x0102,
|
|
75
75
|
tpdu_size: TPDUSize = TPDUSize.S_1024,
|
|
76
76
|
):
|
|
77
77
|
"""
|
|
@@ -81,7 +81,8 @@ class ISOTCPConnection:
|
|
|
81
81
|
host: Target PLC IP address
|
|
82
82
|
port: TCP port (default 102 for S7)
|
|
83
83
|
local_tsap: Local Transport Service Access Point
|
|
84
|
-
remote_tsap: Remote Transport Service Access Point
|
|
84
|
+
remote_tsap: Remote Transport Service Access Point (int for 2-byte TSAP,
|
|
85
|
+
bytes for variable-length TSAP like b"SIMATIC-ROOT-HMI")
|
|
85
86
|
tpdu_size: TPDU size to request during COTP negotiation
|
|
86
87
|
"""
|
|
87
88
|
self.host = host
|
|
@@ -183,7 +184,7 @@ class ISOTCPConnection:
|
|
|
183
184
|
# Send over TCP
|
|
184
185
|
try:
|
|
185
186
|
self.socket.sendall(tpkt_frame)
|
|
186
|
-
logger.debug(f"Sent {len(tpkt_frame)} bytes")
|
|
187
|
+
logger.debug(f"Sent {len(tpkt_frame)} bytes: {tpkt_frame.hex(' ')}")
|
|
187
188
|
except socket.error as e:
|
|
188
189
|
self.connected = False
|
|
189
190
|
raise S7ConnectionError(f"Send failed: {e}")
|
|
@@ -210,12 +211,13 @@ class ISOTCPConnection:
|
|
|
210
211
|
|
|
211
212
|
# Receive remaining data
|
|
212
213
|
remaining = length - 4
|
|
213
|
-
if
|
|
214
|
+
if length < 7:
|
|
214
215
|
raise S7ConnectionError("Invalid TPKT length")
|
|
215
216
|
|
|
216
217
|
payload = self._recv_exact(remaining)
|
|
217
218
|
|
|
218
219
|
# Parse COTP header and extract data
|
|
220
|
+
logger.debug(f"Received TPKT: version={version} length={length} payload ({len(payload)} bytes): {payload.hex(' ')}")
|
|
219
221
|
return self._parse_cotp_data(payload)
|
|
220
222
|
|
|
221
223
|
except socket.timeout:
|
|
@@ -284,6 +286,8 @@ class ISOTCPConnection:
|
|
|
284
286
|
- Length (2 bytes): Total frame length including header
|
|
285
287
|
"""
|
|
286
288
|
length = len(payload) + 4
|
|
289
|
+
if not 7 <= length <= 65535:
|
|
290
|
+
raise S7ConnectionError("Invalid TPKT length: expected 7..65535 bytes")
|
|
287
291
|
return struct.pack(">BBH", 3, 0, length) + payload
|
|
288
292
|
|
|
289
293
|
def _build_cotp_cr(self) -> bytes:
|
|
@@ -309,11 +313,13 @@ class ISOTCPConnection:
|
|
|
309
313
|
)
|
|
310
314
|
|
|
311
315
|
# Add TSAP parameters
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
316
|
+
# Calling TSAP (local) - always 2 bytes
|
|
317
|
+
calling_tsap = struct.pack(">BBH", self.COTP_PARAM_CALLING_TSAP, 2, self.local_tsap)
|
|
318
|
+
# Called TSAP (remote) - can be 2-byte int or variable-length bytes (e.g. "SIMATIC-ROOT-HMI")
|
|
319
|
+
if isinstance(self.remote_tsap, bytes):
|
|
320
|
+
called_tsap = struct.pack(">BB", self.COTP_PARAM_CALLED_TSAP, len(self.remote_tsap)) + self.remote_tsap
|
|
321
|
+
else:
|
|
322
|
+
called_tsap = struct.pack(">BBH", self.COTP_PARAM_CALLED_TSAP, 2, self.remote_tsap)
|
|
317
323
|
# PDU Size parameter (ISO 8073 code, e.g. 0x0A = 1024 bytes)
|
|
318
324
|
pdu_size_param = struct.pack(">BBB", self.COTP_PARAM_PDU_SIZE, 1, self.tpdu_size)
|
|
319
325
|
|
|
@@ -412,6 +418,11 @@ class ISOTCPConnection:
|
|
|
412
418
|
|
|
413
419
|
pdu_len, pdu_type, eot_num = struct.unpack(">BBB", cotp_pdu[:3])
|
|
414
420
|
|
|
421
|
+
if pdu_len != 2:
|
|
422
|
+
raise S7ConnectionError("Invalid COTP DT header length")
|
|
423
|
+
if eot_num & 0x7F:
|
|
424
|
+
raise S7ConnectionError("Invalid Class 0 COTP TPDU number")
|
|
425
|
+
|
|
415
426
|
if pdu_type != self.COTP_DT:
|
|
416
427
|
raise S7ConnectionError(f"Expected COTP DT, got {pdu_type:#02x}")
|
|
417
428
|
|