python-snap7 3.2.0__py3-none-any.whl → 3.3.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
@@ -114,6 +114,10 @@ _STOP_PARAMS = bytes.fromhex("29000000000009") + b"P_PROGRAM"
114
114
  _HOT_START_PARAMS = bytes.fromhex("28000000000000fd000009") + b"P_PROGRAM"
115
115
  # Cold start: same as hot start but with block "C " before PI service
116
116
  _COLD_START_PARAMS = bytes.fromhex("28000000000000fd0002432009") + b"P_PROGRAM"
117
+ # Compress: function(0x28) + 7 reserved + block count(0) + PI length(5) + "_GARB" (as native Snap7 TReqFunCompress)
118
+ _COMPRESS_PARAMS = bytes.fromhex("28000000000000fd000005") + b"_GARB"
119
+ # Copy RAM to ROM: like compress but with file system "EP" before PI service "_MODU" (native TReqFunCopyRamToRom)
120
+ _COPY_RAM_TO_ROM_PARAMS = bytes.fromhex("28000000000000fd0002455005") + b"_MODU"
117
121
 
118
122
 
119
123
  class S7Protocol:
@@ -402,7 +406,7 @@ class S7Protocol:
402
406
  Build PLC control request using the S7 PI service PDU format.
403
407
 
404
408
  Args:
405
- operation: Control operation ('stop', 'hot_start', 'cold_start')
409
+ operation: Control operation ('stop', 'hot_start', 'cold_start', 'compress', 'copy_ram_to_rom')
406
410
 
407
411
  Returns:
408
412
  Complete S7 PDU for PLC control
@@ -411,6 +415,8 @@ class S7Protocol:
411
415
  "stop": _STOP_PARAMS,
412
416
  "hot_start": _HOT_START_PARAMS,
413
417
  "cold_start": _COLD_START_PARAMS,
418
+ "compress": _COMPRESS_PARAMS,
419
+ "copy_ram_to_rom": _COPY_RAM_TO_ROM_PARAMS,
414
420
  }
415
421
 
416
422
  if operation not in params:
@@ -449,87 +455,23 @@ class S7Protocol:
449
455
  """
450
456
  Build PLC control request for memory compression.
451
457
 
452
- Uses PI service "_MSZL" (compress memory).
458
+ Uses PI service "_GARB" (compress memory), as native Snap7 does.
453
459
 
454
460
  Returns:
455
461
  Complete S7 PDU for compress request
456
462
  """
457
- # PI service command for compress
458
- pi_service = b"_MSZL"
459
-
460
- # Parameter section: function code + PI service
461
- # Format: func(1) + unknown(7) + pi_len(1) + pi_service
462
- param_data = (
463
- struct.pack(
464
- ">BBBBBBBBB",
465
- S7Function.PLC_CONTROL, # 0x28
466
- 0x00, # Reserved
467
- 0x00, # Reserved
468
- 0x00, # Reserved
469
- 0x00, # Reserved
470
- 0x00, # Reserved
471
- 0x00, # Reserved
472
- 0x00, # Reserved
473
- len(pi_service), # PI service length
474
- )
475
- + pi_service
476
- )
477
-
478
- header = struct.pack(
479
- ">BBHHHH",
480
- 0x32, # Protocol ID
481
- S7PDUType.REQUEST, # PDU type
482
- 0x0000, # Reserved
483
- self._next_sequence(), # Sequence
484
- len(param_data), # Parameter length
485
- 0x0000, # Data length
486
- )
487
-
488
- return header + param_data
463
+ return self.build_plc_control_request("compress")
489
464
 
490
465
  def build_copy_ram_to_rom_request(self) -> bytes:
491
466
  """
492
467
  Build PLC control request for copying RAM to ROM.
493
468
 
494
- Uses PI service "_MSZL" with file system parameters.
469
+ Uses PI service "_MODU" with the "EP" file system, as native Snap7 does.
495
470
 
496
471
  Returns:
497
472
  Complete S7 PDU for copy RAM to ROM request
498
473
  """
499
- # PI service command for copy RAM to ROM
500
- # Uses EP parameter for target file system
501
- pi_service = b"_MSZL"
502
- file_id = b"P" # P = passive file system (ROM)
503
-
504
- # Parameter section with file system identifier
505
- param_data = (
506
- struct.pack(
507
- ">BBBBBBBBB",
508
- S7Function.PLC_CONTROL, # 0x28
509
- 0x00, # Reserved
510
- 0x00, # Reserved
511
- 0x00, # Reserved
512
- 0x00, # Reserved
513
- 0x00, # Reserved
514
- 0x00, # Reserved
515
- len(file_id), # File ID length
516
- len(pi_service), # PI service length
517
- )
518
- + file_id
519
- + pi_service
520
- )
521
-
522
- header = struct.pack(
523
- ">BBHHHH",
524
- 0x32, # Protocol ID
525
- S7PDUType.REQUEST, # PDU type
526
- 0x0000, # Reserved
527
- self._next_sequence(), # Sequence
528
- len(param_data), # Parameter length
529
- 0x0000, # Data length
530
- )
531
-
532
- return header + param_data
474
+ return self.build_plc_control_request("copy_ram_to_rom")
533
475
 
