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/alarms.py
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PROFINET Alarm handling.
|
|
3
|
+
|
|
4
|
+
Provides alarm item dataclasses and parsing functions for:
|
|
5
|
+
- AlarmNotification parsing
|
|
6
|
+
- Alarm item types (Diagnosis, Maintenance, PE, RS, etc.)
|
|
7
|
+
- USI (User Structure Identifier) dispatch
|
|
8
|
+
|
|
9
|
+
Per IEC 61158-6-10.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import struct
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import List, Tuple
|
|
17
|
+
|
|
18
|
+
from . import indices
|
|
19
|
+
|
|
20
|
+
# =============================================================================
|
|
21
|
+
# Alarm Item Base Classes
|
|
22
|
+
# =============================================================================
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class AlarmItem:
|
|
26
|
+
"""Base class for alarm payload items.
|
|
27
|
+
|
|
28
|
+
All alarm items start with UserStructureIdentifier (USI).
|
|
29
|
+
USI determines the item type and parsing format.
|
|
30
|
+
"""
|
|
31
|
+
user_structure_id: int = 0
|
|
32
|
+
raw_data: bytes = b""
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def usi_name(self) -> str:
|
|
36
|
+
"""Human-readable USI name."""
|
|
37
|
+
return indices.get_usi_name(self.user_structure_id)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# =============================================================================
|
|
41
|
+
# Specific Alarm Item Types
|
|
42
|
+
# =============================================================================
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class DiagnosisItem(AlarmItem):
|
|
46
|
+
"""Diagnosis alarm item (USI 0x8000, 0x8002, 0x8003).
|
|
47
|
+
|
|
48
|
+
Format depends on USI:
|
|
49
|
+
- 0x8000: ChannelNumber(2) + ChannelProperties(2) + ChannelErrorType(2)
|
|
50
|
+
- 0x8002: + ExtChannelErrorType(2) + ExtChannelAddValue(4)
|
|
51
|
+
- 0x8003: + QualifiedChannelQualifier(4)
|
|
52
|
+
"""
|
|
53
|
+
channel_number: int = 0
|
|
54
|
+
channel_properties: int = 0
|
|
55
|
+
channel_error_type: int = 0
|
|
56
|
+
ext_channel_error_type: int = 0
|
|
57
|
+
ext_channel_add_value: int = 0
|
|
58
|
+
qualified_channel_qualifier: int = 0
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def channel_number_value(self) -> int:
|
|
62
|
+
"""Get actual channel number (bits 0-14)."""
|
|
63
|
+
return self.channel_number & 0x7FFF
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def is_accumulative(self) -> bool:
|
|
67
|
+
"""Bit 15: accumulative (multiple errors on channel)."""
|
|
68
|
+
return bool(self.channel_number & 0x8000)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def channel_type(self) -> int:
|
|
72
|
+
"""Get channel type from properties (bits 0-7)."""
|
|
73
|
+
return self.channel_properties & 0xFF
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def is_extended(self) -> bool:
|
|
77
|
+
"""True if this is an extended diagnosis (USI 0x8002 or 0x8003)."""
|
|
78
|
+
return self.user_structure_id in (
|
|
79
|
+
indices.USI_EXT_CHANNEL_DIAGNOSIS,
|
|
80
|
+
indices.USI_QUALIFIED_CHANNEL_DIAGNOSIS,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def is_qualified(self) -> bool:
|
|
85
|
+
"""True if this is a qualified diagnosis (USI 0x8003)."""
|
|
86
|
+
return self.user_structure_id == indices.USI_QUALIFIED_CHANNEL_DIAGNOSIS
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class MaintenanceItem(AlarmItem):
|
|
91
|
+
"""Maintenance alarm item (USI 0x8100).
|
|
92
|
+
|
|
93
|
+
Format: BlockHeader(6) + Padding(2) + MaintenanceStatus(4)
|
|
94
|
+
"""
|
|
95
|
+
user_structure_id: int = indices.USI_MAINTENANCE
|
|
96
|
+
block_type: int = 0
|
|
97
|
+
block_length: int = 0
|
|
98
|
+
block_version: int = 0
|
|
99
|
+
maintenance_status: int = 0
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def maintenance_required(self) -> bool:
|
|
103
|
+
"""Bit 0: Maintenance required."""
|
|
104
|
+
return bool(self.maintenance_status & 0x01)
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def maintenance_demanded(self) -> bool:
|
|
108
|
+
"""Bit 1: Maintenance demanded."""
|
|
109
|
+
return bool(self.maintenance_status & 0x02)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class UploadRetrievalItem(AlarmItem):
|
|
114
|
+
"""Upload/Retrieval alarm item (USI 0x8200, 0x8201).
|
|
115
|
+
|
|
116
|
+
Format: BlockHeader(6) + Padding(2) + URRecordIndex(4) + URRecordLength(4)
|
|
117
|
+
"""
|
|
118
|
+
block_type: int = 0
|
|
119
|
+
block_length: int = 0
|
|
120
|
+
block_version: int = 0
|
|
121
|
+
ur_record_index: int = 0
|
|
122
|
+
ur_record_length: int = 0
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def is_upload(self) -> bool:
|
|
126
|
+
"""True if this is an upload request."""
|
|
127
|
+
return self.user_structure_id == indices.USI_UPLOAD
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def is_retrieval(self) -> bool:
|
|
131
|
+
"""True if this is a retrieval request."""
|
|
132
|
+
return self.user_structure_id == indices.USI_IPARAMETER
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass
|
|
136
|
+
class iParameterItem(AlarmItem):
|
|
137
|
+
"""iParameter alarm item (USI 0x8201).
|
|
138
|
+
|
|
139
|
+
Format:
|
|
140
|
+
BlockHeader(6) + Padding(2) +
|
|
141
|
+
iPar_Req_Header(4) + Max_Segm_Size(4) +
|
|
142
|
+
Transfer_Index(4) + Total_iPar_Size(4)
|
|
143
|
+
"""
|
|
144
|
+
user_structure_id: int = indices.USI_IPARAMETER
|
|
145
|
+
block_type: int = 0
|
|
146
|
+
block_length: int = 0
|
|
147
|
+
block_version: int = 0
|
|
148
|
+
ipar_req_header: int = 0
|
|
149
|
+
max_segment_size: int = 0
|
|
150
|
+
transfer_index: int = 0
|
|
151
|
+
total_ipar_size: int = 0
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass
|
|
155
|
+
class PE_AlarmItem(AlarmItem):
|
|
156
|
+
"""PROFIenergy alarm item (USI 0x8310).
|
|
157
|
+
|
|
158
|
+
Format: BlockHeader(6) + PE_OperationalMode(1)
|
|
159
|
+
"""
|
|
160
|
+
user_structure_id: int = indices.USI_PE_ALARM
|
|
161
|
+
block_type: int = 0
|
|
162
|
+
block_length: int = 0
|
|
163
|
+
block_version: int = 0
|
|
164
|
+
pe_operational_mode: int = 0
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def mode_name(self) -> str:
|
|
168
|
+
"""Human-readable mode name."""
|
|
169
|
+
return indices.get_pe_mode_name(self.pe_operational_mode)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass
|
|
173
|
+
class RS_AlarmItem(AlarmItem):
|
|
174
|
+
"""Reporting System alarm item (USI 0x8300-0x8302).
|
|
175
|
+
|
|
176
|
+
Format: RS_AlarmInfo(2)
|
|
177
|
+
"""
|
|
178
|
+
rs_alarm_info: int = 0
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def rs_specifier(self) -> int:
|
|
182
|
+
"""RS Specifier from AlarmInfo (bits 0-10)."""
|
|
183
|
+
return self.rs_alarm_info & 0x07FF
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def rs_sequence_number(self) -> int:
|
|
187
|
+
"""Sequence number (same as specifier for these items)."""
|
|
188
|
+
return self.rs_specifier
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@dataclass
|
|
192
|
+
class PRAL_AlarmItem(AlarmItem):
|
|
193
|
+
"""Pull Request Alarm item (USI 0x8320).
|
|
194
|
+
|
|
195
|
+
Format:
|
|
196
|
+
ChannelNumber(2) + PRAL_ChannelProperties(2) +
|
|
197
|
+
PRAL_Reason(2) + PRAL_ExtReason(2) +
|
|
198
|
+
PRAL_ReasonAddValue(variable)
|
|
199
|
+
"""
|
|
200
|
+
user_structure_id: int = indices.USI_PRAL_ALARM
|
|
201
|
+
channel_number: int = 0
|
|
202
|
+
pral_channel_properties: int = 0
|
|
203
|
+
pral_reason: int = 0
|
|
204
|
+
pral_ext_reason: int = 0
|
|
205
|
+
pral_reason_add_value: bytes = b""
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# =============================================================================
|
|
209
|
+
# Alarm Notification
|
|
210
|
+
# =============================================================================
|
|
211
|
+
|
|
212
|
+
@dataclass
|
|
213
|
+
class AlarmNotification:
|
|
214
|
+
"""Complete parsed alarm notification.
|
|
215
|
+
|
|
216
|
+
Combines the PDU header with parsed alarm items.
|
|
217
|
+
"""
|
|
218
|
+
# From block header
|
|
219
|
+
block_type: int = 0
|
|
220
|
+
block_version: Tuple[int, int] = (1, 0)
|
|
221
|
+
|
|
222
|
+
# From PDU body
|
|
223
|
+
alarm_type: int = 0
|
|
224
|
+
api: int = 0
|
|
225
|
+
slot_number: int = 0
|
|
226
|
+
subslot_number: int = 0
|
|
227
|
+
module_ident_number: int = 0
|
|
228
|
+
submodule_ident_number: int = 0
|
|
229
|
+
|
|
230
|
+
# AlarmSpecifier bits
|
|
231
|
+
alarm_sequence_number: int = 0
|
|
232
|
+
channel_diagnosis: bool = False
|
|
233
|
+
manufacturer_specific: bool = False
|
|
234
|
+
submodule_diagnosis_state: bool = False
|
|
235
|
+
ar_diagnosis_state: bool = False
|
|
236
|
+
|
|
237
|
+
# Parsed alarm payload items
|
|
238
|
+
items: List[AlarmItem] = field(default_factory=list)
|
|
239
|
+
|
|
240
|
+
# Raw data for debugging
|
|
241
|
+
raw_payload: bytes = b""
|
|
242
|
+
|
|
243
|
+
@property
|
|
244
|
+
def is_high_priority(self) -> bool:
|
|
245
|
+
"""True if this is a high-priority alarm."""
|
|
246
|
+
return self.block_type == indices.BLOCK_ALARM_NOTIFICATION_HIGH
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def is_low_priority(self) -> bool:
|
|
250
|
+
"""True if this is a low-priority alarm."""
|
|
251
|
+
return self.block_type == indices.BLOCK_ALARM_NOTIFICATION_LOW
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def alarm_type_name(self) -> str:
|
|
255
|
+
"""Human-readable alarm type."""
|
|
256
|
+
return indices.get_alarm_type_name(self.alarm_type)
|
|
257
|
+
|
|
258
|
+
@property
|
|
259
|
+
def location(self) -> str:
|
|
260
|
+
"""Location string (API:Slot:Subslot)."""
|
|
261
|
+
return f"{self.api}:{self.slot_number}:0x{self.subslot_number:04X}"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# =============================================================================
|
|
265
|
+
# Parsing Functions
|
|
266
|
+
# =============================================================================
|
|
267
|
+
|
|
268
|
+
def parse_alarm_item(data: bytes, offset: int = 0) -> Tuple[AlarmItem, int]:
|
|
269
|
+
"""Parse a single alarm item from data.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
data: Raw bytes containing alarm items
|
|
273
|
+
offset: Starting offset
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
Tuple of (parsed AlarmItem, new offset after item)
|
|
277
|
+
|
|
278
|
+
Raises:
|
|
279
|
+
ValueError: If data is truncated or invalid
|
|
280
|
+
"""
|
|
281
|
+
if len(data) < offset + 2:
|
|
282
|
+
raise ValueError("Insufficient data for USI")
|
|
283
|
+
|
|
284
|
+
usi = struct.unpack_from(">H", data, offset)[0]
|
|
285
|
+
offset += 2
|
|
286
|
+
|
|
287
|
+
# Dispatch to specific parser based on USI
|
|
288
|
+
if usi in (
|
|
289
|
+
indices.USI_CHANNEL_DIAGNOSIS,
|
|
290
|
+
indices.USI_EXT_CHANNEL_DIAGNOSIS,
|
|
291
|
+
indices.USI_QUALIFIED_CHANNEL_DIAGNOSIS,
|
|
292
|
+
):
|
|
293
|
+
return _parse_diagnosis_item(data, offset, usi)
|
|
294
|
+
elif usi == indices.USI_MAINTENANCE:
|
|
295
|
+
return _parse_maintenance_item(data, offset)
|
|
296
|
+
elif usi in (indices.USI_UPLOAD, indices.USI_IPARAMETER):
|
|
297
|
+
return _parse_upload_retrieval_item(data, offset, usi)
|
|
298
|
+
elif usi in (indices.USI_RS_ALARM_LOW, indices.USI_RS_ALARM_HIGH, indices.USI_RS_ALARM_SUBMODULE):
|
|
299
|
+
return _parse_rs_alarm_item(data, offset, usi)
|
|
300
|
+
elif usi == indices.USI_PE_ALARM:
|
|
301
|
+
return _parse_pe_alarm_item(data, offset)
|
|
302
|
+
elif usi == indices.USI_PRAL_ALARM:
|
|
303
|
+
return _parse_pral_alarm_item(data, offset)
|
|
304
|
+
else:
|
|
305
|
+
# Unknown/manufacturer-specific: return generic item with remaining data
|
|
306
|
+
return AlarmItem(user_structure_id=usi, raw_data=data[offset:]), len(data)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _parse_diagnosis_item(data: bytes, offset: int, usi: int) -> Tuple[DiagnosisItem, int]:
|
|
310
|
+
"""Parse DiagnosisItem (USI 0x8000, 0x8002, 0x8003)."""
|
|
311
|
+
# Base fields: ChannelNumber(2) + ChannelProperties(2) + ChannelErrorType(2)
|
|
312
|
+
if len(data) < offset + 6:
|
|
313
|
+
raise ValueError("Truncated DiagnosisItem")
|
|
314
|
+
|
|
315
|
+
channel_number, channel_props, error_type = struct.unpack_from(">HHH", data, offset)
|
|
316
|
+
offset += 6
|
|
317
|
+
|
|
318
|
+
item = DiagnosisItem(
|
|
319
|
+
user_structure_id=usi,
|
|
320
|
+
channel_number=channel_number,
|
|
321
|
+
channel_properties=channel_props,
|
|
322
|
+
channel_error_type=error_type,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# Extended diagnosis (USI 0x8002, 0x8003)
|
|
326
|
+
if usi in (indices.USI_EXT_CHANNEL_DIAGNOSIS, indices.USI_QUALIFIED_CHANNEL_DIAGNOSIS):
|
|
327
|
+
if len(data) < offset + 6:
|
|
328
|
+
raise ValueError("Truncated ExtChannelDiagnosis")
|
|
329
|
+
ext_error_type, ext_add_value = struct.unpack_from(">HI", data, offset)
|
|
330
|
+
item.ext_channel_error_type = ext_error_type
|
|
331
|
+
item.ext_channel_add_value = ext_add_value
|
|
332
|
+
offset += 6
|
|
333
|
+
|
|
334
|
+
# Qualified diagnosis (USI 0x8003)
|
|
335
|
+
if usi == indices.USI_QUALIFIED_CHANNEL_DIAGNOSIS:
|
|
336
|
+
if len(data) < offset + 4:
|
|
337
|
+
raise ValueError("Truncated QualifiedChannelDiagnosis")
|
|
338
|
+
(qualifier,) = struct.unpack_from(">I", data, offset)
|
|
339
|
+
item.qualified_channel_qualifier = qualifier
|
|
340
|
+
offset += 4
|
|
341
|
+
|
|
342
|
+
return item, offset
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _parse_maintenance_item(data: bytes, offset: int) -> Tuple[MaintenanceItem, int]:
|
|
346
|
+
"""Parse MaintenanceItem (USI 0x8100)."""
|
|
347
|
+
# BlockHeader(6) + Padding(2) + MaintenanceStatus(4) = 12 bytes
|
|
348
|
+
if len(data) < offset + 12:
|
|
349
|
+
raise ValueError("Truncated MaintenanceItem")
|
|
350
|
+
|
|
351
|
+
block_type, block_len, ver_high, ver_low = struct.unpack_from(">HHBB", data, offset)
|
|
352
|
+
offset += 6
|
|
353
|
+
|
|
354
|
+
# Skip 2-byte padding
|
|
355
|
+
offset += 2
|
|
356
|
+
|
|
357
|
+
(maint_status,) = struct.unpack_from(">I", data, offset)
|
|
358
|
+
offset += 4
|
|
359
|
+
|
|
360
|
+
return MaintenanceItem(
|
|
361
|
+
user_structure_id=indices.USI_MAINTENANCE,
|
|
362
|
+
block_type=block_type,
|
|
363
|
+
block_length=block_len,
|
|
364
|
+
block_version=(ver_high << 8) | ver_low,
|
|
365
|
+
maintenance_status=maint_status,
|
|
366
|
+
), offset
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _parse_upload_retrieval_item(data: bytes, offset: int, usi: int) -> Tuple[UploadRetrievalItem, int]:
|
|
370
|
+
"""Parse UploadRetrievalItem (USI 0x8200, 0x8201)."""
|
|
371
|
+
# BlockHeader(6) + Padding(2) + URRecordIndex(4) + URRecordLength(4) = 16 bytes
|
|
372
|
+
if len(data) < offset + 16:
|
|
373
|
+
raise ValueError("Truncated UploadRetrievalItem")
|
|
374
|
+
|
|
375
|
+
block_type, block_len, ver_high, ver_low = struct.unpack_from(">HHBB", data, offset)
|
|
376
|
+
offset += 6
|
|
377
|
+
offset += 2 # Padding
|
|
378
|
+
|
|
379
|
+
ur_index, ur_length = struct.unpack_from(">II", data, offset)
|
|
380
|
+
offset += 8
|
|
381
|
+
|
|
382
|
+
return UploadRetrievalItem(
|
|
383
|
+
user_structure_id=usi,
|
|
384
|
+
block_type=block_type,
|
|
385
|
+
block_length=block_len,
|
|
386
|
+
block_version=(ver_high << 8) | ver_low,
|
|
387
|
+
ur_record_index=ur_index,
|
|
388
|
+
ur_record_length=ur_length,
|
|
389
|
+
), offset
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _parse_pe_alarm_item(data: bytes, offset: int) -> Tuple[PE_AlarmItem, int]:
|
|
393
|
+
"""Parse PE_AlarmItem (USI 0x8310)."""
|
|
394
|
+
# BlockHeader(6) + PE_OperationalMode(1) = 7 bytes
|
|
395
|
+
if len(data) < offset + 7:
|
|
396
|
+
raise ValueError("Truncated PE_AlarmItem")
|
|
397
|
+
|
|
398
|
+
block_type, block_len, ver_high, ver_low = struct.unpack_from(">HHBB", data, offset)
|
|
399
|
+
offset += 6
|
|
400
|
+
|
|
401
|
+
pe_mode = data[offset]
|
|
402
|
+
offset += 1
|
|
403
|
+
|
|
404
|
+
return PE_AlarmItem(
|
|
405
|
+
user_structure_id=indices.USI_PE_ALARM,
|
|
406
|
+
block_type=block_type,
|
|
407
|
+
block_length=block_len,
|
|
408
|
+
block_version=(ver_high << 8) | ver_low,
|
|
409
|
+
pe_operational_mode=pe_mode,
|
|
410
|
+
), offset
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _parse_rs_alarm_item(data: bytes, offset: int, usi: int) -> Tuple[RS_AlarmItem, int]:
|
|
414
|
+
"""Parse RS_AlarmItem (USI 0x8300-0x8302)."""
|
|
415
|
+
# RS_AlarmInfo(2) = 2 bytes
|
|
416
|
+
if len(data) < offset + 2:
|
|
417
|
+
raise ValueError("Truncated RS_AlarmItem")
|
|
418
|
+
|
|
419
|
+
(rs_info,) = struct.unpack_from(">H", data, offset)
|
|
420
|
+
offset += 2
|
|
421
|
+
|
|
422
|
+
return RS_AlarmItem(
|
|
423
|
+
user_structure_id=usi,
|
|
424
|
+
rs_alarm_info=rs_info,
|
|
425
|
+
), offset
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _parse_pral_alarm_item(data: bytes, offset: int) -> Tuple[PRAL_AlarmItem, int]:
|
|
429
|
+
"""Parse PRAL_AlarmItem (USI 0x8320)."""
|
|
430
|
+
# ChannelNumber(2) + PRAL_ChannelProperties(2) + PRAL_Reason(2) +
|
|
431
|
+
# PRAL_ExtReason(2) + PRAL_ReasonAddValue(variable) = 8+ bytes
|
|
432
|
+
if len(data) < offset + 8:
|
|
433
|
+
raise ValueError("Truncated PRAL_AlarmItem")
|
|
434
|
+
|
|
435
|
+
channel_num, channel_props, reason, ext_reason = struct.unpack_from(
|
|
436
|
+
">HHHH", data, offset
|
|
437
|
+
)
|
|
438
|
+
offset += 8
|
|
439
|
+
|
|
440
|
+
# Remaining bytes are PRAL_ReasonAddValue
|
|
441
|
+
add_value = data[offset:]
|
|
442
|
+
|
|
443
|
+
return PRAL_AlarmItem(
|
|
444
|
+
user_structure_id=indices.USI_PRAL_ALARM,
|
|
445
|
+
channel_number=channel_num,
|
|
446
|
+
pral_channel_properties=channel_props,
|
|
447
|
+
pral_reason=reason,
|
|
448
|
+
pral_ext_reason=ext_reason,
|
|
449
|
+
pral_reason_add_value=add_value,
|
|
450
|
+
), len(data)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def parse_alarm_notification(data: bytes) -> AlarmNotification:
|
|
454
|
+
"""Parse complete AlarmNotification PDU.
|
|
455
|
+
|
|
456
|
+
Args:
|
|
457
|
+
data: Raw bytes of AlarmNotification block
|
|
458
|
+
|
|
459
|
+
Returns:
|
|
460
|
+
Parsed AlarmNotification with items
|
|
461
|
+
|
|
462
|
+
Raises:
|
|
463
|
+
ValueError: If data is truncated or invalid
|
|
464
|
+
"""
|
|
465
|
+
if len(data) < 28: # Minimum: BlockHeader(6) + Body(22)
|
|
466
|
+
raise ValueError("AlarmNotification too short")
|
|
467
|
+
|
|
468
|
+
offset = 0
|
|
469
|
+
|
|
470
|
+
# Parse block header
|
|
471
|
+
block_type, block_len, ver_high, ver_low = struct.unpack_from(">HHBB", data, offset)
|
|
472
|
+
offset += 6
|
|
473
|
+
|
|
474
|
+
# Parse PDU body
|
|
475
|
+
(
|
|
476
|
+
alarm_type,
|
|
477
|
+
api,
|
|
478
|
+
slot_number,
|
|
479
|
+
subslot_number,
|
|
480
|
+
module_ident,
|
|
481
|
+
submodule_ident,
|
|
482
|
+
alarm_specifier,
|
|
483
|
+
) = struct.unpack_from(">HIHHIIH", data, offset)
|
|
484
|
+
offset += 22
|
|
485
|
+
|
|
486
|
+
# Decode alarm specifier bits
|
|
487
|
+
seq_num = alarm_specifier & 0x07FF # Bits 0-10
|
|
488
|
+
channel_diag = bool(alarm_specifier & 0x0800) # Bit 11
|
|
489
|
+
mfr_specific = bool(alarm_specifier & 0x1000) # Bit 12
|
|
490
|
+
submod_diag = bool(alarm_specifier & 0x2000) # Bit 13
|
|
491
|
+
ar_diag = bool(alarm_specifier & 0x4000) # Bit 14
|
|
492
|
+
|
|
493
|
+
notification = AlarmNotification(
|
|
494
|
+
block_type=block_type,
|
|
495
|
+
block_version=(ver_high, ver_low),
|
|
496
|
+
alarm_type=alarm_type,
|
|
497
|
+
api=api,
|
|
498
|
+
slot_number=slot_number,
|
|
499
|
+
subslot_number=subslot_number,
|
|
500
|
+
module_ident_number=module_ident,
|
|
501
|
+
submodule_ident_number=submodule_ident,
|
|
502
|
+
alarm_sequence_number=seq_num,
|
|
503
|
+
channel_diagnosis=channel_diag,
|
|
504
|
+
manufacturer_specific=mfr_specific,
|
|
505
|
+
submodule_diagnosis_state=submod_diag,
|
|
506
|
+
ar_diagnosis_state=ar_diag,
|
|
507
|
+
raw_payload=data[offset:],
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
# Parse alarm payload items
|
|
511
|
+
payload_data = data[offset:]
|
|
512
|
+
item_offset = 0
|
|
513
|
+
|
|
514
|
+
while item_offset < len(payload_data):
|
|
515
|
+
try:
|
|
516
|
+
item, item_offset = parse_alarm_item(payload_data, item_offset)
|
|
517
|
+
notification.items.append(item)
|
|
518
|
+
except ValueError:
|
|
519
|
+
# Stop parsing on error, keep what we have
|
|
520
|
+
break
|
|
521
|
+
|
|
522
|
+
return notification
|