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/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: 0x04,
346
+ S7WordLen.CHAR: 0x09,
341
347
  S7WordLen.WORD: 0x04,
342
348
  S7WordLen.INT: 0x05,
343
349
  S7WordLen.DWORD: 0x04,
344
- S7WordLen.DINT: 0x06,
350
+ S7WordLen.DINT: 0x05,
345
351
  S7WordLen.REAL: 0x07,
346
- S7WordLen.COUNTER: 0x04,
347
- S7WordLen.TIMER: 0x04,
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
- len(data) * 8, # Bit length (data length in bits)
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["data"] = self._parse_data_section(data_section)
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
- actual_data = data_section[4 : 4 + data_length]
1715
+ byte_length = data_length
1562
1716
  else:
1563
1717
  # READ_AREA responses use bit length
1564
- actual_data = data_section[4 : 4 + (data_length // 8)]
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
- # For successful writes, check the data section return code if present
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", 0xFF) # Default to success
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
- # If no data and no header error, the write was successful (ACK without data)
1805
+ else:
1806
+ raise S7ProtocolError("Missing write acknowledgement")
1623
1807
 
1624
1808
 
1625
1809
  # ---------------------------------------------------------------------------