534
476
  # ========================================================================
535
477
  # Block Transfer PDU Builders (Upload/Download)
@@ -546,22 +488,15 @@ class S7Protocol:
546
488
  Returns:
547
489
  Complete S7 PDU for start upload request
548
490
  """
549
- # Block address string: e.g., "0A00001P" for DB1
550
- # Format: block_type (2 hex) + block_num (5 digits) + file_system (1 char)
551
- block_addr = f"{block_type:02X}{block_num:05d}A".encode("ascii")
491
+ if not 0 <= block_type <= 0xFF:
492
+ raise ValueError("block_type must fit in one byte")
493
+ if not 0 <= block_num <= 99999:
494
+ raise ValueError("block_num must be between 0 and 99999")
552
495
 
553
- # Parameters: function + status + reserved + upload_id + block_addr_len + block_addr
554
- param_data = (
555
- struct.pack(
556
- ">BBBIB",
557
- S7Function.START_UPLOAD, # Function code
558
- 0x00, # Status
559
- 0x00, # Reserved (error code)
560
- 0x00000000, # Upload ID (0 for start)
561
- len(block_addr), # Block address length
562
- )
563
- + block_addr
564
- )
496
+ # TReqFunStartUploadParams: function, six reserved bytes, one-byte
497
+ # upload ID, then the nine-byte block address `_0TNNNNNA`.
498
+ block_addr = b"_0" + bytes((block_type,)) + f"{block_num:05d}".encode("ascii") + b"A"
499
+ param_data = bytes((S7Function.START_UPLOAD,)) + bytes(7) + bytes((len(block_addr),)) + block_addr
565
500
 
566
501
  header = struct.pack(
567
502
  ">BBHHHH",
@@ -585,13 +520,9 @@ class S7Protocol:
585
520
  Returns:
586
521
  Complete S7 PDU for upload request
587
522
  """
588
- param_data = struct.pack(
589
- ">BBBI",
590
- S7Function.UPLOAD, # Function code
591
- 0x00, # Status
592
- 0x00, # Reserved
593
- upload_id, # Upload ID
594
- )
523
+ if not 0 <= upload_id <= 0xFF:
524
+ raise ValueError("upload_id must fit in one byte")
525
+ param_data = bytes((S7Function.UPLOAD,)) + bytes(6) + bytes((upload_id,))
595
526
 
