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/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