profinet-py 0.4.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.
profinet/blocks.py ADDED
@@ -0,0 +1,1111 @@
1
+ """
2
+ PROFINET Block Parsing Module.
3
+
4
+ Provides data classes and parsing functions for PROFINET block structures
5
+ extracted from the Wireshark pn_io dissector.
6
+
7
+ Block structures follow the standard format:
8
+ - BlockHeader (6 bytes): Type, Length, Version
9
+ - Variable body depending on block type
10
+ """
11
+
12
+ import struct
13
+ from dataclasses import dataclass, field
14
+ from typing import Dict, List, Optional, Tuple
15
+
16
+ from . import indices
17
+
18
+ # =============================================================================
19
+ # Data Classes
20
+ # =============================================================================
21
+
22
+
23
+ @dataclass
24
+ class BlockHeader:
25
+ """PROFINET block header (6 bytes)."""
26
+
27
+ block_type: int
28
+ block_length: int # Includes version (2 bytes), body = length - 2
29
+ version_high: int
30
+ version_low: int
31
+
32
+ @property
33
+ def body_length(self) -> int:
34
+ """Length of block body (excluding version bytes)."""
35
+ return self.block_length - 2 if self.block_length >= 2 else 0
36
+
37
+ @property
38
+ def type_name(self) -> str:
39
+ """Human-readable block type name."""
40
+ return indices.get_block_type_name(self.block_type)
41
+
42
+
43
+ @dataclass
44
+ class SlotInfo:
45
+ """Slot/subslot discovered from device."""
46
+
47
+ slot: int
48
+ subslot: int
49
+ api: int = 0
50
+ module_ident: int = 0
51
+ submodule_ident: int = 0
52
+ blocks: List[str] = field(default_factory=list)
53
+
54
+ def __repr__(self) -> str:
55
+ return f"SlotInfo(api={self.api}, slot={self.slot}, subslot=0x{self.subslot:04X})"
56
+
57
+
58
+ @dataclass
59
+ class PeerInfo:
60
+ """LLDP peer information from PDPortDataReal."""
61
+
62
+ port_id: str
63
+ chassis_id: str
64
+ mac_address: bytes
65
+
66
+ @property
67
+ def mac_str(self) -> str:
68
+ """MAC address as colon-separated string."""
69
+ return ":".join(f"{b:02x}" for b in self.mac_address)
70
+
71
+
72
+ @dataclass
73
+ class PortInfo:
74
+ """Port information from PDPortDataReal (0x020F)."""
75
+
76
+ slot: int
77
+ subslot: int
78
+ port_id: str
79
+ mau_type: int
80
+ link_state_port: int
81
+ link_state_link: int
82
+ media_type: int
83
+ peers: List[PeerInfo] = field(default_factory=list)
84
+ domain_boundary: int = 0
85
+ multicast_boundary: int = 0
86
+
87
+ @property
88
+ def mau_type_name(self) -> str:
89
+ """Human-readable MAU type."""
90
+ from .rpc import MAU_TYPES
91
+
92
+ return MAU_TYPES.get(self.mau_type, f"Unknown({self.mau_type})")
93
+
94
+ @property
95
+ def link_state(self) -> str:
96
+ """Human-readable link state."""
97
+ states = {0: "Unknown", 1: "Up", 2: "Down", 3: "Testing"}
98
+ return states.get(self.link_state_link, f"Unknown({self.link_state_link})")
99
+
100
+
101
+ @dataclass
102
+ class InterfaceInfo:
103
+ """Interface information from PDInterfaceDataReal (0x0240)."""
104
+
105
+ chassis_id: str
106
+ mac_address: bytes
107
+ ip_address: bytes
108
+ subnet_mask: bytes
109
+ gateway: bytes
110
+
111
+ @property
112
+ def mac_str(self) -> str:
113
+ """MAC address as colon-separated string."""
114
+ return ":".join(f"{b:02x}" for b in self.mac_address)
115
+
116
+ @property
117
+ def ip_str(self) -> str:
118
+ """IP address as dotted string."""
119
+ return ".".join(str(b) for b in self.ip_address)
120
+
121
+ @property
122
+ def subnet_str(self) -> str:
123
+ """Subnet mask as dotted string."""
124
+ return ".".join(str(b) for b in self.subnet_mask)
125
+
126
+ @property
127
+ def gateway_str(self) -> str:
128
+ """Gateway as dotted string."""
129
+ return ".".join(str(b) for b in self.gateway)
130
+
131
+
132
+ @dataclass
133
+ class PDRealData:
134
+ """Parsed PDRealData (0xF841) structure."""
135
+
136
+ slots: List[SlotInfo] = field(default_factory=list)
137
+ interface: Optional[InterfaceInfo] = None
138
+ ports: List[PortInfo] = field(default_factory=list)
139
+ raw_blocks: List[Tuple[int, int, int, bytes]] = field(
140
+ default_factory=list
141
+ ) # (api, slot, subslot, data)
142
+
143
+
144
+ @dataclass
145
+ class RealIdentificationData:
146
+ """Parsed RealIdentificationData (0xF000/0x0013) structure."""
147
+
148
+ slots: List[SlotInfo] = field(default_factory=list)
149
+ version: Tuple[int, int] = (1, 0)
150
+
151
+
152
+ # =============================================================================
153
+ # Parsing Functions
154
+ # =============================================================================
155
+
156
+
157
+ def parse_block_header(data: bytes, offset: int = 0) -> Tuple[BlockHeader, int]:
158
+ """
159
+ Parse a 6-byte PROFINET block header.
160
+
161
+ Args:
162
+ data: Raw bytes containing the block
163
+ offset: Starting offset in data
164
+
165
+ Returns:
166
+ Tuple of (BlockHeader, new_offset after header)
167
+
168
+ Raises:
169
+ ValueError: If data is too short
170
+ """
171
+ if len(data) < offset + 6:
172
+ raise ValueError(f"Block header requires 6 bytes, got {len(data) - offset}")
173
+
174
+ block_type, block_length, ver_high, ver_low = struct.unpack_from(
175
+ ">HHBB", data, offset
176
+ )
177
+
178
+ header = BlockHeader(
179
+ block_type=block_type,
180
+ block_length=block_length,
181
+ version_high=ver_high,
182
+ version_low=ver_low,
183
+ )
184
+
185
+ return header, offset + 6
186
+
187
+
188
+ def align4(offset: int) -> int:
189
+ """Align offset to 4-byte boundary."""
190
+ return (offset + 3) & ~3
191
+
192
+
193
+ def parse_multiple_block_header(
194
+ data: bytes, offset: int = 0
195
+ ) -> Tuple[int, int, int, int]:
196
+ """
197
+ Parse MultipleBlockHeader (0x0400) body.
198
+
199
+ Format:
200
+ Padding (2 bytes to align to 4)
201
+ API (uint32 BE)
202
+ SlotNr (uint16 BE)
203
+ SubslotNr (uint16 BE)
204
+
205
+ Args:
206
+ data: Raw bytes (body after block header)
207
+ offset: Starting offset
208
+
209
+ Returns:
210
+ Tuple of (api, slot, subslot, body_offset where nested blocks start)
211
+ """
212
+ # Skip 2-byte padding after header
213
+ offset += 2
214
+
215
+ if len(data) < offset + 8:
216
+ raise ValueError("MultipleBlockHeader body requires 8 bytes after padding")
217
+
218
+ api, slot, subslot = struct.unpack_from(">IHH", data, offset)
219
+
220
+ return api, slot, subslot, offset + 8
221
+
222
+
223
+ def parse_pd_interface_data_real(data: bytes, offset: int = 0, block_header_size: int = 6) -> InterfaceInfo:
224
+ """
225
+ Parse PDInterfaceDataReal (0x0240) block body.
226
+
227
+ Format:
228
+ LengthOwnChassisID (uint8)
229
+ OwnChassisID (variable)
230
+ Padding (to 4-byte boundary from block start)
231
+ MACAddress (6 bytes)
232
+ Padding (to 4-byte boundary from block start)
233
+ IPAddress (4 bytes)
234
+ Subnetmask (4 bytes)
235
+ Gateway (4 bytes)
236
+
237
+ Args:
238
+ data: Raw bytes (body after block header)
239
+ offset: Starting offset in data
240
+ block_header_size: Size of block header (default 6) for alignment calculation
241
+
242
+ Returns:
243
+ InterfaceInfo with parsed data
244
+ """
245
+ start = offset
246
+
247
+ def align_from_block(body_offset: int) -> int:
248
+ """Align to 4-byte boundary relative to block start (including header)."""
249
+ block_offset = block_header_size + (body_offset - start)
250
+ aligned_block = align4(block_offset)
251
+ return start + (aligned_block - block_header_size)
252
+
253
+ # Read chassis ID length and value
254
+ chassis_len = data[offset]
255
+ offset += 1
256
+
257
+ if len(data) < offset + chassis_len:
258
+ raise ValueError("Truncated chassis ID")
259
+
260
+ chassis_id = data[offset : offset + chassis_len].decode("latin-1", errors="replace")
261
+ offset += chassis_len
262
+
263
+ # Align to 4 bytes from block start
264
+ offset = align_from_block(offset)
265
+
266
+ # MAC address (6 bytes)
267
+ if len(data) < offset + 6:
268
+ raise ValueError("Truncated MAC address")
269
+ mac_address = data[offset : offset + 6]
270
+ offset += 6
271
+
272
+ # Align to 4 bytes from block start
273
+ offset = align_from_block(offset)
274
+
275
+ # IP, Subnet, Gateway (4 bytes each)
276
+ if len(data) < offset + 12:
277
+ raise ValueError("Truncated IP configuration")
278
+
279
+ ip_address = data[offset : offset + 4]
280
+ offset += 4
281
+ subnet_mask = data[offset : offset + 4]
282
+ offset += 4
283
+ gateway = data[offset : offset + 4]
284
+
285
+ return InterfaceInfo(
286
+ chassis_id=chassis_id,
287
+ mac_address=mac_address,
288
+ ip_address=ip_address,
289
+ subnet_mask=subnet_mask,
290
+ gateway=gateway,
291
+ )
292
+
293
+
294
+ def parse_pd_port_data_real(
295
+ data: bytes, offset: int = 0, slot: int = 0, subslot: int = 0
296
+ ) -> PortInfo:
297
+ """
298
+ Parse PDPortDataReal (0x020F) block body.
299
+
300
+ Format:
301
+ Padding (align to 4)
302
+ SlotNumber (uint16)
303
+ SubslotNumber (uint16)
304
+ LengthOwnPortID (uint8)
305
+ OwnPortID (variable)
306
+ NumberOfPeers (uint8)
307
+ Padding (align to 4)
308
+ [Peer info...]
309
+ MAUType (uint16)
310
+ Padding (align to 4)
311
+ DomainBoundary (uint32)
312
+ MulticastBoundary (uint32)
313
+ LinkStatePort (uint8)
314
+ LinkStateLink (uint8)
315
+ Padding (align to 4)
316
+ MediaType (uint32)
317
+
318
+ Args:
319
+ data: Raw bytes (body after block header)
320
+ offset: Starting offset
321
+ slot: Slot number from parent MultipleBlockHeader
322
+ subslot: Subslot number from parent MultipleBlockHeader
323
+
324
+ Returns:
325
+ PortInfo with parsed data
326
+ """
327
+ start = offset
328
+
329
+ # Padding to align to 4
330
+ offset = align4(offset)
331
+
332
+ # Slot/Subslot (may override passed values)
333
+ if len(data) >= offset + 4:
334
+ slot_nr, subslot_nr = struct.unpack_from(">HH", data, offset)
335
+ slot = slot_nr
336
+ subslot = subslot_nr
337
+ offset += 4
338
+
339
+ # Port ID
340
+ if len(data) < offset + 1:
341
+ return PortInfo(
342
+ slot=slot, subslot=subslot, port_id="", mau_type=0,
343
+ link_state_port=0, link_state_link=0, media_type=0
344
+ )
345
+
346
+ port_id_len = data[offset]
347
+ offset += 1
348
+
349
+ if len(data) < offset + port_id_len:
350
+ port_id = ""
351
+ else:
352
+ port_id = data[offset : offset + port_id_len].decode("latin-1", errors="replace")
353
+ offset += port_id_len
354
+
355
+ # Number of peers
356
+ num_peers = 0
357
+ peers = []
358
+ if len(data) > offset:
359
+ num_peers = data[offset]
360
+ offset += 1
361
+
362
+ # Align
363
+ offset = start + align4(offset - start)
364
+
365
+ # Parse peers
366
+ for _ in range(num_peers):
367
+ if len(data) < offset + 1:
368
+ break
369
+
370
+ # Peer port ID
371
+ peer_port_len = data[offset]
372
+ offset += 1
373
+ peer_port_id = ""
374
+ if len(data) >= offset + peer_port_len:
375
+ peer_port_id = data[offset : offset + peer_port_len].decode(
376
+ "latin-1", errors="replace"
377
+ )
378
+ offset += peer_port_len
379
+
380
+ # Peer chassis ID
381
+ if len(data) < offset + 1:
382
+ break
383
+ peer_chassis_len = data[offset]
384
+ offset += 1
385
+ peer_chassis_id = ""
386
+ if len(data) >= offset + peer_chassis_len:
387
+ peer_chassis_id = data[offset : offset + peer_chassis_len].decode(
388
+ "latin-1", errors="replace"
389
+ )
390
+ offset += peer_chassis_len
391
+
392
+ # Align to 4
393
+ offset = start + align4(offset - start)
394
+
395
+ # Peer MAC
396
+ peer_mac = b"\x00" * 6
397
+ if len(data) >= offset + 6:
398
+ peer_mac = data[offset : offset + 6]
399
+ offset += 6
400
+
401
+ # Align
402
+ offset = start + align4(offset - start)
403
+
404
+ peers.append(
405
+ PeerInfo(port_id=peer_port_id, chassis_id=peer_chassis_id, mac_address=peer_mac)
406
+ )
407
+
408
+ # MAU type
409
+ mau_type = 0
410
+ if len(data) >= offset + 2:
411
+ (mau_type,) = struct.unpack_from(">H", data, offset)
412
+ offset += 2
413
+
414
+ # Align
415
+ offset = start + align4(offset - start)
416
+
417
+ # Domain/Multicast boundaries
418
+ domain_boundary = 0
419
+ multicast_boundary = 0
420
+ if len(data) >= offset + 8:
421
+ domain_boundary, multicast_boundary = struct.unpack_from(">II", data, offset)
422
+ offset += 8
423
+
424
+ # Link states
425
+ link_state_port = 0
426
+ link_state_link = 0
427
+ if len(data) >= offset + 2:
428
+ link_state_port = data[offset]
429
+ link_state_link = data[offset + 1]
430
+ offset += 2
431
+
432
+ # Align
433
+ offset = start + align4(offset - start)
434
+
435
+ # Media type
436
+ media_type = 0
437
+ if len(data) >= offset + 4:
438
+ (media_type,) = struct.unpack_from(">I", data, offset)
439
+
440
+ return PortInfo(
441
+ slot=slot,
442
+ subslot=subslot,
443
+ port_id=port_id,
444
+ mau_type=mau_type,
445
+ link_state_port=link_state_port,
446
+ link_state_link=link_state_link,
447
+ media_type=media_type,
448
+ peers=peers,
449
+ domain_boundary=domain_boundary,
450
+ multicast_boundary=multicast_boundary,
451
+ )
452
+
453
+
454
+ def parse_pd_real_data(data: bytes) -> PDRealData:
455
+ """
456
+ Parse complete PDRealData (0xF841) response.
457
+
458
+ PDRealData contains multiple MultipleBlockHeader blocks, each describing
459
+ a slot/subslot with nested sub-blocks (PDInterfaceDataReal, PDPortDataReal, etc).
460
+
461
+ Args:
462
+ data: Raw bytes from reading index 0xF841
463
+
464
+ Returns:
465
+ PDRealData with parsed slots, interface, and ports
466
+ """
467
+ result = PDRealData()
468
+ offset = 0
469
+
470
+ while offset + 6 <= len(data):
471
+ try:
472
+ header, new_offset = parse_block_header(data, offset)
473
+ except ValueError:
474
+ break
475
+
476
+ block_end = new_offset + header.body_length
477
+
478
+ if header.block_type == indices.BLOCK_MULTIPLE_HEADER:
479
+ # Parse MultipleBlockHeader to get API/slot/subslot
480
+ try:
481
+ api, slot_nr, subslot_nr, nested_offset = parse_multiple_block_header(
482
+ data, new_offset
483
+ )
484
+
485
+ # Track this slot
486
+ slot_info = SlotInfo(api=api, slot=slot_nr, subslot=subslot_nr)
487
+
488
+ # Parse nested blocks within this MultipleBlockHeader
489
+ while nested_offset + 6 <= block_end:
490
+ try:
491
+ nested_header, nested_body = parse_block_header(
492
+ data, nested_offset
493
+ )
494
+ except ValueError:
495
+ break
496
+
497
+ nested_end = nested_body + nested_header.body_length
498
+ slot_info.blocks.append(nested_header.type_name)
499
+
500
+ # Parse specific block types
501
+ if nested_header.block_type == indices.BLOCK_PD_INTERFACE_DATA_REAL:
502
+ try:
503
+ result.interface = parse_pd_interface_data_real(
504
+ data, nested_body
505
+ )
506
+ except (ValueError, IndexError):
507
+ pass
508
+
509
+ elif nested_header.block_type == indices.BLOCK_PD_PORT_DATA_REAL:
510
+ try:
511
+ port = parse_pd_port_data_real(
512
+ data, nested_body, slot_nr, subslot_nr
513
+ )
514
+ result.ports.append(port)
515
+ except (ValueError, IndexError):
516
+ pass
517
+
518
+ nested_offset = nested_end
519
+
520
+ result.slots.append(slot_info)
521
+ result.raw_blocks.append(
522
+ (api, slot_nr, subslot_nr, data[new_offset:block_end])
523
+ )
524
+
525
+ except (ValueError, IndexError):
526
+ pass
527
+
528
+ offset = block_end
529
+
530
+ return result
531
+
532
+
533
+ def parse_real_identification_data(data: bytes) -> RealIdentificationData:
534
+ """
535
+ Parse RealIdentificationData (0xF000 or 0x0013) response.
536
+
537
+ Version 1.0:
538
+ NumberOfSlots (uint16)
539
+ For each slot:
540
+ SlotNumber (uint16)
541
+ ModuleIdentNumber (uint32)
542
+ NumberOfSubslots (uint16)
543
+ For each subslot:
544
+ SubslotNumber (uint16)
545
+ SubmoduleIdentNumber (uint32)
546
+
547
+ Version 1.1:
548
+ NumberOfAPIs (uint16)
549
+ For each API:
550
+ API (uint32)
551
+ NumberOfSlots (uint16)
552
+ ...same as 1.0
553
+
554
+ Args:
555
+ data: Raw bytes from reading index 0xF000
556
+
557
+ Returns:
558
+ RealIdentificationData with parsed slot structure
559
+ """
560
+ result = RealIdentificationData()
561
+ offset = 0
562
+
563
+ # Parse outer block header if present
564
+ if len(data) >= 6:
565
+ try:
566
+ header, offset = parse_block_header(data, 0)
567
+ result.version = (header.version_high, header.version_low)
568
+ except ValueError:
569
+ offset = 0
570
+ result.version = (1, 0)
571
+
572
+ if len(data) < offset + 2:
573
+ return result
574
+
575
+ # Version 1.1 has NumberOfAPIs first
576
+ if result.version[0] >= 1 and result.version[1] >= 1:
577
+ (num_apis,) = struct.unpack_from(">H", data, offset)
578
+ offset += 2
579
+
580
+ for _ in range(num_apis):
581
+ if len(data) < offset + 6:
582
+ break
583
+
584
+ (api,) = struct.unpack_from(">I", data, offset)
585
+ offset += 4
586
+
587
+ (num_slots,) = struct.unpack_from(">H", data, offset)
588
+ offset += 2
589
+
590
+ for _ in range(num_slots):
591
+ if len(data) < offset + 8:
592
+ break
593
+
594
+ slot_nr, module_ident, num_subslots = struct.unpack_from(
595
+ ">HIH", data, offset
596
+ )
597
+ offset += 8
598
+
599
+ for _ in range(num_subslots):
600
+ if len(data) < offset + 6:
601
+ break
602
+
603
+ subslot_nr, submodule_ident = struct.unpack_from(">HI", data, offset)
604
+ offset += 6
605
+
606
+ result.slots.append(
607
+ SlotInfo(
608
+ api=api,
609
+ slot=slot_nr,
610
+ subslot=subslot_nr,
611
+ module_ident=module_ident,
612
+ submodule_ident=submodule_ident,
613
+ )
614
+ )
615
+ else:
616
+ # Version 1.0 - no API level
617
+ (num_slots,) = struct.unpack_from(">H", data, offset)
618
+ offset += 2
619
+
620
+ for _ in range(num_slots):
621
+ if len(data) < offset + 8:
622
+ break
623
+
624
+ slot_nr, module_ident, num_subslots = struct.unpack_from(">HIH", data, offset)
625
+ offset += 8
626
+
627
+ for _ in range(num_subslots):
628
+ if len(data) < offset + 6:
629
+ break
630
+
631
+ subslot_nr, submodule_ident = struct.unpack_from(">HI", data, offset)
632
+ offset += 6
633
+
634
+ result.slots.append(
635
+ SlotInfo(
636
+ api=0,
637
+ slot=slot_nr,
638
+ subslot=subslot_nr,
639
+ module_ident=module_ident,
640
+ submodule_ident=submodule_ident,
641
+ )
642
+ )
643
+
644
+ return result
645
+
646
+
647
+ def parse_port_statistics(data: bytes, offset: int = 0) -> Dict[str, int]:
648
+ """
649
+ Parse PDPortStatistic (0x0251) block body.
650
+
651
+ Format:
652
+ CounterStatus (uint16)
653
+ ifInOctets (uint32)
654
+ ifOutOctets (uint32)
655
+ ifInDiscards (uint32)
656
+ ifOutDiscards (uint32)
657
+ ifInErrors (uint32)
658
+ ifOutErrors (uint32)
659
+
660
+ Args:
661
+ data: Raw bytes (body after block header)
662
+ offset: Starting offset
663
+
664
+ Returns:
665
+ Dictionary with counter names and values
666
+ """
667
+ result = {}
668
+
669
+ if len(data) < offset + 26:
670
+ return result
671
+
672
+ (
673
+ counter_status,
674
+ in_octets,
675
+ out_octets,
676
+ in_discards,
677
+ out_discards,
678
+ in_errors,
679
+ out_errors,
680
+ ) = struct.unpack_from(">HIIIIII", data, offset)
681
+
682
+ result = {
683
+ "counter_status": counter_status,
684
+ "in_octets": in_octets,
685
+ "out_octets": out_octets,
686
+ "in_discards": in_discards,
687
+ "out_discards": out_discards,
688
+ "in_errors": in_errors,
689
+ "out_errors": out_errors,
690
+ }
691
+
692
+ return result
693
+
694
+
695
+ # =============================================================================
696
+ # ModuleDiffBlock (0x8104) - Response showing module/submodule differences
697
+ # =============================================================================
698
+
699
+ @dataclass
700
+ class ModuleDiffSubmodule:
701
+ """Difference info for a single submodule."""
702
+ subslot_number: int = 0
703
+ submodule_ident_number: int = 0
704
+ submodule_state: int = 0
705
+
706
+ @property
707
+ def state_name(self) -> str:
708
+ """Human-readable state name."""
709
+ return indices.SUBMODULE_STATE_NAMES.get(
710
+ self.submodule_state,
711
+ f"Unknown(0x{self.submodule_state:04X})"
712
+ )
713
+
714
+ @property
715
+ def is_ok(self) -> bool:
716
+ """True if submodule state is OK (0x0007)."""
717
+ return self.submodule_state == indices.SUBMODULE_STATE_OK
718
+
719
+
720
+ @dataclass
721
+ class ModuleDiffModule:
722
+ """Difference info for a single module (slot)."""
723
+ api: int = 0
724
+ slot_number: int = 0
725
+ module_ident_number: int = 0
726
+ module_state: int = 0
727
+ submodules: List[ModuleDiffSubmodule] = field(default_factory=list)
728
+
729
+ @property
730
+ def state_name(self) -> str:
731
+ """Human-readable state name."""
732
+ return indices.MODULE_STATE_NAMES.get(
733
+ self.module_state,
734
+ f"Unknown(0x{self.module_state:04X})"
735
+ )
736
+
737
+ @property
738
+ def is_proper(self) -> bool:
739
+ """True if module state is Proper (0x0002)."""
740
+ return self.module_state == indices.MODULE_STATE_PROPER_MODULE
741
+
742
+
743
+ @dataclass
744
+ class ModuleDiffBlock:
745
+ """Parsed ModuleDiffBlock (0x8104)."""
746
+ modules: List[ModuleDiffModule] = field(default_factory=list)
747
+
748
+ @property
749
+ def all_ok(self) -> bool:
750
+ """Check if all modules and submodules match expected configuration."""
751
+ for mod in self.modules:
752
+ if not mod.is_proper:
753
+ return False
754
+ for sub in mod.submodules:
755
+ if not sub.is_ok:
756
+ return False
757
+ return True
758
+
759
+ def get_mismatches(self) -> List[Tuple[int, int, str]]:
760
+ """Get list of mismatched slots/subslots.
761
+
762
+ Returns:
763
+ List of (slot, subslot, state_name) tuples for non-OK items
764
+ """
765
+ mismatches = []
766
+ for mod in self.modules:
767
+ if not mod.is_proper:
768
+ mismatches.append((mod.slot_number, 0, mod.state_name))
769
+ for sub in mod.submodules:
770
+ if not sub.is_ok:
771
+ mismatches.append((mod.slot_number, sub.subslot_number, sub.state_name))
772
+ return mismatches
773
+
774
+
775
+ def parse_module_diff_block(data: bytes) -> ModuleDiffBlock:
776
+ """Parse ModuleDiffBlock (0x8104) from bytes.
777
+
778
+ Args:
779
+ data: Raw bytes of ModuleDiffBlock
780
+
781
+ Returns:
782
+ Parsed ModuleDiffBlock
783
+
784
+ Raises:
785
+ ValueError: If block type is wrong or data is truncated
786
+ """
787
+ if len(data) < 6:
788
+ return ModuleDiffBlock(modules=[])
789
+
790
+ offset = 0
791
+
792
+ # Parse block header
793
+ block_type, block_len, ver_hi, ver_lo = struct.unpack_from(">HHBB", data, offset)
794
+ offset += 6
795
+
796
+ if block_type != indices.BLOCK_MODULE_DIFF_BLOCK:
797
+ raise ValueError(f"Expected block type 0x8104, got 0x{block_type:04X}")
798
+
799
+ # NumberOfAPIs
800
+ if len(data) < offset + 2:
801
+ return ModuleDiffBlock(modules=[])
802
+
803
+ num_apis = struct.unpack_from(">H", data, offset)[0]
804
+ offset += 2
805
+
806
+ modules = []
807
+
808
+ for _ in range(num_apis):
809
+ if len(data) < offset + 6:
810
+ break
811
+
812
+ api = struct.unpack_from(">I", data, offset)[0]
813
+ offset += 4
814
+
815
+ num_modules = struct.unpack_from(">H", data, offset)[0]
816
+ offset += 2
817
+
818
+ for _ in range(num_modules):
819
+ if len(data) < offset + 10:
820
+ break
821
+
822
+ slot_nr, module_ident, module_state, num_submodules = struct.unpack_from(
823
+ ">HIHH", data, offset
824
+ )
825
+ offset += 10
826
+
827
+ submodules = []
828
+ for _ in range(num_submodules):
829
+ if len(data) < offset + 8:
830
+ break
831
+
832
+ subslot_nr, submodule_ident, submodule_state = struct.unpack_from(
833
+ ">HIH", data, offset
834
+ )
835
+ offset += 8
836
+
837
+ submodules.append(ModuleDiffSubmodule(
838
+ subslot_number=subslot_nr,
839
+ submodule_ident_number=submodule_ident,
840
+ submodule_state=submodule_state,
841
+ ))
842
+
843
+ modules.append(ModuleDiffModule(
844
+ api=api,
845
+ slot_number=slot_nr,
846
+ module_ident_number=module_ident,
847
+ module_state=module_state,
848
+ submodules=submodules,
849
+ ))
850
+
851
+ return ModuleDiffBlock(modules=modules)
852
+
853
+
854
+ # =============================================================================
855
+ # IODWriteMultiple Builder (Index 0xE040)
856
+ # =============================================================================
857
+
858
+ @dataclass
859
+ class WriteMultipleResult:
860
+ """Result of a single write in WriteMultiple operation."""
861
+ seq_num: int = 0
862
+ api: int = 0
863
+ slot: int = 0
864
+ subslot: int = 0
865
+ index: int = 0
866
+ status: int = 0
867
+ additional_value1: int = 0
868
+ additional_value2: int = 0
869
+
870
+ @property
871
+ def success(self) -> bool:
872
+ """True if write succeeded (status == 0)."""
873
+ return self.status == 0
874
+
875
+
876
+ class IODWriteMultipleBuilder:
877
+ """Builder for IODWriteMultipleReq packets (index 0xE040).
878
+
879
+ Handles proper padding between write blocks per IEC 61158-6-10.
880
+ """
881
+
882
+ INDEX = 0xE040
883
+ BLOCK_TYPE = 0x0008
884
+
885
+ def __init__(self, ar_uuid: bytes, seq_num: int = 0):
886
+ """Initialize builder.
887
+
888
+ Args:
889
+ ar_uuid: AR UUID (16 bytes)
890
+ seq_num: Starting sequence number
891
+ """
892
+ self.ar_uuid = ar_uuid
893
+ self.seq_num = seq_num
894
+ self.writes: List[Tuple[int, int, int, int, bytes]] = []
895
+
896
+ def add_write(
897
+ self,
898
+ slot: int,
899
+ subslot: int,
900
+ index: int,
901
+ data: bytes,
902
+ api: int = 0,
903
+ ) -> "IODWriteMultipleBuilder":
904
+ """Add a write operation."""
905
+ self.writes.append((api, slot, subslot, index, data))
906
+ return self
907
+
908
+ def build(self) -> bytes:
909
+ """Build the complete IODWriteMultipleReq packet."""
910
+ blocks_data = bytearray()
911
+
912
+ for i, (api, slot, subslot, index, data) in enumerate(self.writes):
913
+ block = self._build_write_block(i, api, slot, subslot, index, data)
914
+ blocks_data.extend(block)
915
+
916
+ # 4-byte padding (except last block)
917
+ if i < len(self.writes) - 1:
918
+ pad_len = (4 - (len(block) % 4)) % 4
919
+ blocks_data.extend(b'\x00' * pad_len)
920
+
921
+ header = self._build_header(len(blocks_data))
922
+ return bytes(header) + bytes(blocks_data)
923
+
924
+ def _build_write_block(
925
+ self, seq: int, api: int, slot: int, subslot: int, index: int, data: bytes
926
+ ) -> bytes:
927
+ """Build a single IODWriteReq block."""
928
+ block_header = struct.pack(">HHBB", 0x0008, 60, 0x01, 0x00)
929
+ body = struct.pack(
930
+ ">H16sIHHHHI24s",
931
+ seq, self.ar_uuid, api, slot, subslot, 0, index, len(data), bytes(24)
932
+ )
933
+ return block_header + body + data
934
+
935
+ def _build_header(self, blocks_len: int) -> bytes:
936
+ """Build the outer IODWriteMultipleReq header."""
937
+ block_header = struct.pack(">HHBB", 0x0008, 60, 0x01, 0x00)
938
+ body = struct.pack(
939
+ ">H16sIHHHHI24s",
940
+ self.seq_num, self.ar_uuid, 0xFFFFFFFF, 0xFFFF, 0xFFFF, 0, self.INDEX, blocks_len, bytes(24)
941
+ )
942
+ return block_header + body
943
+
944
+
945
+ def parse_write_multiple_response(data: bytes) -> List[WriteMultipleResult]:
946
+ """Parse IODWriteMultipleRes into individual results."""
947
+ results = []
948
+ if len(data) < 64:
949
+ return results
950
+
951
+ record_len = struct.unpack_from(">I", data, 36)[0]
952
+ offset = 64
953
+ end = min(offset + record_len, len(data))
954
+
955
+ while offset + 56 <= end:
956
+ block_type, block_len = struct.unpack_from(">HH", data, offset)
957
+ if block_type != 0x8008:
958
+ break
959
+
960
+ seq_num = struct.unpack_from(">H", data, offset + 6)[0]
961
+ api = struct.unpack_from(">I", data, offset + 24)[0]
962
+ slot = struct.unpack_from(">H", data, offset + 28)[0]
963
+ subslot = struct.unpack_from(">H", data, offset + 30)[0]
964
+ index = struct.unpack_from(">H", data, offset + 34)[0]
965
+ add_val1 = struct.unpack_from(">H", data, offset + 40)[0]
966
+ add_val2 = struct.unpack_from(">H", data, offset + 42)[0]
967
+ status = struct.unpack_from(">I", data, offset + 44)[0]
968
+
969
+ results.append(WriteMultipleResult(
970
+ seq_num=seq_num, api=api, slot=slot, subslot=subslot,
971
+ index=index, status=status, additional_value1=add_val1, additional_value2=add_val2
972
+ ))
973
+
974
+ block_size = 4 + block_len
975
+ pad = (4 - (block_size % 4)) % 4
976
+ offset += block_size + pad
977
+
978
+ return results
979
+
980
+
981
+ # =============================================================================
982
+ # ExpectedSubmodule Structures (0x0104)
983
+ # =============================================================================
984
+
985
+ @dataclass
986
+ class ExpectedSubmoduleDataDescription:
987
+ """Describes I/O data for a submodule."""
988
+ data_description: int = 1 # 1=Input, 2=Output
989
+ submodule_data_length: int = 0
990
+ length_iocs: int = 1
991
+ length_iops: int = 1
992
+
993
+ def to_bytes(self) -> bytes:
994
+ """Serialize to bytes."""
995
+ return struct.pack(
996
+ ">HHBB",
997
+ self.data_description,
998
+ self.submodule_data_length,
999
+ self.length_iocs,
1000
+ self.length_iops,
1001
+ )
1002
+
1003
+
1004
+ @dataclass
1005
+ class ExpectedSubmodule:
1006
+ """Expected submodule within a slot."""
1007
+ subslot_number: int = 0
1008
+ submodule_ident_number: int = 0
1009
+ submodule_properties: int = 0
1010
+ data_descriptions: List[ExpectedSubmoduleDataDescription] = field(default_factory=list)
1011
+
1012
+ @property
1013
+ def submodule_type(self) -> int:
1014
+ """Get SubmoduleProperties_Type (0=NO_IO, 1=INPUT, 2=OUTPUT, 3=INPUT_OUTPUT)."""
1015
+ return self.submodule_properties & 0x03
1016
+
1017
+ def to_bytes(self) -> bytes:
1018
+ """Serialize to bytes."""
1019
+ result = struct.pack(
1020
+ ">HIH",
1021
+ self.subslot_number,
1022
+ self.submodule_ident_number,
1023
+ self.submodule_properties,
1024
+ )
1025
+ for dd in self.data_descriptions:
1026
+ result += dd.to_bytes()
1027
+ return result
1028
+
1029
+
1030
+ @dataclass
1031
+ class ExpectedSubmoduleAPI:
1032
+ """Expected submodules for a specific API/slot."""
1033
+ api: int = 0
1034
+ slot_number: int = 0
1035
+ module_ident_number: int = 0
1036
+ module_properties: int = 0
1037
+ submodules: List[ExpectedSubmodule] = field(default_factory=list)
1038
+
1039
+ def to_bytes(self) -> bytes:
1040
+ """Serialize to bytes."""
1041
+ result = struct.pack(
1042
+ ">IHIHH",
1043
+ self.api,
1044
+ self.slot_number,
1045
+ self.module_ident_number,
1046
+ self.module_properties,
1047
+ len(self.submodules),
1048
+ )
1049
+ for sm in self.submodules:
1050
+ result += sm.to_bytes()
1051
+ return result
1052
+
1053
+
1054
+ class ExpectedSubmoduleBlockReq:
1055
+ """ExpectedSubmoduleBlockReq (0x0104) builder."""
1056
+
1057
+ BLOCK_TYPE = 0x0104
1058
+
1059
+ def __init__(self):
1060
+ """Initialize empty builder."""
1061
+ self.apis: List[ExpectedSubmoduleAPI] = []
1062
+
1063
+ def add_submodule(
1064
+ self,
1065
+ api: int,
1066
+ slot: int,
1067
+ subslot: int,
1068
+ module_ident: int,
1069
+ submodule_ident: int,
1070
+ submodule_type: int = 0,
1071
+ input_length: int = 0,
1072
+ output_length: int = 0,
1073
+ ) -> "ExpectedSubmoduleBlockReq":
1074
+ """Add a single submodule."""
1075
+ # Find or create API entry
1076
+ api_entry = None
1077
+ for a in self.apis:
1078
+ if a.api == api and a.slot_number == slot:
1079
+ api_entry = a
1080
+ break
1081
+
1082
+ if api_entry is None:
1083
+ api_entry = ExpectedSubmoduleAPI(
1084
+ api=api, slot_number=slot, module_ident_number=module_ident,
1085
+ module_properties=0, submodules=[]
1086
+ )
1087
+ self.apis.append(api_entry)
1088
+
1089
+ # Build data descriptions
1090
+ dds = []
1091
+ if submodule_type in (1, 3): # INPUT or INPUT_OUTPUT
1092
+ dds.append(ExpectedSubmoduleDataDescription(1, input_length, 1, 1))
1093
+ if submodule_type in (2, 3): # OUTPUT or INPUT_OUTPUT
1094
+ dds.append(ExpectedSubmoduleDataDescription(2, output_length, 1, 1))
1095
+ if submodule_type == 0: # NO_IO
1096
+ dds.append(ExpectedSubmoduleDataDescription(1, 0, 1, 1))
1097
+
1098
+ api_entry.submodules.append(ExpectedSubmodule(
1099
+ subslot, submodule_ident, submodule_type, dds
1100
+ ))
1101
+ return self
1102
+
1103
+ def to_bytes(self) -> bytes:
1104
+ """Build complete ExpectedSubmoduleBlockReq."""
1105
+ body = struct.pack(">H", len(self.apis))
1106
+ for api in self.apis:
1107
+ body += api.to_bytes()
1108
+
1109
+ block_len = len(body) + 2
1110
+ header = struct.pack(">HHBB", self.BLOCK_TYPE, block_len, 0x01, 0x00)
1111
+ return header + body