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.
- {python_snap7-3.2.0.dist-info → python_snap7-3.3.0.dist-info}/METADATA +27 -1
- {python_snap7-3.2.0.dist-info → python_snap7-3.3.0.dist-info}/RECORD +13 -13
- {python_snap7-3.2.0.dist-info → python_snap7-3.3.0.dist-info}/WHEEL +1 -1
- snap7/async_client.py +399 -67
- snap7/client.py +96 -92
- snap7/client_base.py +75 -2
- snap7/s7protocol.py +251 -231
- snap7/server/__init__.py +216 -249
- snap7/szl.py +1 -1
- snap7/util/getters.py +19 -10
- {python_snap7-3.2.0.dist-info → python_snap7-3.3.0.dist-info}/entry_points.txt +0 -0
- {python_snap7-3.2.0.dist-info → python_snap7-3.3.0.dist-info}/licenses/LICENSE +0 -0
- {python_snap7-3.2.0.dist-info → python_snap7-3.3.0.dist-info}/top_level.txt +0 -0
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 "
|
|
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
|
-
|
|
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 "
|
|
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
|
-
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
#
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
589
|
-
"
|
|
590
|
-
|
|
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
|
-
|
|
619
|
-
"
|
|
620
|
-
|
|
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
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
-
|
|
672
|
-
|
|
616
|
+
payload, _ = self.parse_upload_fragment(response)
|
|
617
|
+
return payload
|
|
673
618
|
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
-
|
|
692
|
-
|
|
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
|
-
|
|
695
|
-
|
|
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
|
-
|
|
896
|
-
|
|
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
|
-
|
|
911
|
-
|
|
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
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
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,
|
|
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"
|
|
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)
|
|
1034
|
-
|
|
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
|
-
|
|
1103
|
-
|
|
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
|
|
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
|
-
">
|
|
1098
|
+
">BBBBBBBBBBBB",
|
|
1138
1099
|
0x00, # Reserved
|
|
1139
1100
|
0x01, # Parameter count
|
|
1140
1101
|
0x12, # Type/length header
|
|
1141
|
-
|
|
1142
|
-
|
|
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
|
-
|
|
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
|
-
">
|
|
1263
|
-
0x00,
|
|
1264
|
-
|
|
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
|
-
(
|
|
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
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
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
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1358
|
-
|
|
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
|
-
|
|
1367
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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) >=
|
|
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,
|
|
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("
|
|
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}
|