python-snap7 3.0.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 ADDED
@@ -0,0 +1,1509 @@
1
+ """
2
+ S7 protocol implementation.
3
+
4
+ Handles S7 PDU encoding/decoding and protocol operations.
5
+ """
6
+
7
+ import struct
8
+ import logging
9
+ from datetime import datetime
10
+ from typing import List, Dict, Any
11
+ from enum import IntEnum
12
+
13
+ from .datatypes import S7Area, S7WordLen, S7DataTypes
14
+ from .error import S7ProtocolError, S7StalePacketError, S7PacketLostError, get_protocol_error_message
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class S7Function(IntEnum):
20
+ """S7 protocol function codes."""
21
+
22
+ READ_AREA = 0x04
23
+ WRITE_AREA = 0x05
24
+ REQUEST_DOWNLOAD = 0x1A
25
+ DOWNLOAD_BLOCK = 0x1B
26
+ DOWNLOAD_ENDED = 0x1C
27
+ START_UPLOAD = 0x1D
28
+ UPLOAD = 0x1E
29
+ END_UPLOAD = 0x1F
30
+ PLC_CONTROL = 0x28
31
+ PLC_STOP = 0x29
32
+ SETUP_COMMUNICATION = 0xF0
33
+
34
+
35
+ class S7PDUType(IntEnum):
36
+ """S7 PDU type codes."""
37
+
38
+ REQUEST = 0x01
39
+ ACK = 0x02 # Acknowledge without data (e.g., write responses)
40
+ ACK_DATA = 0x03 # Acknowledge with data (e.g., read responses)
41
+ USERDATA = 0x07
42
+
43
+
44
+ class S7UserDataGroup(IntEnum):
45
+ """S7 USER_DATA type groups (from s7_types.h)."""
46
+
47
+ PROGRAMMER = 0x01 # grProgrammer
48
+ CYCLIC_DATA = 0x02 # grCyclicData
49
+ BLOCK_INFO = 0x03 # grBlocksInfo
50
+ SZL = 0x04 # grSZL
51
+ SECURITY = 0x05 # grPassword
52
+ TIME = 0x07 # grClock
53
+
54
+
55
+ class S7UserDataSubfunction(IntEnum):
56
+ """S7 USER_DATA subfunctions."""
57
+
58
+ # Block info subfunctions
59
+ LIST_ALL = 0x01 # SFun_ListAll
60
+ LIST_BLOCKS_OF_TYPE = 0x02 # SFun_ListBoT
61
+ BLOCK_INFO = 0x03 # SFun_BlkInfo
62
+
63
+ # SZL subfunctions
64
+ READ_SZL = 0x01 # SFun_ReadSZL
65
+ SYSTEM_STATE = 0x02 # System state request
66
+
67
+ # Clock subfunctions
68
+ GET_CLOCK = 0x01
69
+ SET_CLOCK = 0x02
70
+
71
+
72
+ # S7 data section return codes with human-readable descriptions
73
+ S7_RETURN_CODES: Dict[int, str] = {
74
+ 0x00: "Reserved",
75
+ 0x01: "Hardware error",
76
+ 0x03: "Accessing the object not allowed",
77
+ 0x05: "Invalid address",
78
+ 0x06: "Data type not supported",
79
+ 0x07: "Data type inconsistent",
80
+ 0x0A: "Object does not exist",
81
+ 0x10: "Invalid block type number",
82
+ 0x11: "Block not found in storage medium",
83
+ 0x12: "Block already exists",
84
+ 0x13: "Block is protected",
85
+ 0x14: "Block download without proper block first",
86
+ 0x19: "Block download sequence error",
87
+ 0x1A: "Insufficient working memory",
88
+ 0x1B: "Insufficient load memory",
89
+ 0x1C: "Not enough work retentive data (instance DBs)",
90
+ 0x1D: "Interface error",
91
+ 0x1E: "Delete block refused",
92
+ 0x20: "Invalid parameter",
93
+ 0x21: "PG resource error (max connections reached)",
94
+ 0xFF: "Success",
95
+ }
96
+
97
+
98
+ def get_return_code_description(return_code: int) -> str:
99
+ """Get human-readable description for S7 return code."""
100
+ if return_code in S7_RETURN_CODES:
101
+ return S7_RETURN_CODES[return_code]
102
+ return "Unknown error"
103
+
104
+
105
+ class S7Protocol:
106
+ """
107
+ S7 protocol implementation.
108
+
109
+ Handles encoding and decoding of S7 PDUs for communication with Siemens PLCs.
110
+ """
111
+
112
+ def __init__(self) -> None:
113
+ self.sequence = 0 # Message sequence counter
114
+
115
+ def _next_sequence(self) -> int:
116
+ """Get next sequence number for S7 PDU."""
117
+ self.sequence = (self.sequence + 1) & 0xFFFF
118
+ return self.sequence
119
+
120
+ def validate_pdu_reference(self, response_sequence: int) -> None:
121
+ """Validate the PDU reference number from a response.
122
+
123
+ Compares the response sequence number against the expected (current) sequence.
124
+
125
+ Args:
126
+ response_sequence: Sequence number from the response PDU.
127
+
128
+ Raises:
129
+ S7StalePacketError: If response is older than expected (stale).
130
+ S7PacketLostError: If response is ahead of expected (packet loss).
131
+ """
132
+ if response_sequence < self.sequence:
133
+ raise S7StalePacketError(f"Stale packet: expected sequence {self.sequence}, got {response_sequence}")
134
+ elif response_sequence > self.sequence:
135
+ raise S7PacketLostError(f"Packet lost: expected sequence {self.sequence}, got {response_sequence}")
136
+
137
+ def build_read_request(self, area: S7Area, db_number: int, start: int, word_len: S7WordLen, count: int) -> bytes:
138
+ """
139
+ Build S7 read request PDU.
140
+
141
+ Args:
142
+ area: Memory area to read from
143
+ db_number: DB number (for DB area)
144
+ start: Start address/offset
145
+ word_len: Data word length
146
+ count: Number of items to read
147
+
148
+ Returns:
149
+ Complete S7 PDU
150
+ """
151
+ # S7 Header (12 bytes)
152
+ header = struct.pack(
153
+ ">BBHHHH",
154
+ 0x32, # Protocol ID
155
+ S7PDUType.REQUEST, # PDU type
156
+ 0x0000, # Reserved
157
+ self._next_sequence(), # Sequence
158
+ 0x000E, # Parameter length (14 bytes)
159
+ 0x0000, # Data length (no data for read)
160
+ )
161
+
162
+ # Parameter section (14 bytes)
163
+ parameters = struct.pack(
164
+ ">BBB",
165
+ S7Function.READ_AREA, # Function code
166
+ 0x01, # Item count
167
+ 0x12, # Variable specification
168
+ )
169
+
170
+ # Add address specification
171
+ address_spec = S7DataTypes.encode_address(area, db_number, start, word_len, count)
172
+ parameters += address_spec[1:] # Skip first byte (already included as 0x12)
173
+
174
+ return header + parameters
175
+
176
+ def build_write_request(self, area: S7Area, db_number: int, start: int, word_len: S7WordLen, data: bytes) -> bytes:
177
+ """
178
+ Build S7 write request PDU.
179
+
180
+ Args:
181
+ area: Memory area to write to
182
+ db_number: DB number (for DB area)
183
+ start: Start address/offset
184
+ word_len: Data word length
185
+ data: Data to write
186
+
187
+ Returns:
188
+ Complete S7 PDU
189
+ """
190
+ # Calculate count from data length
191
+ item_size = S7DataTypes.get_size_bytes(word_len, 1)
192
+ count = len(data) // item_size
193
+
194
+ # Parameter length: function + item count + address spec
195
+ param_len = 3 + 11 # 14 bytes total
196
+
197
+ # Data length: transport size + data
198
+ data_len = 4 + len(data) # Transport size (4 bytes) + actual data
199
+
200
+ # S7 Header
201
+ header = struct.pack(
202
+ ">BBHHHH",
203
+ 0x32, # Protocol ID
204
+ S7PDUType.REQUEST, # PDU type
205
+ 0x0000, # Reserved
206
+ self._next_sequence(), # Sequence
207
+ param_len, # Parameter length
208
+ data_len, # Data length
209
+ )
210
+
211
+ # Parameter section
212
+ parameters = struct.pack(
213
+ ">BBB",
214
+ S7Function.WRITE_AREA, # Function code
215
+ 0x01, # Item count
216
+ 0x12, # Variable specification
217
+ )
218
+
219
+ # Add address specification
220
+ address_spec = S7DataTypes.encode_address(area, db_number, start, word_len, count)
221
+ parameters += address_spec[1:] # Skip first byte
222
+
223
+ # Map word_len to data section transport size
224
+ # Data section uses different transport size codes than address specification:
225
+ # - 0x03 = BIT
226
+ # - 0x04 = BYTE/WORD/DWORD (byte-oriented data)
227
+ # - 0x05 = INT
228
+ # - 0x06 = DINT
229
+ # - 0x07 = REAL
230
+ # - 0x09 = OCTET STRING
231
+ transport_size_map = {
232
+ S7WordLen.BIT: 0x03,
233
+ S7WordLen.BYTE: 0x04,
234
+ S7WordLen.CHAR: 0x04,
235
+ S7WordLen.WORD: 0x04,
236
+ S7WordLen.INT: 0x05,
237
+ S7WordLen.DWORD: 0x04,
238
+ S7WordLen.DINT: 0x06,
239
+ S7WordLen.REAL: 0x07,
240
+ S7WordLen.COUNTER: 0x04,
241
+ S7WordLen.TIMER: 0x04,
242
+ }
243
+ transport_size = transport_size_map.get(word_len, 0x04)
244
+
245
+ # Data section
246
+ data_section = (
247
+ struct.pack(
248
+ ">BBH",
249
+ 0x00, # Reserved/Error
250
+ transport_size, # Transport size (proper S7 data section format)
251
+ len(data) * 8, # Bit length (data length in bits)
252
+ )
253
+ + data
254
+ )
255
+
256
+ return header + parameters + data_section
257
+
258
+ def build_setup_communication_request(self, max_amq_caller: int = 1, max_amq_callee: int = 1, pdu_length: int = 480) -> bytes:
259
+ """
260
+ Build S7 setup communication request.
261
+
262
+ This negotiates communication parameters with the PLC.
263
+ """
264
+ header = struct.pack(
265
+ ">BBHHHH",
266
+ 0x32, # Protocol ID
267
+ S7PDUType.REQUEST, # PDU type
268
+ 0x0000, # Reserved
269
+ self._next_sequence(), # Sequence
270
+ 0x0008, # Parameter length (8 bytes)
271
+ 0x0000, # Data length
272
+ )
273
+
274
+ parameters = struct.pack(
275
+ ">BBHHH",
276
+ S7Function.SETUP_COMMUNICATION, # Function code
277
+ 0x00, # Reserved
278
+ max_amq_caller, # Max AMQ caller
279
+ max_amq_callee, # Max AMQ callee
280
+ pdu_length, # PDU length
281
+ )
282
+
283
+ return header + parameters
284
+
285
+ def build_plc_control_request(self, operation: str) -> bytes:
286
+ """
287
+ Build PLC control request.
288
+
289
+ Args:
290
+ operation: Control operation ('stop', 'hot_start', 'cold_start')
291
+
292
+ Returns:
293
+ Complete S7 PDU for PLC control
294
+ """
295
+ # Map operations to S7 control codes
296
+ control_codes = {
297
+ "stop": 0x29, # PLC_STOP
298
+ "hot_start": 0x28, # PLC_CONTROL (warm restart)
299
+ "cold_start": 0x28, # PLC_CONTROL (cold restart)
300
+ }
301
+
302
+ if operation not in control_codes:
303
+ raise ValueError(f"Unknown PLC control operation: {operation}")
304
+
305
+ function_code = control_codes[operation]
306
+
307
+ # Build control-specific parameters
308
+ if operation == "stop":
309
+ # Simple stop command
310
+ param_data = struct.pack(">B", function_code)
311
+ else:
312
+ # Start commands with restart type
313
+ restart_type = 1 if operation == "hot_start" else 2 # 1=warm, 2=cold
314
+ param_data = struct.pack(">BB", function_code, restart_type)
315
+
316
+ header = struct.pack(
317
+ ">BBHHHH",
318
+ 0x32, # Protocol ID
319
+ S7PDUType.REQUEST, # PDU type
320
+ 0x0000, # Reserved
321
+ self._next_sequence(), # Sequence
322
+ len(param_data), # Parameter length
323
+ 0x0000, # Data length
324
+ )
325
+
326
+ return header + param_data
327
+
328
+ def check_control_response(self, response: Dict[str, Any]) -> None:
329
+ """
330
+ Check PLC control response for errors.
331
+
332
+ Args:
333
+ response: Parsed S7 response
334
+
335
+ Raises:
336
+ S7ProtocolError: If control operation failed
337
+ """
338
+ # For now, just check that we got a response
339
+ # In a full implementation, we would check specific error codes
340
+ if response.get("error_code", 0) != 0:
341
+ raise S7ProtocolError(f"PLC control failed with error: {response['error_code']}")
342
+
343
+ def build_compress_request(self) -> bytes:
344
+ """
345
+ Build PLC control request for memory compression.
346
+
347
+ Uses PI service "_MSZL" (compress memory).
348
+
349
+ Returns:
350
+ Complete S7 PDU for compress request
351
+ """
352
+ # PI service command for compress
353
+ pi_service = b"_MSZL"
354
+
355
+ # Parameter section: function code + PI service
356
+ # Format: func(1) + unknown(7) + pi_len(1) + pi_service
357
+ param_data = (
358
+ struct.pack(
359
+ ">BBBBBBBBB",
360
+ S7Function.PLC_CONTROL, # 0x28
361
+ 0x00, # Reserved
362
+ 0x00, # Reserved
363
+ 0x00, # Reserved
364
+ 0x00, # Reserved
365
+ 0x00, # Reserved
366
+ 0x00, # Reserved
367
+ 0x00, # Reserved
368
+ len(pi_service), # PI service length
369
+ )
370
+ + pi_service
371
+ )
372
+
373
+ header = struct.pack(
374
+ ">BBHHHH",
375
+ 0x32, # Protocol ID
376
+ S7PDUType.REQUEST, # PDU type
377
+ 0x0000, # Reserved
378
+ self._next_sequence(), # Sequence
379
+ len(param_data), # Parameter length
380
+ 0x0000, # Data length
381
+ )
382
+
383
+ return header + param_data
384
+
385
+ def build_copy_ram_to_rom_request(self) -> bytes:
386
+ """
387
+ Build PLC control request for copying RAM to ROM.
388
+
389
+ Uses PI service "_MSZL" with file system parameters.
390
+
391
+ Returns:
392
+ Complete S7 PDU for copy RAM to ROM request
393
+ """
394
+ # PI service command for copy RAM to ROM
395
+ # Uses EP parameter for target file system
396
+ pi_service = b"_MSZL"
397
+ file_id = b"P" # P = passive file system (ROM)
398
+
399
+ # Parameter section with file system identifier
400
+ param_data = (
401
+ struct.pack(
402
+ ">BBBBBBBBB",
403
+ S7Function.PLC_CONTROL, # 0x28
404
+ 0x00, # Reserved
405
+ 0x00, # Reserved
406
+ 0x00, # Reserved
407
+ 0x00, # Reserved
408
+ 0x00, # Reserved
409
+ 0x00, # Reserved
410
+ len(file_id), # File ID length
411
+ len(pi_service), # PI service length
412
+ )
413
+ + file_id
414
+ + pi_service
415
+ )
416
+
417
+ header = struct.pack(
418
+ ">BBHHHH",
419
+ 0x32, # Protocol ID
420
+ S7PDUType.REQUEST, # PDU type
421
+ 0x0000, # Reserved
422
+ self._next_sequence(), # Sequence
423
+ len(param_data), # Parameter length
424
+ 0x0000, # Data length
425
+ )
426
+
427
+ return header + param_data
428
+
429
+ # ========================================================================
430
+ # Block Transfer PDU Builders (Upload/Download)
431
+ # ========================================================================
432
+
433
+ def build_start_upload_request(self, block_type: int, block_num: int) -> bytes:
434
+ """
435
+ Build start upload request.
436
+
437
+ Args:
438
+ block_type: Block type code (0x38=OB, 0x41=DB, 0x42=SDB, 0x43=FC, 0x44=SFC, 0x45=FB, 0x46=SFB)
439
+ block_num: Block number
440
+
441
+ Returns:
442
+ Complete S7 PDU for start upload request
443
+ """
444
+ # Block address string: e.g., "0A00001P" for DB1
445
+ # Format: block_type (2 hex) + block_num (5 digits) + file_system (1 char)
446
+ block_addr = f"{block_type:02X}{block_num:05d}A".encode("ascii")
447
+
448
+ # Parameters: function + status + reserved + upload_id + block_addr_len + block_addr
449
+ param_data = (
450
+ struct.pack(
451
+ ">BBBIB",
452
+ S7Function.START_UPLOAD, # Function code
453
+ 0x00, # Status
454
+ 0x00, # Reserved (error code)
455
+ 0x00000000, # Upload ID (0 for start)
456
+ len(block_addr), # Block address length
457
+ )
458
+ + block_addr
459
+ )
460
+
461
+ header = struct.pack(
462
+ ">BBHHHH",
463
+ 0x32, # Protocol ID
464
+ S7PDUType.REQUEST, # PDU type
465
+ 0x0000, # Reserved
466
+ self._next_sequence(), # Sequence
467
+ len(param_data), # Parameter length
468
+ 0x0000, # Data length
469
+ )
470
+
471
+ return header + param_data
472
+
473
+ def build_upload_request(self, upload_id: int) -> bytes:
474
+ """
475
+ Build upload request to get block data.
476
+
477
+ Args:
478
+ upload_id: Upload ID from start upload response
479
+
480
+ Returns:
481
+ Complete S7 PDU for upload request
482
+ """
483
+ param_data = struct.pack(
484
+ ">BBBI",
485
+ S7Function.UPLOAD, # Function code
486
+ 0x00, # Status
487
+ 0x00, # Reserved
488
+ upload_id, # Upload ID
489
+ )
490
+
491
+ header = struct.pack(
492
+ ">BBHHHH",
493
+ 0x32, # Protocol ID
494
+ S7PDUType.REQUEST, # PDU type
495
+ 0x0000, # Reserved
496
+ self._next_sequence(), # Sequence
497
+ len(param_data), # Parameter length
498
+ 0x0000, # Data length
499
+ )
500
+
501
+ return header + param_data
502
+
503
+ def build_end_upload_request(self, upload_id: int) -> bytes:
504
+ """
505
+ Build end upload request.
506
+
507
+ Args:
508
+ upload_id: Upload ID from start upload response
509
+
510
+ Returns:
511
+ Complete S7 PDU for end upload request
512
+ """
513
+ param_data = struct.pack(
514
+ ">BBBI",
515
+ S7Function.END_UPLOAD, # Function code
516
+ 0x00, # Status
517
+ 0x00, # Reserved
518
+ upload_id, # Upload ID
519
+ )
520
+
521
+ header = struct.pack(
522
+ ">BBHHHH",
523
+ 0x32, # Protocol ID
524
+ S7PDUType.REQUEST, # PDU type
525
+ 0x0000, # Reserved
526
+ self._next_sequence(), # Sequence
527
+ len(param_data), # Parameter length
528
+ 0x0000, # Data length
529
+ )
530
+
531
+ return header + param_data
532
+
533
+ def parse_start_upload_response(self, response: Dict[str, Any]) -> Dict[str, Any]:
534
+ """
535
+ Parse start upload response.
536
+
537
+ Returns:
538
+ Dictionary with upload_id and block_length
539
+ """
540
+ result = {"upload_id": 0, "block_length": 0}
541
+
542
+ raw_params = response.get("raw_parameters", b"")
543
+
544
+ if len(raw_params) >= 8:
545
+ # Parse: function + status + reserved + upload_id
546
+ result["upload_id"] = struct.unpack(">I", raw_params[4:8])[0]
547
+ if len(raw_params) > 8:
548
+ # Block length string follows
549
+ len_field = raw_params[8]
550
+ if len(raw_params) > 9 + len_field:
551
+ length_str = raw_params[9 : 9 + len_field]
552
+ try:
553
+ result["block_length"] = int(length_str)
554
+ except ValueError:
555
+ pass
556
+
557
+ return result
558
+
559
+ def parse_upload_response(self, response: Dict[str, Any]) -> bytes:
560
+ """
561
+ Parse upload response and extract block data.
562
+
563
+ Returns:
564
+ Block data bytes
565
+ """
566
+ data_info = response.get("data", {})
567
+ raw_data: bytes = data_info.get("data", b"")
568
+
569
+ # Skip the data header if present (length + unknown bytes)
570
+ if len(raw_data) > 2:
571
+ return raw_data
572
+ return b""
573
+
574
+ def build_download_request(self, block_type: int, block_num: int, block_data: bytes) -> bytes:
575
+ """
576
+ Build request download request.
577
+
578
+ Args:
579
+ block_type: Block type code
580
+ block_num: Block number
581
+ block_data: Block data to download
582
+
583
+ Returns:
584
+ Complete S7 PDU for request download
585
+ """
586
+ # Block address string
587
+ block_addr = f"{block_type:02X}{block_num:05d}P".encode("ascii")
588
+
589
+ # Block length as string
590
+ length_str = f"{len(block_data):06d}".encode("ascii")
591
+
592
+ # Parameters
593
+ param_data = (
594
+ struct.pack(
595
+ ">BBBBB",
596
+ S7Function.REQUEST_DOWNLOAD, # Function code
597
+ 0x00, # Status
598
+ 0x00, # Reserved
599
+ 0x00, # Reserved
600
+ len(block_addr), # Block address length
601
+ )
602
+ + block_addr
603
+ + struct.pack(">B", len(length_str))
604
+ + length_str
605
+ )
606
+
607
+ header = struct.pack(
608
+ ">BBHHHH",
609
+ 0x32, # Protocol ID
610
+ S7PDUType.REQUEST, # PDU type
611
+ 0x0000, # Reserved
612
+ self._next_sequence(), # Sequence
613
+ len(param_data), # Parameter length
614
+ 0x0000, # Data length
615
+ )
616
+
617
+ return header + param_data
618
+
619
+ def build_delete_block_request(self, block_type: int, block_num: int) -> bytes:
620
+ """
621
+ Build delete block request.
622
+
623
+ Uses PLC_CONTROL with PI service "_DELE" for block deletion.
624
+
625
+ Args:
626
+ block_type: Block type code
627
+ block_num: Block number
628
+
629
+ Returns:
630
+ Complete S7 PDU for delete block request
631
+ """
632
+ # PI service for delete
633
+ pi_service = b"_DELE"
634
+
635
+ # Block specification: type + number + filesystem
636
+ block_spec = f"{block_type:02X}{block_num:05d}P".encode("ascii")
637
+
638
+ # Parameter section
639
+ param_data = (
640
+ struct.pack(
641
+ ">BBBBBBBBB",
642
+ S7Function.PLC_CONTROL, # 0x28
643
+ 0x00, # Reserved
644
+ 0x00, # Reserved
645
+ 0x00, # Reserved
646
+ 0x00, # Reserved
647
+ 0x00, # Reserved
648
+ len(block_spec), # Block spec length
649
+ len(pi_service), # PI service length
650
+ 0x00, # Reserved
651
+ )
652
+ + block_spec
653
+ + pi_service
654
+ )
655
+
656
+ header = struct.pack(
657
+ ">BBHHHH",
658
+ 0x32, # Protocol ID
659
+ S7PDUType.REQUEST, # PDU type
660
+ 0x0000, # Reserved
661
+ self._next_sequence(), # Sequence
662
+ len(param_data), # Parameter length
663
+ 0x0000, # Data length
664
+ )
665
+
666
+ return header + param_data
667
+
668
+ # ========================================================================
669
+ # USER_DATA PDU Builders (Chunk 3 of protocol implementation)
670
+ # ========================================================================
671
+
672
+ def build_list_blocks_request(self) -> bytes:
673
+ """
674
+ Build USER_DATA request for listing all blocks.
675
+
676
+ Returns:
677
+ Complete S7 PDU for list blocks request
678
+ """
679
+ # USER_DATA PDU format:
680
+ # - S7 header (10 bytes)
681
+ # - Parameter section (8 bytes for USER_DATA)
682
+ # - Data section (4 bytes for list blocks)
683
+
684
+ # Parameter section for USER_DATA request
685
+ # Format: header + method + type|group + subfunction + seq
686
+ param_data = struct.pack(
687
+ ">BBBBBBBB",
688
+ 0x00, # Reserved
689
+ 0x01, # Parameter count
690
+ 0x12, # Type/length header
691
+ 0x04, # Length of following data
692
+ 0x11, # Method (0x11 = request)
693
+ 0x43, # Type (4=request) | Group (3=grBlocksInfo)
694
+ S7UserDataSubfunction.LIST_ALL, # Subfunction (0x01 = list all)
695
+ 0x00, # DataRef (0x00 for initial request)
696
+ )
697
+
698
+ # Data section: return code placeholder
699
+ data_section = struct.pack(
700
+ ">BBH",
701
+ 0x0A, # Return value (request)
702
+ 0x00, # Transport size
703
+ 0x0000, # Length (0 for request)
704
+ )
705
+
706
+ # S7 header for USER_DATA
707
+ header = struct.pack(
708
+ ">BBHHHH",
709
+ 0x32, # Protocol ID
710
+ S7PDUType.USERDATA, # PDU type (0x07)
711
+ 0x0000, # Reserved
712
+ self._next_sequence(), # Sequence
713
+ len(param_data), # Parameter length
714
+ len(data_section), # Data length
715
+ )
716
+
717
+ return header + param_data + data_section
718
+
719
+ def build_list_blocks_of_type_request(self, block_type: int) -> bytes:
720
+ """
721
+ Build USER_DATA request for listing blocks of a specific type.
722
+
723
+ Args:
724
+ block_type: Block type code (e.g., 0x41 for DB)
725
+
726
+ Returns:
727
+ Complete S7 PDU for list blocks of type request
728
+ """
729
+ # Parameter section for USER_DATA request
730
+ param_data = struct.pack(
731
+ ">BBBBBBBB",
732
+ 0x00, # Reserved
733
+ 0x01, # Parameter count
734
+ 0x12, # Type/length header
735
+ 0x04, # Length of following data
736
+ 0x11, # Method (0x11 = request)
737
+ 0x43, # Type (4=request) | Group (3=grBlocksInfo)
738
+ S7UserDataSubfunction.LIST_BLOCKS_OF_TYPE, # Subfunction (0x02)
739
+ 0x00, # DataRef (0x00 for initial request)
740
+ )
741
+
742
+ # Data section: block type (0x30 prefix + type per Snap7 C format)
743
+ data_section = struct.pack(
744
+ ">BBHBBBB",
745
+ 0x0A, # Return value (request)
746
+ 0x00, # Transport size
747
+ 0x0004, # Length (4 bytes)
748
+ 0x30, # Block type indicator
749
+ block_type, # Block type code
750
+ 0x0A, # Trailing bytes per Snap7 C
751
+ 0x00,
752
+ )
753
+
754
+ # S7 header for USER_DATA
755
+ header = struct.pack(
756
+ ">BBHHHH",
757
+ 0x32, # Protocol ID
758
+ S7PDUType.USERDATA, # PDU type (0x07)
759
+ 0x0000, # Reserved
760
+ self._next_sequence(), # Sequence
761
+ len(param_data), # Parameter length
762
+ len(data_section), # Data length
763
+ )
764
+
765
+ return header + param_data + data_section
766
+
767
+ def parse_list_blocks_response(self, response: Dict[str, Any]) -> Dict[str, int]:
768
+ """
769
+ Parse list blocks response and extract block counts.
770
+
771
+ Args:
772
+ response: Parsed S7 response
773
+
774
+ Returns:
775
+ Dictionary mapping block type names to counts
776
+ """
777
+ result = {
778
+ "OBCount": 0,
779
+ "FBCount": 0,
780
+ "FCCount": 0,
781
+ "SFBCount": 0,
782
+ "SFCCount": 0,
783
+ "DBCount": 0,
784
+ "SDBCount": 0,
785
+ }
786
+
787
+ data_info = response.get("data", {})
788
+ raw_data = data_info.get("data", b"")
789
+
790
+ if not raw_data:
791
+ return result
792
+
793
+ # Parse block entries (4 bytes each: 0x30 | type | count_hi | count_lo)
794
+ # Block type codes
795
+ type_to_name = {
796
+ 0x38: "OBCount", # Organization Block
797
+ 0x41: "DBCount", # Data Block
798
+ 0x42: "SDBCount", # System Data Block
799
+ 0x43: "FCCount", # Function
800
+ 0x44: "SFCCount", # System Function
801
+ 0x45: "FBCount", # Function Block
802
+ 0x46: "SFBCount", # System Function Block
803
+ }
804
+
805
+ offset = 0
806
+ while offset + 4 <= len(raw_data):
807
+ indicator = raw_data[offset]
808
+ block_type = raw_data[offset + 1]
809
+ count = struct.unpack(">H", raw_data[offset + 2 : offset + 4])[0]
810
+
811
+ if indicator == 0x30 and block_type in type_to_name:
812
+ result[type_to_name[block_type]] = count
813
+
814
+ offset += 4
815
+
816
+ return result
817
+
818
+ def parse_list_blocks_of_type_response(self, response: Dict[str, Any]) -> List[int]:
819
+ """
820
+ Parse list blocks of type response and extract block numbers.
821
+
822
+ Args:
823
+ response: Parsed S7 response
824
+
825
+ Returns:
826
+ List of block numbers
827
+ """
828
+ result: List[int] = []
829
+
830
+ data_info = response.get("data", {})
831
+ raw_data = data_info.get("data", b"")
832
+
833
+ if not raw_data:
834
+ return result
835
+
836
+ # Parse block entries (4 bytes each per TDataFunGetBotItem:
837
+ # BlockNum(2) + Unknown(1) + BlockLang(1))
838
+ offset = 0
839
+ while offset + 4 <= len(raw_data):
840
+ block_num = struct.unpack(">H", raw_data[offset : offset + 2])[0]
841
+ result.append(block_num)
842
+ offset += 4
843
+
844
+ return result
845
+
846
+ def build_get_block_info_request(self, block_type: int, block_num: int) -> bytes:
847
+ """
848
+ Build USER_DATA request for getting block information.
849
+
850
+ Args:
851
+ block_type: Block type code (0x38=OB, 0x41=DB, 0x42=SDB, 0x43=FC, 0x44=SFC, 0x45=FB, 0x46=SFB)
852
+ block_num: Block number
853
+
854
+ Returns:
855
+ Complete S7 PDU for get block info request
856
+ """
857
+ # Parameter section for USER_DATA block info request
858
+ param_data = struct.pack(
859
+ ">BBBBBBBB",
860
+ 0x00, # Reserved
861
+ 0x01, # Parameter count
862
+ 0x12, # Type/length header
863
+ 0x04, # Length of following data
864
+ 0x11, # Method (0x11 = request)
865
+ 0x43, # Type (4=request) | Group (3=grBlocksInfo)
866
+ S7UserDataSubfunction.BLOCK_INFO, # Subfunction (0x03)
867
+ 0x00, # DataRef (0x00 for initial request)
868
+ )
869
+
870
+ # Data section: [0x30, type, 'A', ASCII_num(5)] per Snap7 C format
871
+ # Block number is 5-digit zero-padded ASCII (e.g., 1 -> "00001")
872
+ block_num_ascii = f"{block_num:05d}".encode("ascii")
873
+ data_payload = struct.pack(">BB", 0x30, block_type) + b"A" + block_num_ascii
874
+ data_section = (
875
+ struct.pack(
876
+ ">BBH",
877
+ 0x0A, # Return value (request)
878
+ 0x00, # Transport size
879
+ len(data_payload), # Length
880
+ )
881
+ + data_payload
882
+ )
883
+
884
+ # S7 header for USER_DATA
885
+ header = struct.pack(
886
+ ">BBHHHH",
887
+ 0x32, # Protocol ID
888
+ S7PDUType.USERDATA, # PDU type (0x07)
889
+ 0x0000, # Reserved
890
+ self._next_sequence(), # Sequence
891
+ len(param_data), # Parameter length
892
+ len(data_section), # Data length
893
+ )
894
+
895
+ return header + param_data + data_section
896
+
897
+ def parse_get_block_info_response(self, response: Dict[str, Any]) -> Dict[str, Any]:
898
+ """
899
+ Parse get block info response.
900
+
901
+ Args:
902
+ response: Parsed S7 response
903
+
904
+ Returns:
905
+ Dictionary with block info fields
906
+ """
907
+ result: Dict[str, Any] = {
908
+ "block_type": 0,
909
+ "block_number": 0,
910
+ "block_lang": 0,
911
+ "block_flags": 0,
912
+ "mc7_size": 0,
913
+ "load_size": 0,
914
+ "local_data": 0,
915
+ "sbb_length": 0,
916
+ "checksum": 0,
917
+ "version": 0,
918
+ "code_date": b"",
919
+ "intf_date": b"",
920
+ "author": b"",
921
+ "family": b"",
922
+ "header": b"",
923
+ }
924
+
925
+ data_info = response.get("data", {})
926
+ raw_data = data_info.get("data", b"")
927
+
928
+ if len(raw_data) < 78:
929
+ return result
930
+
931
+ # Parse block info structure per TResDataBlockInfo layout
932
+ result["block_type"] = raw_data[1]
933
+ result["block_flags"] = raw_data[9]
934
+ result["block_lang"] = raw_data[10]
935
+ result["block_number"] = struct.unpack(">H", raw_data[12:14])[0]
936
+ result["load_size"] = struct.unpack(">I", raw_data[14:18])[0]
937
+ result["sbb_length"] = struct.unpack(">H", raw_data[34:36])[0]
938
+ result["local_data"] = struct.unpack(">H", raw_data[38:40])[0]
939
+ result["mc7_size"] = struct.unpack(">H", raw_data[40:42])[0]
940
+ result["version"] = raw_data[66]
941
+ result["checksum"] = struct.unpack(">H", raw_data[68:70])[0]
942
+
943
+ # Dates (6 bytes each: ms(2) + days(2) + reserved(2))
944
+ result["code_date"] = raw_data[22:28]
945
+ result["intf_date"] = raw_data[28:34]
946
+
947
+ # Strings (8 bytes each)
948
+ result["author"] = raw_data[42:50]
949
+ result["family"] = raw_data[50:58]
950
+ result["header"] = raw_data[58:66]
951
+
952
+ return result
953
+
954
+ def build_read_szl_request(self, szl_id: int, szl_index: int) -> bytes:
955
+ """
956
+ Build USER_DATA request for reading SZL (System Status List).
957
+
958
+ Args:
959
+ szl_id: SZL identifier
960
+ szl_index: SZL index
961
+
962
+ Returns:
963
+ Complete S7 PDU for read SZL request
964
+ """
965
+ # Parameter section for USER_DATA SZL request
966
+ param_data = struct.pack(
967
+ ">BBBBBBBB",
968
+ 0x00, # Reserved
969
+ 0x01, # Parameter count
970
+ 0x12, # Type/length header
971
+ 0x04, # Length of following data
972
+ 0x11, # Method (0x11 = request)
973
+ 0x44, # Type (4=request) | Group (4=grSZL)
974
+ S7UserDataSubfunction.READ_SZL, # Subfunction (0x01)
975
+ 0x00, # DataRef (0x00 for initial request)
976
+ )
977
+
978
+ # Data section: SZL ID and Index
979
+ data_section = struct.pack(
980
+ ">BBHHH",
981
+ 0x0A, # Return value (request)
982
+ 0x00, # Transport size
983
+ 0x0004, # Length (4 bytes for ID + Index)
984
+ szl_id, # SZL ID
985
+ szl_index, # SZL Index
986
+ )
987
+
988
+ # S7 header for USER_DATA
989
+ header = struct.pack(
990
+ ">BBHHHH",
991
+ 0x32, # Protocol ID
992
+ S7PDUType.USERDATA, # PDU type (0x07)
993
+ 0x0000, # Reserved
994
+ self._next_sequence(), # Sequence
995
+ len(param_data), # Parameter length
996
+ len(data_section), # Data length
997
+ )
998
+
999
+ return header + param_data + data_section
1000
+
1001
+ def build_userdata_followup_request(self, group: int, subfunction: int, sequence_number: int) -> bytes:
1002
+ """
1003
+ Build USERDATA follow-up request for multi-packet responses.
1004
+
1005
+ Args:
1006
+ group: USERDATA group (e.g., 4 for SZL, 3 for block info)
1007
+ subfunction: Subfunction code
1008
+ sequence_number: Sequence number from the previous response
1009
+
1010
+ Returns:
1011
+ Complete S7 PDU for follow-up request
1012
+ """
1013
+ # Parameter section: same as initial but with DataRef = sequence_number
1014
+ type_group = 0x40 | (group & 0x0F) # Type 4 (request) | group
1015
+ param_data = struct.pack(
1016
+ ">BBBBBBBB",
1017
+ 0x00, # Reserved
1018
+ 0x01, # Parameter count
1019
+ 0x12, # Type/length header
1020
+ 0x04, # Length of following data
1021
+ 0x11, # Method (0x11 = request)
1022
+ type_group, # Type | Group
1023
+ subfunction, # Subfunction
1024
+ sequence_number, # DataRef from previous response
1025
+ )
1026
+
1027
+ # Minimal data section for follow-up
1028
+ data_section = struct.pack(
1029
+ ">BBH",
1030
+ 0x0A, # Return value (request)
1031
+ 0x00, # Transport size
1032
+ 0x0000, # Length (0 bytes)
1033
+ )
1034
+
1035
+ header = struct.pack(
1036
+ ">BBHHHH",
1037
+ 0x32, # Protocol ID
1038
+ S7PDUType.USERDATA, # PDU type (0x07)
1039
+ 0x0000, # Reserved
1040
+ self._next_sequence(), # Sequence
1041
+ len(param_data), # Parameter length
1042
+ len(data_section), # Data length
1043
+ )
1044
+
1045
+ return header + param_data + data_section
1046
+
1047
+ def parse_read_szl_response(self, response: Dict[str, Any], first_fragment: bool = True) -> Dict[str, Any]:
1048
+ """
1049
+ Parse read SZL response.
1050
+
1051
+ Args:
1052
+ response: Parsed S7 response
1053
+ first_fragment: If True (default), parse SZL header (ID+Index).
1054
+ If False, treat all data as raw payload (follow-up fragments).
1055
+
1056
+ Returns:
1057
+ Dictionary with SZL ID, Index, and data
1058
+ """
1059
+ result: Dict[str, Any] = {
1060
+ "szl_id": 0,
1061
+ "szl_index": 0,
1062
+ "data": b"",
1063
+ }
1064
+
1065
+ data_info = response.get("data", {})
1066
+ raw_data = data_info.get("data", b"")
1067
+
1068
+ if first_fragment:
1069
+ if len(raw_data) < 4:
1070
+ return result
1071
+
1072
+ # Parse SZL header: ID (2) + Index (2)
1073
+ result["szl_id"] = struct.unpack(">H", raw_data[0:2])[0]
1074
+ result["szl_index"] = struct.unpack(">H", raw_data[2:4])[0]
1075
+ result["data"] = raw_data[4:]
1076
+ else:
1077
+ # Follow-up fragments don't include SZL header
1078
+ result["data"] = raw_data
1079
+
1080
+ return result
1081
+
1082
+ def build_get_clock_request(self) -> bytes:
1083
+ """
1084
+ Build USER_DATA request for reading PLC clock.
1085
+
1086
+ Returns:
1087
+ Complete S7 PDU for get clock request
1088
+ """
1089
+ # Parameter section for USER_DATA clock request
1090
+ param_data = struct.pack(
1091
+ ">BBBBBBBB",
1092
+ 0x00, # Reserved
1093
+ 0x01, # Parameter count
1094
+ 0x12, # Type/length header
1095
+ 0x04, # Length of following data
1096
+ 0x11, # Method (0x11 = request)
1097
+ 0x47, # Type (4=request) | Group (7=grClock)
1098
+ S7UserDataSubfunction.GET_CLOCK, # Subfunction (0x01)
1099
+ 0x00, # DataRef (0x00 for initial request)
1100
+ )
1101
+
1102
+ # Data section: empty for get clock
1103
+ data_section = struct.pack(
1104
+ ">BBH",
1105
+ 0x0A, # Return value (request)
1106
+ 0x00, # Transport size
1107
+ 0x0000, # Length (0 bytes)
1108
+ )
1109
+
1110
+ # S7 header for USER_DATA
1111
+ header = struct.pack(
1112
+ ">BBHHHH",
1113
+ 0x32, # Protocol ID
1114
+ S7PDUType.USERDATA, # PDU type (0x07)
1115
+ 0x0000, # Reserved
1116
+ self._next_sequence(), # Sequence
1117
+ len(param_data), # Parameter length
1118
+ len(data_section), # Data length
1119
+ )
1120
+
1121
+ return header + param_data + data_section
1122
+
1123
+ def build_set_clock_request(self, dt: "datetime") -> bytes:
1124
+ """
1125
+ Build USER_DATA request for setting PLC clock.
1126
+
1127
+ Args:
1128
+ dt: Datetime to set
1129
+
1130
+ Returns:
1131
+ Complete S7 PDU for set clock request
1132
+ """
1133
+
1134
+ # Convert datetime to BCD format
1135
+ # BCD encoding: each decimal digit is stored in a nibble
1136
+ def to_bcd(value: int) -> int:
1137
+ return ((value // 10) << 4) | (value % 10)
1138
+
1139
+ year = dt.year % 100 # Only last 2 digits
1140
+ bcd_time = struct.pack(
1141
+ ">BBBBBBBB",
1142
+ 0x00, # Reserved
1143
+ to_bcd(year), # Year (BCD)
1144
+ to_bcd(dt.month), # Month (BCD)
1145
+ to_bcd(dt.day), # Day (BCD)
1146
+ to_bcd(dt.hour), # Hour (BCD)
1147
+ to_bcd(dt.minute), # Minute (BCD)
1148
+ to_bcd(dt.second), # Second (BCD)
1149
+ (dt.weekday() + 1) & 0x0F, # Day of week (1=Monday)
1150
+ )
1151
+
1152
+ # Parameter section for USER_DATA clock request
1153
+ param_data = struct.pack(
1154
+ ">BBBBBBBB",
1155
+ 0x00, # Reserved
1156
+ 0x01, # Parameter count
1157
+ 0x12, # Type/length header
1158
+ 0x04, # Length of following data
1159
+ 0x11, # Method (0x11 = request)
1160
+ 0x47, # Type (4=request) | Group (7=grClock)
1161
+ S7UserDataSubfunction.SET_CLOCK, # Subfunction (0x02)
1162
+ 0x00, # DataRef (0x00 for initial request)
1163
+ )
1164
+
1165
+ # Data section with BCD time
1166
+ data_section = (
1167
+ struct.pack(
1168
+ ">BBH",
1169
+ 0x0A, # Return value (request)
1170
+ 0x00, # Transport size
1171
+ len(bcd_time), # Length
1172
+ )
1173
+ + bcd_time
1174
+ )
1175
+
1176
+ # S7 header for USER_DATA
1177
+ header = struct.pack(
1178
+ ">BBHHHH",
1179
+ 0x32, # Protocol ID
1180
+ S7PDUType.USERDATA, # PDU type (0x07)
1181
+ 0x0000, # Reserved
1182
+ self._next_sequence(), # Sequence
1183
+ len(param_data), # Parameter length
1184
+ len(data_section), # Data length
1185
+ )
1186
+
1187
+ return header + param_data + data_section
1188
+
1189
+ def parse_get_clock_response(self, response: Dict[str, Any]) -> "datetime":
1190
+ """
1191
+ Parse get clock response.
1192
+
1193
+ Args:
1194
+ response: Parsed S7 response
1195
+
1196
+ Returns:
1197
+ Datetime from PLC
1198
+ """
1199
+ from datetime import datetime as dt_class
1200
+
1201
+ data_info = response.get("data", {})
1202
+ raw_data = data_info.get("data", b"")
1203
+
1204
+ if len(raw_data) < 8:
1205
+ # Return current time if no valid data
1206
+ return dt_class.now().replace(microsecond=0)
1207
+
1208
+ # Parse BCD time
1209
+ def from_bcd(value: int) -> int:
1210
+ return ((value >> 4) * 10) + (value & 0x0F)
1211
+
1212
+ # Skip first byte (reserved)
1213
+ year = from_bcd(raw_data[1])
1214
+ month = from_bcd(raw_data[2])
1215
+ day = from_bcd(raw_data[3])
1216
+ hour = from_bcd(raw_data[4])
1217
+ minute = from_bcd(raw_data[5])
1218
+ second = from_bcd(raw_data[6])
1219
+
1220
+ # Determine century (assume 2000s for years 0-99)
1221
+ full_year = 2000 + year if year < 90 else 1900 + year
1222
+
1223
+ try:
1224
+ return dt_class(full_year, month, day, hour, minute, second)
1225
+ except ValueError:
1226
+ return dt_class.now().replace(microsecond=0)
1227
+
1228
+ def build_cpu_state_request(self) -> bytes:
1229
+ """
1230
+ Build CPU state request.
1231
+
1232
+ Returns:
1233
+ Complete S7 PDU for CPU state query
1234
+ """
1235
+ # Simple CPU state request - in real S7 this would be a userdata function
1236
+ header = struct.pack(
1237
+ ">BBHHHH",
1238
+ 0x32, # Protocol ID
1239
+ S7PDUType.REQUEST, # PDU type
1240
+ 0x0000, # Reserved
1241
+ self._next_sequence(), # Sequence
1242
+ 0x0001, # Parameter length
1243
+ 0x0000, # Data length
1244
+ )
1245
+
1246
+ # Use a custom function code for CPU state
1247
+ parameters = struct.pack(">B", 0x04) # Use READ_AREA function for simplicity
1248
+
1249
+ return header + parameters
1250
+
1251
+ def extract_cpu_state(self, response: Dict[str, Any]) -> str:
1252
+ """
1253
+ Extract CPU state from response.
1254
+
1255
+ Args:
1256
+ response: Parsed S7 response
1257
+
1258
+ Returns:
1259
+ CPU state string in S7CpuStatus format (e.g., 'S7CpuStatusRun')
1260
+ """
1261
+ # Map internal states to S7 status format for API compatibility with master branch
1262
+ # The cpu_statuses dict in type.py uses: {0: "S7CpuStatusUnknown", 4: "S7CpuStatusStop", 8: "S7CpuStatusRun"}
1263
+ return "S7CpuStatusRun" # Default state for pure Python server
1264
+
1265
+ def parse_response(self, pdu: bytes) -> Dict[str, Any]:
1266
+ """
1267
+ Parse S7 response PDU.
1268
+
1269
+ Args:
1270
+ pdu: Complete S7 PDU
1271
+
1272
+ Returns:
1273
+ Parsed response data
1274
+ """
1275
+ if len(pdu) < 10:
1276
+ raise S7ProtocolError("PDU too short for S7 response header")
1277
+
1278
+ # First peek at PDU type to determine header size
1279
+ pdu_type = pdu[1]
1280
+
1281
+ if pdu_type == S7PDUType.USERDATA:
1282
+ # USERDATA PDUs have a 10-byte header (no error_class/error_code in header)
1283
+ if len(pdu) < 10:
1284
+ raise S7ProtocolError("PDU too short for USERDATA header")
1285
+ header = struct.unpack(">BBHHHH", pdu[:10])
1286
+ protocol_id, pdu_type, reserved, sequence, param_len, data_len = header
1287
+ error_class = 0
1288
+ error_code = 0
1289
+ offset = 10
1290
+ else:
1291
+ # ACK/ACK_DATA PDUs have a 12-byte header (with error_class/error_code)
1292
+ if len(pdu) < 12:
1293
+ raise S7ProtocolError("PDU too short for ACK/ACK_DATA header")
1294
+ header = struct.unpack(">BBHHHHBB", pdu[:12])
1295
+ protocol_id, pdu_type, reserved, sequence, param_len, data_len, error_class, error_code = header
1296
+ offset = 12
1297
+
1298
+ if protocol_id != 0x32:
1299
+ raise S7ProtocolError(f"Invalid protocol ID: {protocol_id:#02x}")
1300
+
1301
+ # Accept ACK (write responses), ACK_DATA (read responses), and USERDATA response types
1302
+ if pdu_type not in (S7PDUType.ACK, S7PDUType.ACK_DATA, S7PDUType.USERDATA):
1303
+ raise S7ProtocolError(f"Expected response PDU, got {pdu_type}")
1304
+
1305
+ combined_error = (error_class << 8) | error_code
1306
+ if error_class != 0:
1307
+ error_msg = get_protocol_error_message(combined_error)
1308
+ raise S7ProtocolError(
1309
+ f"S7 protocol error (class={error_class:#04x}, code={error_code:#04x}): {error_msg}",
1310
+ error_code=combined_error,
1311
+ )
1312
+
1313
+ response = {
1314
+ "sequence": sequence,
1315
+ "param_length": param_len,
1316
+ "data_length": data_len,
1317
+ "parameters": None,
1318
+ "data": None,
1319
+ "error_code": combined_error,
1320
+ }
1321
+
1322
+ # Parse parameters if present
1323
+ if param_len > 0:
1324
+ if offset + param_len > len(pdu):
1325
+ raise S7ProtocolError("Parameter section extends beyond PDU")
1326
+
1327
+ param_data = pdu[offset : offset + param_len]
1328
+ response["parameters"] = self._parse_parameters(param_data)
1329
+ offset += param_len
1330
+
1331
+ # Parse data if present
1332
+ if data_len > 0:
1333
+ if offset + data_len > len(pdu):
1334
+ raise S7ProtocolError("Data section extends beyond PDU")
1335
+
1336
+ data_section = pdu[offset : offset + data_len]
1337
+ response["data"] = self._parse_data_section(data_section)
1338
+
1339
+ return response
1340
+
1341
+ def _parse_parameters(self, param_data: bytes) -> Dict[str, Any]:
1342
+ """Parse S7 parameter section."""
1343
+ if len(param_data) < 1:
1344
+ return {}
1345
+
1346
+ # Detect USERDATA response parameters:
1347
+ # byte 0 = 0x00 (reserved), len >= 12, byte 2 = 0x12, byte 4 = 0x12 (method=response)
1348
+ if param_data[0] == 0x00 and len(param_data) >= 12 and param_data[2] == 0x12 and param_data[4] == 0x12:
1349
+ return self._parse_userdata_response_params(param_data)
1350
+
1351
+ function_code = param_data[0]
1352
+
1353
+ if function_code == S7Function.READ_AREA:
1354
+ return self._parse_read_response_params(param_data)
1355
+ elif function_code == S7Function.WRITE_AREA:
1356
+ return self._parse_write_response_params(param_data)
1357
+ elif function_code == S7Function.SETUP_COMMUNICATION:
1358
+ return self._parse_setup_comm_response_params(param_data)
1359
+ else:
1360
+ return {"function_code": function_code}
1361
+
1362
+ def _parse_read_response_params(self, param_data: bytes) -> Dict[str, Any]:
1363
+ """Parse read area response parameters."""
1364
+ if len(param_data) < 2:
1365
+ raise S7ProtocolError("Read response parameters too short")
1366
+
1367
+ function_code = param_data[0]
1368
+ item_count = param_data[1]
1369
+
1370
+ return {"function_code": function_code, "item_count": item_count}
1371
+
1372
+ def _parse_write_response_params(self, param_data: bytes) -> Dict[str, Any]:
1373
+ """Parse write area response parameters."""
1374
+ if len(param_data) < 2:
1375
+ raise S7ProtocolError("Write response parameters too short")
1376
+
1377
+ function_code = param_data[0]
1378
+ item_count = param_data[1]
1379
+
1380
+ return {"function_code": function_code, "item_count": item_count}
1381
+
1382
+ def _parse_setup_comm_response_params(self, param_data: bytes) -> Dict[str, Any]:
1383
+ """Parse setup communication response parameters."""
1384
+ if len(param_data) < 8:
1385
+ raise S7ProtocolError("Setup communication response parameters too short")
1386
+
1387
+ function_code, reserved, max_amq_caller, max_amq_callee, pdu_length = struct.unpack(">BBHHH", param_data[:8])
1388
+
1389
+ return {
1390
+ "function_code": function_code,
1391
+ "max_amq_caller": max_amq_caller,
1392
+ "max_amq_callee": max_amq_callee,
1393
+ "pdu_length": pdu_length,
1394
+ }
1395
+
1396
+ def _parse_userdata_response_params(self, param_data: bytes) -> Dict[str, Any]:
1397
+ """Parse USERDATA response parameter section (12 bytes).
1398
+
1399
+ Layout:
1400
+ [0] Reserved (0x00)
1401
+ [1] Parameter count (0x01)
1402
+ [2] Type header (0x12)
1403
+ [3] Length of following data (0x08 for response)
1404
+ [4] Method (0x12 = response)
1405
+ [5] Type (high nibble) | Group (low nibble)
1406
+ [6] Subfunction
1407
+ [7] Sequence number (used as DataRef in follow-up)
1408
+ [8] Data unit reference
1409
+ [9] Last data unit (0x00 = last, non-zero = more)
1410
+ [10-11] Error code
1411
+ """
1412
+ type_group = param_data[5]
1413
+ group = type_group & 0x0F
1414
+ subfunction = param_data[6]
1415
+ sequence_number = param_data[7]
1416
+ last_data_unit = param_data[9]
1417
+ error_code = struct.unpack(">H", param_data[10:12])[0]
1418
+
1419
+ if error_code != 0:
1420
+ error_msg = get_protocol_error_message(error_code)
1421
+ logger.warning(f"USERDATA response error {error_code:#06x}: {error_msg}")
1422
+
1423
+ return {
1424
+ "group": group,
1425
+ "subfunction": subfunction,
1426
+ "sequence_number": sequence_number,
1427
+ "last_data_unit": last_data_unit,
1428
+ "error_code": error_code,
1429
+ }
1430
+
1431
+ def _parse_data_section(self, data_section: bytes) -> Dict[str, Any]:
1432
+ """Parse S7 data section."""
1433
+ if len(data_section) == 1:
1434
+ # Simple return code (for write responses)
1435
+ return {"return_code": data_section[0], "transport_size": 0, "data_length": 0, "data": b""}
1436
+ elif len(data_section) >= 4:
1437
+ # Full data header
1438
+ return_code = data_section[0]
1439
+ transport_size = data_section[1]
1440
+ data_length = struct.unpack(">H", data_section[2:4])[0]
1441
+
1442
+ # Extract actual data - length interpretation depends on transport_size
1443
+ # Transport size 0x09 (octet string): byte length (USERDATA responses)
1444
+ # Transport size 0x00: byte length (USERDATA requests)
1445
+ # Transport size 0x04 (byte): bit length (READ_AREA responses)
1446
+ if transport_size in (0x00, 0x09):
1447
+ # USERDATA uses byte length directly
1448
+ actual_data = data_section[4 : 4 + data_length]
1449
+ else:
1450
+ # READ_AREA responses use bit length
1451
+ actual_data = data_section[4 : 4 + (data_length // 8)]
1452
+
1453
+ return {"return_code": return_code, "transport_size": transport_size, "data_length": data_length, "data": actual_data}
1454
+ else:
1455
+ return {"raw_data": data_section}
1456
+
1457
+ def extract_read_data(self, response: Dict[str, Any], word_len: S7WordLen, count: int) -> List[Any]:
1458
+ """
1459
+ Extract and decode data from read response.
1460
+
1461
+ Args:
1462
+ response: Parsed S7 response
1463
+ word_len: Expected data word length
1464
+ count: Expected number of items
1465
+
1466
+ Returns:
1467
+ List of decoded values
1468
+ """
1469
+ if not response.get("data"):
1470
+ raise S7ProtocolError("No data in response")
1471
+
1472
+ data_info = response["data"]
1473
+ return_code = data_info.get("return_code", 0)
1474
+
1475
+ if return_code != 0xFF: # 0xFF = Success
1476
+ desc = get_return_code_description(return_code)
1477
+ raise S7ProtocolError(f"Read operation failed: {desc} (0x{return_code:02x})")
1478
+
1479
+ raw_data = data_info.get("data", b"")
1480
+
1481
+ # Return raw bytes directly - caller handles type conversion
1482
+ return list(raw_data)
1483
+
1484
+ def check_write_response(self, response: Dict[str, Any]) -> None:
1485
+ """
1486
+ Check write operation response for errors.
1487
+
1488
+ Args:
1489
+ response: Parsed S7 response
1490
+
1491
+ Raises:
1492
+ S7ProtocolError: If write operation failed
1493
+ """
1494
+ # First check for errors in the response header
1495
+ # S7-1200/1500 returns error codes in the header for write failures
1496
+ header_error = response.get("error_code", 0)
1497
+ if header_error != 0:
1498
+ error_msg = f"Write operation failed with S7 error code: {header_error:#06x}"
1499
+ raise S7ProtocolError(error_msg)
1500
+
1501
+ # For successful writes, check the data section return code if present
1502
+ if response.get("data"):
1503
+ data_info = response["data"]
1504
+ return_code = data_info.get("return_code", 0xFF) # Default to success
1505
+
1506
+ if return_code != 0xFF: # 0xFF = Success
1507
+ desc = get_return_code_description(return_code)
1508
+ raise S7ProtocolError(f"Write operation failed: {desc} (0x{return_code:02x})")
1509
+ # If no data and no header error, the write was successful (ACK without data)