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/s7protocol.py
CHANGED
|
@@ -65,6 +65,10 @@ class S7UserDataSubfunction(IntEnum):
|
|
|
65
65
|
READ_SZL = 0x01 # SFun_ReadSZL
|
|
66
66
|
SYSTEM_STATE = 0x02 # System state request
|
|
67
67
|
|
|
68
|
+
# Security subfunctions
|
|
69
|
+
SET_SESSION_PASSWORD = 0x01
|
|
70
|
+
CLEAR_SESSION_PASSWORD = 0x02
|
|
71
|
+
|
|
68
72
|
# Clock subfunctions
|
|
69
73
|
GET_CLOCK = 0x01
|
|
70
74
|
SET_CLOCK = 0x02
|
|
@@ -244,6 +248,7 @@ class S7Protocol:
|
|
|
244
248
|
raw = response.get("raw_data", b"")
|
|
245
249
|
if not raw:
|
|
246
250
|
raise S7ProtocolError("No raw data in multi-read response")
|
|
251
|
+
self._validate_area_response(response, S7Function.READ_AREA, block_count)
|
|
247
252
|
|
|
248
253
|
results: List[bytearray] = []
|
|
249
254
|
offset = 0
|
|
@@ -295,6 +300,8 @@ class S7Protocol:
|
|
|
295
300
|
"""
|
|
296
301
|
# Calculate count from data length
|
|
297
302
|
item_size = S7DataTypes.get_size_bytes(word_len, 1)
|
|
303
|
+
if len(data) % item_size:
|
|
304
|
+
raise ValueError("Write data length must be a multiple of the element width")
|
|
298
305
|
count = len(data) // item_size
|
|
299
306
|
|
|
300
307
|
# Parameter length: function + item count + address spec
|
|
@@ -330,31 +337,33 @@ class S7Protocol:
|
|
|
330
337
|
# Data section uses different transport size codes than address specification:
|
|
331
338
|
# - 0x03 = BIT
|
|
332
339
|
# - 0x04 = BYTE/WORD/DWORD (byte-oriented data)
|
|
333
|
-
# - 0x05 = INT
|
|
334
|
-
# - 0x06 = DINT
|
|
340
|
+
# - 0x05 = INT/DINT
|
|
335
341
|
# - 0x07 = REAL
|
|
336
|
-
# - 0x09 = OCTET STRING
|
|
342
|
+
# - 0x09 = OCTET STRING (CHAR, COUNTER, TIMER)
|
|
337
343
|
transport_size_map = {
|
|
338
344
|
S7WordLen.BIT: 0x03,
|
|
339
345
|
S7WordLen.BYTE: 0x04,
|
|
340
|
-
S7WordLen.CHAR:
|
|
346
|
+
S7WordLen.CHAR: 0x09,
|
|
341
347
|
S7WordLen.WORD: 0x04,
|
|
342
348
|
S7WordLen.INT: 0x05,
|
|
343
349
|
S7WordLen.DWORD: 0x04,
|
|
344
|
-
S7WordLen.DINT:
|
|
350
|
+
S7WordLen.DINT: 0x05,
|
|
345
351
|
S7WordLen.REAL: 0x07,
|
|
346
|
-
S7WordLen.COUNTER:
|
|
347
|
-
S7WordLen.TIMER:
|
|
352
|
+
S7WordLen.COUNTER: 0x09,
|
|
353
|
+
S7WordLen.TIMER: 0x09,
|
|
348
354
|
}
|
|
349
355
|
transport_size = transport_size_map.get(word_len, 0x04)
|
|
350
356
|
|
|
357
|
+
# BIT values occupy one byte each; BYTE/WORD/INT lengths are in bits.
|
|
358
|
+
data_length = len(data) if transport_size in (0x03, 0x07, 0x09) else len(data) * 8
|
|
359
|
+
|
|
351
360
|
# Data section
|
|
352
361
|
data_section = (
|
|
353
362
|
struct.pack(
|
|
354
363
|
">BBH",
|
|
355
364
|
0x00, # Reserved/Error
|
|
356
365
|
transport_size, # Transport size (proper S7 data section format)
|
|
357
|
-
|
|
366
|
+
data_length,
|
|
358
367
|
)
|
|
359
368
|
+ data
|
|
360
369
|
)
|
|
@@ -1337,6 +1346,143 @@ class S7Protocol:
|
|
|
1337
1346
|
except ValueError:
|
|
1338
1347
|
return dt_class.now().replace(microsecond=0)
|
|
1339
1348
|
|
|
1349
|
+
# ========================================================================
|
|
1350
|
+
# Session Password PDU Builders (Security / Function Group 5)
|
|
1351
|
+
# ========================================================================
|
|
1352
|
+
|
|
1353
|
+
@staticmethod
|
|
1354
|
+
def encode_password(password: str) -> bytes:
|
|
1355
|
+
"""Encode an S7 session password into the 8-byte wire format.
|
|
1356
|
+
|
|
1357
|
+
The encoding pads or truncates the password to 8 characters, XORs each
|
|
1358
|
+
byte with ``0x55``, then rotates each byte left by 3 bits.
|
|
1359
|
+
|
|
1360
|
+
Args:
|
|
1361
|
+
password: Plaintext password (max 8 characters).
|
|
1362
|
+
|
|
1363
|
+
Returns:
|
|
1364
|
+
8-byte encoded password block.
|
|
1365
|
+
"""
|
|
1366
|
+
# Pad/truncate to exactly 8 bytes
|
|
1367
|
+
raw = password.encode("ascii")[:8].ljust(8, b"\x00")
|
|
1368
|
+
encoded = bytearray(8)
|
|
1369
|
+
for i in range(8):
|
|
1370
|
+
xored = raw[i] ^ 0x55
|
|
1371
|
+
encoded[i] = ((xored << 3) | (xored >> 5)) & 0xFF
|
|
1372
|
+
return bytes(encoded)
|
|
1373
|
+
|
|
1374
|
+
def build_set_session_password_request(self, encoded_password: bytes) -> bytes:
|
|
1375
|
+
"""Build USERDATA request to set the session password.
|
|
1376
|
+
|
|
1377
|
+
Uses function group 5 (Security), subfunction 1.
|
|
1378
|
+
|
|
1379
|
+
Args:
|
|
1380
|
+
encoded_password: 8-byte encoded password from :meth:`encode_password`.
|
|
1381
|
+
|
|
1382
|
+
Returns:
|
|
1383
|
+
Complete S7 PDU.
|
|
1384
|
+
"""
|
|
1385
|
+
# Parameter section for USERDATA security request
|
|
1386
|
+
param_data = struct.pack(
|
|
1387
|
+
">BBBBBBBB",
|
|
1388
|
+
0x00, # Reserved
|
|
1389
|
+
0x01, # Parameter count
|
|
1390
|
+
0x12, # Type/length header
|
|
1391
|
+
0x04, # Length of following data
|
|
1392
|
+
0x11, # Method (0x11 = request)
|
|
1393
|
+
0x45, # Type (4=request) | Group (5=grSecurity)
|
|
1394
|
+
S7UserDataSubfunction.SET_SESSION_PASSWORD, # Subfunction (0x01)
|
|
1395
|
+
0x00, # DataRef
|
|
1396
|
+
)
|
|
1397
|
+
|
|
1398
|
+
# Data section: encoded password
|
|
1399
|
+
data_section = (
|
|
1400
|
+
struct.pack(
|
|
1401
|
+
">BBH",
|
|
1402
|
+
0x0A, # Return value (request)
|
|
1403
|
+
0x00, # Transport size
|
|
1404
|
+
len(encoded_password), # Length
|
|
1405
|
+
)
|
|
1406
|
+
+ encoded_password
|
|
1407
|
+
)
|
|
1408
|
+
|
|
1409
|
+
header = struct.pack(
|
|
1410
|
+
">BBHHHH",
|
|
1411
|
+
0x32, # Protocol ID
|
|
1412
|
+
S7PDUType.USERDATA, # PDU type (0x07)
|
|
1413
|
+
0x0000, # Reserved
|
|
1414
|
+
self._next_sequence(), # Sequence
|
|
1415
|
+
len(param_data), # Parameter length
|
|
1416
|
+
len(data_section), # Data length
|
|
1417
|
+
)
|
|
1418
|
+
|
|
1419
|
+
return header + param_data + data_section
|
|
1420
|
+
|
|
1421
|
+
def build_clear_session_password_request(self) -> bytes:
|
|
1422
|
+
"""Build USERDATA request to clear the session password.
|
|
1423
|
+
|
|
1424
|
+
Uses function group 5 (Security), subfunction 2.
|
|
1425
|
+
|
|
1426
|
+
Returns:
|
|
1427
|
+
Complete S7 PDU.
|
|
1428
|
+
"""
|
|
1429
|
+
param_data = struct.pack(
|
|
1430
|
+
">BBBBBBBB",
|
|
1431
|
+
0x00, # Reserved
|
|
1432
|
+
0x01, # Parameter count
|
|
1433
|
+
0x12, # Type/length header
|
|
1434
|
+
0x04, # Length of following data
|
|
1435
|
+
0x11, # Method (0x11 = request)
|
|
1436
|
+
0x45, # Type (4=request) | Group (5=grSecurity)
|
|
1437
|
+
S7UserDataSubfunction.CLEAR_SESSION_PASSWORD, # Subfunction (0x02)
|
|
1438
|
+
0x00, # DataRef
|
|
1439
|
+
)
|
|
1440
|
+
|
|
1441
|
+
data_section = struct.pack(
|
|
1442
|
+
">BBH",
|
|
1443
|
+
0x0A, # Return value (request)
|
|
1444
|
+
0x00, # Transport size
|
|
1445
|
+
0x0000, # Length (0 bytes)
|
|
1446
|
+
)
|
|
1447
|
+
|
|
1448
|
+
header = struct.pack(
|
|
1449
|
+
">BBHHHH",
|
|
1450
|
+
0x32, # Protocol ID
|
|
1451
|
+
S7PDUType.USERDATA, # PDU type (0x07)
|
|
1452
|
+
0x0000, # Reserved
|
|
1453
|
+
self._next_sequence(), # Sequence
|
|
1454
|
+
len(param_data), # Parameter length
|
|
1455
|
+
len(data_section), # Data length
|
|
1456
|
+
)
|
|
1457
|
+
|
|
1458
|
+
return header + param_data + data_section
|
|
1459
|
+
|
|
1460
|
+
def check_userdata_response(self, response: Dict[str, Any]) -> None:
|
|
1461
|
+
"""Check a USERDATA response for errors.
|
|
1462
|
+
|
|
1463
|
+
Verifies both the parameter-level error code and the data section
|
|
1464
|
+
return code.
|
|
1465
|
+
|
|
1466
|
+
Args:
|
|
1467
|
+
response: Parsed S7 response from :meth:`parse_response`.
|
|
1468
|
+
|
|
1469
|
+
Raises:
|
|
1470
|
+
~snap7.error.S7ProtocolError: If the response indicates an error.
|
|
1471
|
+
"""
|
|
1472
|
+
params = response.get("parameters", {})
|
|
1473
|
+
if isinstance(params, dict):
|
|
1474
|
+
param_error = params.get("error_code", 0)
|
|
1475
|
+
if param_error != 0:
|
|
1476
|
+
error_msg = get_protocol_error_message(param_error)
|
|
1477
|
+
raise S7ProtocolError(f"USERDATA request failed: {error_msg} (0x{param_error:04x})")
|
|
1478
|
+
|
|
1479
|
+
data_info = response.get("data", {})
|
|
1480
|
+
if isinstance(data_info, dict):
|
|
1481
|
+
return_code = data_info.get("return_code", 0xFF)
|
|
1482
|
+
if return_code != 0xFF:
|
|
1483
|
+
desc = get_return_code_description(return_code)
|
|
1484
|
+
raise S7ProtocolError(f"USERDATA request failed: {desc} (0x{return_code:02x})")
|
|
1485
|
+
|
|
1340
1486
|
def build_cpu_state_request(self) -> bytes:
|
|
1341
1487
|
"""
|
|
1342
1488
|
Build CPU state request.
|
|
@@ -1446,7 +1592,15 @@ class S7Protocol:
|
|
|
1446
1592
|
raise S7ProtocolError("Data section extends beyond PDU")
|
|
1447
1593
|
|
|
1448
1594
|
data_section = pdu[offset : offset + data_len]
|
|
1449
|
-
response
|
|
1595
|
+
if (response.get("parameters") or {}).get("function_code") == S7Function.WRITE_AREA:
|
|
1596
|
+
# WRITE data is an array of acknowledgement codes, not a READ item header.
|
|
1597
|
+
response["data"] = {"return_code": data_section[0]}
|
|
1598
|
+
else:
|
|
1599
|
+
# Other functions (notably block upload) do not carry READ item headers.
|
|
1600
|
+
response["data"] = self._parse_data_section(
|
|
1601
|
+
data_section,
|
|
1602
|
+
validate_length=(response.get("parameters") or {}).get("function_code") == S7Function.READ_AREA,
|
|
1603
|
+
)
|
|
1450
1604
|
response["raw_data"] = data_section
|
|
1451
1605
|
|
|
1452
1606
|
return response
|
|
@@ -1541,7 +1695,7 @@ class S7Protocol:
|
|
|
1541
1695
|
"error_code": error_code,
|
|
1542
1696
|
}
|
|
1543
1697
|
|
|
1544
|
-
def _parse_data_section(self, data_section: bytes) -> Dict[str, Any]:
|
|
1698
|
+
def _parse_data_section(self, data_section: bytes, *, validate_length: bool = True) -> Dict[str, Any]:
|
|
1545
1699
|
"""Parse S7 data section."""
|
|
1546
1700
|
if len(data_section) == 1:
|
|
1547
1701
|
# Simple return code (for write responses)
|
|
@@ -1556,12 +1710,16 @@ class S7Protocol:
|
|
|
1556
1710
|
# Transport size 0x09 (octet string): byte length (USERDATA responses)
|
|
1557
1711
|
# Transport size 0x00: byte length (USERDATA requests)
|
|
1558
1712
|
# Transport size 0x04 (byte): bit length (READ_AREA responses)
|
|
1559
|
-
if transport_size in (0x00, 0x09):
|
|
1713
|
+
if transport_size in (0x00, 0x03, 0x06, 0x07, 0x09):
|
|
1560
1714
|
# USERDATA uses byte length directly
|
|
1561
|
-
|
|
1715
|
+
byte_length = data_length
|
|
1562
1716
|
else:
|
|
1563
1717
|
# READ_AREA responses use bit length
|
|
1564
|
-
|
|
1718
|
+
byte_length = data_length // 8
|
|
1719
|
+
|
|
1720
|
+
if validate_length and 4 + byte_length > len(data_section):
|
|
1721
|
+
raise S7ProtocolError("Read data item is truncated")
|
|
1722
|
+
actual_data = data_section[4 : 4 + byte_length]
|
|
1565
1723
|
|
|
1566
1724
|
return {"return_code": return_code, "transport_size": transport_size, "data_length": data_length, "data": actual_data}
|
|
1567
1725
|
else:
|
|
@@ -1590,10 +1748,31 @@ class S7Protocol:
|
|
|
1590
1748
|
raise S7ProtocolError(f"Read operation failed: {desc} (0x{return_code:02x})")
|
|
1591
1749
|
|
|
1592
1750
|
raw_data = data_info.get("data", b"")
|
|
1751
|
+
self._validate_area_response(response, S7Function.READ_AREA, 1)
|
|
1752
|
+
expected = S7DataTypes.get_size_bytes(word_len, count)
|
|
1753
|
+
if len(raw_data) != expected:
|
|
1754
|
+
raise S7ProtocolError(f"Read response size mismatch: expected {expected} bytes, received {len(raw_data)}")
|
|
1755
|
+
transport_size = data_info.get("transport_size")
|
|
1756
|
+
expected_length = count if word_len == S7WordLen.BIT else expected
|
|
1757
|
+
if transport_size in (0x04, 0x05):
|
|
1758
|
+
expected_length = expected * 8
|
|
1759
|
+
elif transport_size not in (0x03, 0x06, 0x07, 0x09):
|
|
1760
|
+
raise S7ProtocolError("Invalid read response transport size")
|
|
1761
|
+
if data_info.get("data_length") != expected_length:
|
|
1762
|
+
raise S7ProtocolError("Read response declared length does not match requested size")
|
|
1593
1763
|
|
|
1594
1764
|
# Return raw bytes directly - caller handles type conversion
|
|
1595
1765
|
return list(raw_data)
|
|
1596
1766
|
|
|
1767
|
+
@staticmethod
|
|
1768
|
+
def _validate_area_response(response: Dict[str, Any], function: S7Function, count: int) -> None:
|
|
1769
|
+
"""Require the response to acknowledge the requested operation and item count."""
|
|
1770
|
+
parameters = response.get("parameters") or {}
|
|
1771
|
+
if parameters.get("function_code") != function:
|
|
1772
|
+
raise S7ProtocolError("Unexpected S7 response function")
|
|
1773
|
+
if parameters.get("item_count") != count:
|
|
1774
|
+
raise S7ProtocolError("Unexpected S7 response item count")
|
|
1775
|
+
|
|
1597
1776
|
def check_write_response(self, response: Dict[str, Any]) -> None:
|
|
1598
1777
|
"""
|
|
1599
1778
|
Check write operation response for errors.
|
|
@@ -1611,15 +1790,20 @@ class S7Protocol:
|
|
|
1611
1790
|
error_msg = f"Write operation failed with S7 error code: {header_error:#06x}"
|
|
1612
1791
|
raise S7ProtocolError(error_msg)
|
|
1613
1792
|
|
|
1614
|
-
|
|
1793
|
+
self._validate_area_response(response, S7Function.WRITE_AREA, 1)
|
|
1794
|
+
if len(response.get("raw_data", b"")) != 1:
|
|
1795
|
+
raise S7ProtocolError("Write response must contain one item acknowledgement")
|
|
1796
|
+
|
|
1797
|
+
# For successful writes, check the data section return code.
|
|
1615
1798
|
if response.get("data"):
|
|
1616
1799
|
data_info = response["data"]
|
|
1617
|
-
return_code = data_info.get("return_code",
|
|
1800
|
+
return_code = data_info.get("return_code", 0)
|
|
1618
1801
|
|
|
1619
1802
|
if return_code != 0xFF: # 0xFF = Success
|
|
1620
1803
|
desc = get_return_code_description(return_code)
|
|
1621
1804
|
raise S7ProtocolError(f"Write operation failed: {desc} (0x{return_code:02x})")
|
|
1622
|
-
|
|
1805
|
+
else:
|
|
1806
|
+
raise S7ProtocolError("Missing write acknowledgement")
|
|
1623
1807
|
|
|
1624
1808
|
|
|
1625
1809
|
# ---------------------------------------------------------------------------
|