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/__init__.py +489 -0
- profinet/alarm_listener.py +455 -0
- profinet/alarms.py +522 -0
- profinet/blocks.py +1111 -0
- profinet/cli.py +328 -0
- profinet/cyclic.py +535 -0
- profinet/dcp.py +1510 -0
- profinet/device.py +1044 -0
- profinet/diagnosis.py +635 -0
- profinet/exceptions.py +505 -0
- profinet/indices.py +778 -0
- profinet/protocol.py +665 -0
- profinet/py.typed +2 -0
- profinet/rpc.py +2633 -0
- profinet/rt.py +446 -0
- profinet/util.py +1333 -0
- profinet/vendors.py +2220 -0
- profinet_py-0.4.0.dist-info/METADATA +131 -0
- profinet_py-0.4.0.dist-info/RECORD +23 -0
- profinet_py-0.4.0.dist-info/WHEEL +5 -0
- profinet_py-0.4.0.dist-info/entry_points.txt +2 -0
- profinet_py-0.4.0.dist-info/licenses/LICENSE +674 -0
- profinet_py-0.4.0.dist-info/top_level.txt +1 -0
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
|