596
527
  header = struct.pack(
597
528
  ">BBHHHH",
@@ -615,13 +546,9 @@ class S7Protocol:
615
546
  Returns:
616
547
  Complete S7 PDU for end upload request
617
548
  """
618
- param_data = struct.pack(
619
- ">BBBI",
620
- S7Function.END_UPLOAD, # Function code
621
- 0x00, # Status
622
- 0x00, # Reserved
623
- upload_id, # Upload ID
624
- )
549
+ if not 0 <= upload_id <= 0xFF:
550
+ raise ValueError("upload_id must fit in one byte")
551
+ param_data = bytes((S7Function.END_UPLOAD,)) + bytes(6) + bytes((upload_id,))
625
552
 
626
553
  header = struct.pack(
627
554
  ">BBHHHH",
@@ -642,24 +569,42 @@ class S7Protocol:
642
569
  Returns:
643
570
  Dictionary with upload_id and block_length
644
571
  """
645
- result = {"upload_id": 0, "block_length": 0}
646
-
647
572
  raw_params = response.get("raw_parameters", b"")
648
-
649
- if len(raw_params) >= 8:
650
- # Parse: function + status + reserved + upload_id
651
- result["upload_id"] = struct.unpack(">I", raw_params[4:8])[0]
652
- if len(raw_params) > 8:
653
- # Block length string follows
654
- len_field = raw_params[8]
655
- if len(raw_params) > 9 + len_field:
656
- length_str = raw_params[9 : 9 + len_field]
657
- try:
658
- result["block_length"] = int(length_str)
659
- except ValueError:
660
- pass
661
-
662
- return result
573
+ if len(raw_params) != 16 or raw_params[0] != S7Function.START_UPLOAD:
574
+ raise S7ProtocolError("Invalid start-upload response parameters")
575
+ try:
576
+ block_length = int(raw_params[11:16].decode("ascii"))
577
+ except (UnicodeDecodeError, ValueError) as exc:
578
+ raise S7ProtocolError("Invalid start-upload block length") from exc
579
+ return {"upload_id": raw_params[7], "block_length": block_length}
580
+
581
+ def parse_upload_fragment(
582
+ self,
583
+ response: Dict[str, Any],
584
+ *,
585
+ first_fragment: bool = False,
586
+ include_block_header: bool = False,
587
+ ) -> Tuple[bytes, bool]:
588
+ """Validate and decode one upload response fragment."""
589
+ raw_params = response.get("raw_parameters", b"")
590
+ if len(raw_params) != 2 or raw_params[0] != S7Function.UPLOAD or raw_params[1] not in (0, 1):
591
+ raise S7ProtocolError("Invalid upload response parameters")
592
+
593
+ raw_data = response.get("raw_data", b"")
594
+ if len(raw_data) < 4:
595
+ raise S7ProtocolError("Upload response data is truncated")
596
+ declared_length, marker = struct.unpack(">HH", raw_data[:4])
597
+ if marker != 0x00FB:
598
+ raise S7ProtocolError("Invalid upload response data marker")
599
+ if declared_length != len(raw_data) - 4:
600
+ raise S7ProtocolError("Upload response data length mismatch")
601
+ payload = raw_data[4:]
602
+ if first_fragment and not include_block_header:
603
+ compact_header_size = 36
604
+ if len(payload) < compact_header_size:
605
+ raise S7ProtocolError("First upload response is missing its compact block header")
606
+ payload = payload[compact_header_size:]
607
+ return payload, raw_params[1] == 0
663
608
 
664
609
  def parse_upload_response(self, response: Dict[str, Any]) -> bytes:
665
610
  """
@@ -668,13 +613,15 @@ class S7Protocol:
668
613
  Returns:
669
614
  Block data bytes
670
615
  """
671
- data_info = response.get("data", {})
672
- raw_data: bytes = data_info.get("data", b"")
616
+ payload, _ = self.parse_upload_fragment(response)
617
+ return payload
673
618
 
674
- # Skip the data header if present (length + unknown bytes)
675
- if len(raw_data) > 2:
676
- return raw_data
677
- return b""
619
+ def check_end_upload_response(self, response: Dict[str, Any]) -> None:
620
+ """Require a canonical END_UPLOAD acknowledgement."""
621
+ if response.get("raw_parameters", b"") != bytes((S7Function.END_UPLOAD,)):
622
+ raise S7ProtocolError("Invalid end-upload response")
623
+ if response.get("raw_data", b""):
624
+ raise S7ProtocolError("End-upload response must not contain data")
678
625
 
679
626
  def build_download_request(self, block_type: int, block_num: int, block_data: bytes) -> bytes:
680
627
  """
@@ -688,26 +635,20 @@ class S7Protocol:
688
635
  Returns:
689
636
  Complete S7 PDU for request download
690
637
  """
691
- # Block address string
692
- block_addr = f"{block_type:02X}{block_num:05d}P".encode("ascii")
638
+ if not 0 <= block_type <= 0xFF or not 0 <= block_num <= 99999:
639
+ raise ValueError("Invalid block type or number")
640
+ if len(block_data) > 999999:
641
+ raise ValueError("Block is too large for the download request")
642
+ if len(block_data) >= 36 and block_data[2] == 1 and block_data[4] == 5:
643
+ mc7_size = struct.unpack(">H", block_data[34:36])[0]
644
+ else:
645
+ mc7_size = len(block_data) - 36 if len(block_data) >= 36 else len(block_data)
646
+ if mc7_size > 999999:
647
+ raise ValueError("MC7 size is too large for the download request")
693
648
 
694
- # Block length as string
695
- length_str = f"{len(block_data):06d}".encode("ascii")
696
-
697
- # Parameters
698
- param_data = (
699
- struct.pack(
700
- ">BBBBB",
701
- S7Function.REQUEST_DOWNLOAD, # Function code
702
- 0x00, # Status
703
- 0x00, # Reserved
704
- 0x00, # Reserved
705
- len(block_addr), # Block address length
706
- )
707
- + block_addr
708
- + struct.pack(">B", len(length_str))
709
- + length_str
710
- )
649
+ block_addr = b"_0" + bytes((block_type,)) + f"{block_num:05d}".encode("ascii") + b"P"
650
+ sizes = b"1" + f"{len(block_data):06d}{mc7_size:06d}".encode("ascii")
651
+ param_data = bytes((S7Function.REQUEST_DOWNLOAD, 0, 1)) + bytes(5) + b"\x09" + block_addr + b"\x0d" + sizes
711
652
 
712
653
  header = struct.pack(
713
654
  ">BBHHHH",
@@ -721,6 +662,37 @@ class S7Protocol:
721
662
 
722
663
  return header + param_data
723
664
 
665
+ @staticmethod
666
+ def parse_download_service_request(pdu: bytes, function: int, block_num: int) -> int:
667
+ """Validate a PLC request for a download slice or completion acknowledgement."""
668
+ if len(pdu) != 28 or pdu[0:2] != bytes((0x32, S7PDUType.REQUEST)):
669
+ raise S7ProtocolError("Invalid PLC download request header")
670
+ sequence, param_len, data_len = struct.unpack(">HHH", pdu[4:10])
671
+ if param_len != 18 or data_len != 0:
672
+ raise S7ProtocolError("Invalid PLC download request lengths")
673
+ params = pdu[10:]
674
+ expected_address = b"_0" + bytes((0x41,)) + f"{block_num:05d}".encode("ascii") + b"P"
675
+ if params[0] != function or params[8] != 9 or params[9:] != expected_address:
676
+ raise S7ProtocolError("Unexpected PLC download request")
677
+ return int(sequence)
678
+
679
+ @staticmethod
680
+ def build_download_fragment_response(sequence: int, is_last: bool, data: bytes) -> bytes:
681
+ """Reply to a PLC DOWNLOAD_BLOCK request with one bounded fragment."""
682
+ if not 0 <= sequence <= 0xFFFF or len(data) > 0xFFFF:
683
+ raise ValueError("Invalid download response sequence or payload length")
684
+ parameters = bytes((S7Function.DOWNLOAD_BLOCK, 0 if is_last else 1))
685
+ payload = struct.pack(">HH", len(data), 0x00FB) + data
686
+ header = struct.pack(">BBHHHHH", 0x32, S7PDUType.ACK_DATA, 0, sequence, 2, len(payload), 0)
687
+ return header + parameters + payload
688
+
689
+ @staticmethod
690
+ def build_download_ended_response(sequence: int) -> bytes:
691
+ """Acknowledge the PLC's DOWNLOAD_ENDED request."""
692
+ if not 0 <= sequence <= 0xFFFF:
693
+ raise ValueError("Invalid download response sequence")
694
+ return struct.pack(">BBHHHHH", 0x32, S7PDUType.ACK_DATA, 0, sequence, 1, 0, 0) + bytes((S7Function.DOWNLOAD_ENDED,))
695
+
724
696
  def build_delete_block_request(self, block_type: int, block_num: int) -> bytes:
725
697
  """
726
698
  Build delete block request.
@@ -845,16 +817,7 @@ class S7Protocol:
845
817
  )
846
818
 
847
819
  # Data section: block type (0x30 prefix + type per Snap7 C format)
848
- data_section = struct.pack(
849
- ">BBHBBBB",
850
- 0x0A, # Return value (request)
851
- 0x00, # Transport size
852
- 0x0004, # Length (4 bytes)
853
- 0x30, # Block type indicator
854
- block_type, # Block type code
855
- 0x0A, # Trailing bytes per Snap7 C
856
- 0x00,
857
- )
820
+ data_section = struct.pack(">BBHBB", 0xFF, 0x09, 2, 0x30, block_type)
858
821
 
859
822
  # S7 header for USER_DATA
860
823
  header = struct.pack(
@@ -892,8 +855,9 @@ class S7Protocol:
892
855
  data_info = response.get("data", {})
893
856
  raw_data = data_info.get("data", b"")
894
857
 
895
- if not raw_data:
896
- return result
858
+ # Native Snap7 requires exactly seven four-byte entries (28 bytes)
859
+ if len(raw_data) != 28:
860
+ raise S7ProtocolError("Block-count response must contain exactly seven entries (28 bytes)")
897
861
 
898
862
  # Parse block entries (4 bytes each: 0x30 | type | count_hi | count_lo)
899
863
  # Block type codes
@@ -907,16 +871,18 @@ class S7Protocol:
907
871
  0x46: "SFBCount", # System Function Block
908
872
  }
909
873
 
910
- offset = 0
911
- while offset + 4 <= len(raw_data):
874
+ seen: set[int] = set()
875
+ for offset in range(0, 28, 4):
912
876
  indicator = raw_data[offset]
913
877
  block_type = raw_data[offset + 1]
914
878
  count = struct.unpack(">H", raw_data[offset + 2 : offset + 4])[0]
915
879
 
916
- if indicator == 0x30 and block_type in type_to_name:
917
- result[type_to_name[block_type]] = count
918
-
919
- offset += 4
880
+ if indicator != 0x30 or block_type not in type_to_name:
881
+ raise S7ProtocolError("Invalid block-count response entry")
882
+ if block_type in seen:
883
+ raise S7ProtocolError("Duplicate block type in block-count response")
884
+ seen.add(block_type)
885
+ result[type_to_name[block_type]] = count
920
886
 
921
887
  return result
922
888
 
@@ -937,6 +903,8 @@ class S7Protocol:
937
903
 
938
904
  if not raw_data:
939
905
  return result
906
+ if len(raw_data) % 4 != 0:
907
+ raise S7ProtocolError("Block-list response length is not a multiple of four")
940
908
 
941
909
  # Parse block entries (4 bytes each per TDataFunGetBotItem:
942
910
  # BlockNum(2) + Unknown(1) + BlockLang(1))
@@ -972,19 +940,11 @@ class S7Protocol:
972
940
  0x00, # DataRef (0x00 for initial request)
973
941
  )
974
942
 
975
- # Data section: [0x30, type, 'A', ASCII_num(5)] per Snap7 C format
943
+ # Data section: [0x30, type, ASCII_num(5), 'A'] per Snap7 C TReqDataBlockInfo
976
944
  # Block number is 5-digit zero-padded ASCII (e.g., 1 -> "00001")
977
945
  block_num_ascii = f"{block_num:05d}".encode("ascii")
978
- data_payload = struct.pack(">BB", 0x30, block_type) + b"A" + block_num_ascii
979
- data_section = (
980
- struct.pack(
981
- ">BBH",
982
- 0x0A, # Return value (request)
983
- 0x00, # Transport size
984
- len(data_payload), # Length
985
- )
986
- + data_payload
987
- )
946
+ data_payload = struct.pack(">BB", 0x30, block_type) + block_num_ascii + b"A"
947
+ data_section = struct.pack(">BBH", 0xFF, 0x09, len(data_payload)) + data_payload
988
948
 
989
949
  # S7 header for USER_DATA
990
950
  header = struct.pack(
@@ -1030,8 +990,8 @@ class S7Protocol:
1030
990
  data_info = response.get("data", {})
1031
991
  raw_data = data_info.get("data", b"")
1032
992
 
1033
- if len(raw_data) < 78:
1034
- return result
993
+ if len(raw_data) != 78:
994
+ raise S7ProtocolError("Block-info response must contain exactly 78 bytes")
1035
995
 
1036
996
  # Parse block info structure per TResDataBlockInfo layout
1037
997
  result["block_type"] = raw_data[1]
@@ -1099,8 +1059,8 @@ class S7Protocol:
1099
1059
  # Data section: SZL ID and Index
1100
1060
  data_section = struct.pack(
1101
1061
  ">BBHHH",
1102
- 0x0A, # Return value (request)
1103
- 0x00, # Transport size
1062
+ 0xFF, # Return value (0xFF, as engineering tools and native Snap7 send for requests carrying data)
1063
+ 0x09, # Transport size (octet string)
1104
1064
  0x0004, # Length (4 bytes for ID + Index)
1105
1065
  szl_id, # SZL ID
1106
1066
  szl_index, # SZL Index
@@ -1131,18 +1091,23 @@ class S7Protocol:
1131
1091
  Returns:
1132
1092
  Complete S7 PDU for follow-up request
1133
1093
  """
1134
- # Parameter section: same as initial but with DataRef = sequence_number
1094
+ # Parameter section as sent by engineering tools for continuation requests: 12 bytes, method 0x12,
1095
+ # DataRef = sequence_number, followed by last-data-unit and error code fields set to zero
1135
1096
  type_group = 0x40 | (group & 0x0F) # Type 4 (request) | group
1136
1097
  param_data = struct.pack(
1137
- ">BBBBBBBB",
1098
+ ">BBBBBBBBBBBB",
1138
1099
  0x00, # Reserved
1139
1100
  0x01, # Parameter count
1140
1101
  0x12, # Type/length header
1141
- 0x04, # Length of following data
1142
- 0x11, # Method (0x11 = request)
1102
+ 0x08, # Length of following data
1103
+ 0x12, # Method (0x12)
1143
1104
  type_group, # Type | Group
1144
1105
  subfunction, # Subfunction
1145
1106
  sequence_number, # DataRef from previous response
1107
+ 0x00, # Last data unit
1108
+ 0x00, # Reserved
1109
+ 0x00, # Error code high
1110
+ 0x00, # Error code low
1146
1111
  )
1147
1112
 
1148
1113
  # Minimal data section for follow-up
@@ -1250,6 +1215,9 @@ class S7Protocol:
1250
1215
 
1251
1216
  Returns:
1252
1217
  Complete S7 PDU for set clock request
1218
+
1219
+ Raises:
1220
+ ValueError: If the year is outside 1990-2089, which the two-digit S7 year cannot represent.
1253
1221
  """
1254
1222
 
1255
1223
  # Convert datetime to BCD format
@@ -1257,17 +1225,23 @@ class S7Protocol:
1257
1225
  def to_bcd(value: int) -> int:
1258
1226
  return ((value // 10) << 4) | (value % 10)
1259
1227
 
1260
- year = dt.year % 100 # Only last 2 digits
1228
+ # The two-digit S7 year maps 90-99 to 1990-1999 and 00-89 to 2000-2089
1229
+ if not 1990 <= dt.year <= 2089:
1230
+ raise ValueError(f"S7 clock supports years 1990-2089, got {dt.year}")
1231
+ year = dt.year % 100
1232
+ millisecond = dt.microsecond // 1000
1261
1233
  bcd_time = struct.pack(
1262
- ">BBBBBBBB",
1263
- 0x00, # Reserved
1264
- to_bcd(year), # Year (BCD)
1234
+ ">BBBBBBBBBB",
1235
+ 0x00,
1236
+ 0x19, # Siemens date/time prefix
1237
+ to_bcd(year),
1265
1238
  to_bcd(dt.month), # Month (BCD)
1266
1239
  to_bcd(dt.day), # Day (BCD)
1267
1240
  to_bcd(dt.hour), # Hour (BCD)
1268
1241
  to_bcd(dt.minute), # Minute (BCD)
1269
1242
  to_bcd(dt.second), # Second (BCD)
1270
- (dt.weekday() + 1) & 0x0F, # Day of week (1=Monday)
1243
+ to_bcd(millisecond // 10),
1244
+ ((millisecond % 10) << 4) | (dt.isoweekday() % 7 + 1), # S7 weekday: 1=Sunday .. 7=Saturday
1271
1245
  )
1272
1246
 
1273
1247
  # Parameter section for USER_DATA clock request
@@ -1284,15 +1258,7 @@ class S7Protocol:
1284
1258
  )
1285
1259
 
1286
1260
  # Data section with BCD time
1287
- data_section = (
1288
- struct.pack(
1289
- ">BBH",
1290
- 0x0A, # Return value (request)
1291
- 0x00, # Transport size
1292
- len(bcd_time), # Length
1293
- )
1294
- + bcd_time
1295
- )
1261
+ data_section = struct.pack(">BBH", 0xFF, 0x09, len(bcd_time)) + bcd_time
1296
1262
 
1297
1263
  # S7 header for USER_DATA
1298
1264
  header = struct.pack(
@@ -1322,29 +1288,41 @@ class S7Protocol:
1322
1288
  data_info = response.get("data", {})
1323
1289
  raw_data = data_info.get("data", b"")
1324
1290
 
1325
- if len(raw_data) < 8:
1326
- # Return current time if no valid data
1327
- return dt_class.now().replace(microsecond=0)
1291
+ # The S7 clock data is ten bytes: reserved, century (0x19/0x20), then year, month, day,
1292
+ # hour, minute, second and milliseconds/weekday. The older eight-byte form (no century byte,
1293
+ # no milliseconds) is still accepted.
1294
+ if len(raw_data) == 10:
1295
+ time_bytes = raw_data[2:]
1296
+ elif len(raw_data) == 8:
1297
+ time_bytes = raw_data[1:]
1298
+ else:
1299
+ raise S7ProtocolError("Clock response must contain ten bytes (or eight in the legacy form)")
1328
1300
 
1329
- # Parse BCD time
1330
1301
  def from_bcd(value: int) -> int:
1302
+ if value >> 4 > 9 or value & 0x0F > 9:
1303
+ raise S7ProtocolError("Clock response contains an invalid BCD timestamp")
1331
1304
  return ((value >> 4) * 10) + (value & 0x0F)
1332
1305
 
1333
- # Skip first byte (reserved)
1334
- year = from_bcd(raw_data[1])
1335
- month = from_bcd(raw_data[2])
1336
- day = from_bcd(raw_data[3])
1337
- hour = from_bcd(raw_data[4])
1338
- minute = from_bcd(raw_data[5])
1339
- second = from_bcd(raw_data[6])
1340
-
1341
- # Determine century (assume 2000s for years 0-99)
1306
+ year = from_bcd(time_bytes[0])
1307
+ month = from_bcd(time_bytes[1])
1308
+ day = from_bcd(time_bytes[2])
1309
+ hour = from_bcd(time_bytes[3])
1310
+ minute = from_bcd(time_bytes[4])
1311
+ second = from_bcd(time_bytes[5])
1312
+ microsecond = 0
1313
+ if len(raw_data) == 10:
1314
+ # Byte 8: milliseconds hundreds/tens (BCD); high nibble of byte 9: milliseconds ones
1315
+ microsecond = (from_bcd(time_bytes[6]) * 10 + (time_bytes[7] >> 4)) * 1000
1316
+ if microsecond > 999000:
1317
+ raise S7ProtocolError("Clock response contains an invalid BCD timestamp")
1318
+
1319
+ # The two-digit S7 year maps 90-99 to 1990-1999 and 00-89 to 2000-2089
1342
1320
  full_year = 2000 + year if year < 90 else 1900 + year
1343
1321
 
1344
1322
  try:
1345
- return dt_class(full_year, month, day, hour, minute, second)
1346
- except ValueError:
1347
- return dt_class.now().replace(microsecond=0)
1323
+ return dt_class(full_year, month, day, hour, minute, second, microsecond)
1324
+ except ValueError as exc:
1325
+ raise S7ProtocolError("Clock response contains an invalid BCD timestamp") from exc
1348
1326
 
1349
1327
  # ========================================================================
1350
1328
  # Session Password PDU Builders (Security / Function Group 5)
@@ -1354,8 +1332,8 @@ class S7Protocol:
1354
1332
  def encode_password(password: str) -> bytes:
1355
1333
  """Encode an S7 session password into the 8-byte wire format.
1356
1334
 
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.
1335
+ Snap7 pads with spaces, XORs with ``0x55``, and chains each byte
1336
+ from the encoded byte two positions earlier.
1359
1337
 
1360
1338
  Args:
1361
1339
  password: Plaintext password (max 8 characters).
@@ -1363,12 +1341,13 @@ class S7Protocol:
1363
1341
  Returns:
1364
1342
  8-byte encoded password block.
1365
1343
  """
1366
- # Pad/truncate to exactly 8 bytes
1367
- raw = password.encode("ascii")[:8].ljust(8, b"\x00")
1344
+ raw = password.encode("ascii")
1345
+ if not 1 <= len(raw) <= 8:
1346
+ raise ValueError("Session password must contain between one and eight ASCII characters")
1347
+ raw = raw.ljust(8, b" ")
1368
1348
  encoded = bytearray(8)
1369
1349
  for i in range(8):
1370
- xored = raw[i] ^ 0x55
1371
- encoded[i] = ((xored << 3) | (xored >> 5)) & 0xFF
1350
+ encoded[i] = raw[i] ^ 0x55 ^ (encoded[i - 2] if i >= 2 else 0)
1372
1351
  return bytes(encoded)
1373
1352
 
1374
1353
  def build_set_session_password_request(self, encoded_password: bytes) -> bytes:
@@ -1396,15 +1375,9 @@ class S7Protocol:
1396
1375
  )
1397
1376
 
1398
1377
  # 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
- )
1378
+ if len(encoded_password) != 8:
1379
+ raise ValueError("Encoded session password must contain eight bytes")
1380
+ data_section = struct.pack(">BBH", 0xFF, 0x09, 8) + encoded_password
1408
1381
 
1409
1382
  header = struct.pack(
1410
1383
  ">BBHHHH",
@@ -1457,7 +1430,14 @@ class S7Protocol:
1457
1430
 
1458
1431
  return header + param_data + data_section
1459
1432
 
1460
- def check_userdata_response(self, response: Dict[str, Any]) -> None:
1433
+ def check_userdata_response(
1434
+ self,
1435
+ response: Dict[str, Any],
1436
+ expected_group: int | None = None,
1437
+ expected_subfunction: int | None = None,
1438
+ *,
1439
+ accept_null_ack: bool = False,
1440
+ ) -> None:
1461
1441
  """Check a USERDATA response for errors.
1462
1442
 
1463
1443
  Verifies both the parameter-level error code and the data section
@@ -1465,6 +1445,11 @@ class S7Protocol:
1465
1445
 
1466
1446
  Args:
1467
1447
  response: Parsed S7 response from :meth:`parse_response`.
1448
+ expected_group: Expected function group, if it should be verified.
1449
+ expected_subfunction: Expected subfunction, if it should be verified.
1450
+ accept_null_ack: For services that return no data (set clock, session passwords). Real PLCs
1451
+ acknowledge these with return code 0x0A, transport size 0 and length 0 next to a zero
1452
+ parameter error code, which is then a success.
1468
1453
 
1469
1454
  Raises:
1470
1455
  ~snap7.error.S7ProtocolError: If the response indicates an error.
@@ -1475,13 +1460,29 @@ class S7Protocol:
1475
1460
  if param_error != 0:
1476
1461
  error_msg = get_protocol_error_message(param_error)
1477
1462
  raise S7ProtocolError(f"USERDATA request failed: {error_msg} (0x{param_error:04x})")
1463
+ if params.get("last_data_unit", 0) not in (0x00, 0x01):
1464
+ raise S7ProtocolError(f"Invalid USERDATA last data unit value 0x{params['last_data_unit']:02x}")
1465
+ if expected_group is not None and params.get("group") != expected_group:
1466
+ raise S7ProtocolError("Unexpected USERDATA response function group")
1467
+ if expected_subfunction is not None and params.get("subfunction") != expected_subfunction:
1468
+ raise S7ProtocolError("Unexpected USERDATA response subfunction")
1469
+ else:
1470
+ raise S7ProtocolError("Missing USERDATA response parameters")
1478
1471
 
1479
1472
  data_info = response.get("data", {})
1480
1473
  if isinstance(data_info, dict):
1481
1474
  return_code = data_info.get("return_code", 0xFF)
1482
- if return_code != 0xFF:
1475
+ null_ack = (
1476
+ accept_null_ack
1477
+ and return_code == 0x0A
1478
+ and data_info.get("transport_size", 0) == 0
1479
+ and data_info.get("data_length", 0) == 0
1480
+ )
1481
+ if return_code != 0xFF and not null_ack:
1483
1482
  desc = get_return_code_description(return_code)
1484
1483
  raise S7ProtocolError(f"USERDATA request failed: {desc} (0x{return_code:02x})")
1484
+ else:
1485
+ raise S7ProtocolError("Missing USERDATA response data")
1485
1486
 
1486
1487
  def build_cpu_state_request(self) -> bytes:
1487
1488
  """
@@ -1560,8 +1561,9 @@ class S7Protocol:
1560
1561
  if pdu_type not in (S7PDUType.ACK, S7PDUType.ACK_DATA, S7PDUType.USERDATA):
1561
1562
  raise S7ProtocolError(f"Expected response PDU, got {pdu_type}")
1562
1563
 
1564
+ # Like native Snap7, treat the whole 16-bit header error word as the failure indicator
1563
1565
  combined_error = (error_class << 8) | error_code
1564
- if error_class != 0:
1566
+ if combined_error != 0:
1565
1567
  error_msg = get_protocol_error_message(combined_error)
1566
1568
  raise S7ProtocolError(
1567
1569
  f"S7 protocol error (class={error_class:#04x}, code={error_code:#04x}): {error_msg}",
@@ -1583,6 +1585,7 @@ class S7Protocol:
1583
1585
  raise S7ProtocolError("Parameter section extends beyond PDU")
1584
1586
 
1585
1587
  param_data = pdu[offset : offset + param_len]
1588
+ response["raw_parameters"] = param_data
1586
1589
  response["parameters"] = self._parse_parameters(param_data)
1587
1590
  offset += param_len
1588
1591
 
@@ -1592,16 +1595,29 @@ class S7Protocol:
1592
1595
  raise S7ProtocolError("Data section extends beyond PDU")
1593
1596
 
1594
1597
  data_section = pdu[offset : offset + data_len]
1595
- if (response.get("parameters") or {}).get("function_code") == S7Function.WRITE_AREA:
1598
+ function_code = (response.get("parameters") or {}).get("function_code")
1599
+ if function_code == S7Function.WRITE_AREA:
1596
1600
  # WRITE data is an array of acknowledgement codes, not a READ item header.
1597
1601
  response["data"] = {"return_code": data_section[0]}
1602
+ elif function_code == S7Function.UPLOAD:
1603
+ response["data"] = {"data": data_section}
1598
1604
  else:
1599
- # Other functions (notably block upload) do not carry READ item headers.
1605
+ parameters = response.get("parameters") or {}
1606
+ validate_item_length = function_code != S7Function.READ_AREA or parameters.get("item_count") == 1
1600
1607
  response["data"] = self._parse_data_section(
1601
1608
  data_section,
1602
- validate_length=(response.get("parameters") or {}).get("function_code") == S7Function.READ_AREA,
1609
+ validate_length=validate_item_length,
1603
1610
  )
1604
1611
  response["raw_data"] = data_section
1612
+ offset += data_len
1613
+
1614
+ if offset != len(pdu):
1615
+ raise S7ProtocolError("S7 response contains trailing bytes")
1616
+
1617
+ if pdu_type == S7PDUType.USERDATA:
1618
+ # The null acknowledgement is only a success for no-data services; callers that expect data
1619
+ # run check_userdata_response again without accept_null_ack.
1620
+ self.check_userdata_response(response, accept_null_ack=True)
1605
1621
 
1606
1622
  return response
1607
1623
 
@@ -1612,7 +1628,9 @@ class S7Protocol:
1612
1628
 
1613
1629
  # Detect USERDATA response parameters:
1614
1630
  # byte 0 = 0x00 (reserved), len >= 12, byte 2 = 0x12, byte 4 = 0x12 (method=response)
1615
- if param_data[0] == 0x00 and len(param_data) >= 12 and param_data[2] == 0x12 and param_data[4] == 0x12:
1631
+ if param_data[0] == 0x00 and len(param_data) >= 5 and param_data[2] == 0x12 and param_data[4] == 0x12:
1632
+ if len(param_data) != 12:
1633
+ raise S7ProtocolError("USERDATA response parameters must contain exactly 12 bytes")
1616
1634
  return self._parse_userdata_response_params(param_data)
1617
1635
 
1618
1636
  function_code = param_data[0]
@@ -1673,7 +1691,7 @@ class S7Protocol:
1673
1691
  [6] Subfunction
1674
1692
  [7] Sequence number (used as DataRef in follow-up)
1675
1693
  [8] Data unit reference
1676
- [9] Last data unit (0x00 = last, non-zero = more)
1694
+ [9] Last data unit (0x00 = last, 0x01 = more)
1677
1695
  [10-11] Error code
1678
1696
  """
1679
1697
  type_group = param_data[5]
@@ -1718,7 +1736,9 @@ class S7Protocol:
1718
1736
  byte_length = data_length // 8
1719
1737
 
1720
1738
  if validate_length and 4 + byte_length > len(data_section):
1721
- raise S7ProtocolError("Read data item is truncated")
1739
+ raise S7ProtocolError("Data item is truncated")
1740
+ if validate_length and 4 + byte_length < len(data_section):
1741
+ raise S7ProtocolError("Data item contains trailing bytes")
1722
1742
  actual_data = data_section[4 : 4 + byte_length]
1723
1743
 
1724
1744
  return {"return_code": return_code, "transport_size": transport_size, "data_length": data_length, "data": actual_data}