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.
@@ -0,0 +1,455 @@
1
+ """
2
+ PROFINET Alarm Listener.
3
+
4
+ Provides background alarm reception for established AlarmCR connections.
5
+ Receives alarm notifications from devices and invokes registered callbacks.
6
+
7
+ Per IEC 61158-6-10:
8
+ - Alarms are sent via Layer 2 (RTA-PDU) or UDP
9
+ - Controller must acknowledge each alarm with AlarmAck-PDU
10
+ - Frame IDs: 0xFC01 (high priority), 0xFE01 (low priority)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import socket
17
+ import struct
18
+ import threading
19
+ from collections.abc import Callable
20
+ from dataclasses import dataclass
21
+ from typing import List, Optional
22
+
23
+ from .alarms import AlarmNotification, parse_alarm_notification
24
+ from .protocol import PNAlarmAckPDU, PNBlockHeader, PNRTAHeader
25
+ from .util import ethernet_socket
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ # Frame IDs for RT alarms (Layer 2)
31
+ FRAME_ID_ALARM_HIGH = 0xFC01
32
+ FRAME_ID_ALARM_LOW = 0xFE01
33
+
34
+ # EtherType for PROFINET
35
+ ETHERTYPE_PROFINET = 0x8892
36
+
37
+
38
+ @dataclass
39
+ class AlarmEndpoint:
40
+ """Alarm endpoint configuration.
41
+
42
+ Contains all information needed to set up alarm reception
43
+ for an established AR with AlarmCR.
44
+ """
45
+
46
+ interface: str
47
+ """Network interface name (e.g., 'eth0')."""
48
+
49
+ controller_ref: int
50
+ """Controller's local alarm reference (from AlarmCRBlockReq)."""
51
+
52
+ device_ref: int
53
+ """Device's local alarm reference (from AlarmCRBlockRes)."""
54
+
55
+ device_mac: bytes
56
+ """Device MAC address (6 bytes)."""
57
+
58
+ transport: int = 0
59
+ """Transport type: 0=Layer2 (RTA), 1=UDP."""
60
+
61
+
62
+ class AlarmListener:
63
+ """Background listener for PROFINET alarm notifications.
64
+
65
+ Runs a background thread that receives alarm frames from devices,
66
+ parses them, invokes registered callbacks, and sends acknowledgments.
67
+
68
+ Example:
69
+ >>> endpoint = AlarmEndpoint(
70
+ ... interface="eth0",
71
+ ... controller_ref=1,
72
+ ... device_ref=42,
73
+ ... device_mac=b"\\xd0\\xc8\\x57\\xe0\\x1c\\x2c",
74
+ ... )
75
+ >>> listener = AlarmListener(endpoint)
76
+ >>> listener.add_callback(lambda alarm: print(f"Alarm: {alarm.alarm_type_name}"))
77
+ >>> listener.start()
78
+ >>> # ... wait for alarms ...
79
+ >>> listener.stop()
80
+ """
81
+
82
+ def __init__(self, endpoint: AlarmEndpoint, controller_mac: Optional[bytes] = None):
83
+ """Initialize alarm listener.
84
+
85
+ Args:
86
+ endpoint: Alarm endpoint configuration
87
+ controller_mac: Controller MAC address for Layer 2 responses
88
+ """
89
+ self.endpoint = endpoint
90
+ self.controller_mac = controller_mac or b"\x00\x00\x00\x00\x00\x00"
91
+
92
+ self._running = False
93
+ self._thread: Optional[threading.Thread] = None
94
+ self._callbacks: List[Callable[[AlarmNotification], None]] = []
95
+ self._sock: Optional[socket.socket] = None
96
+
97
+ # Sequence tracking for RTA
98
+ self._send_seq_num: int = 0
99
+ self._recv_seq_num: int = 0
100
+
101
+ def add_callback(
102
+ self, callback: Callable[[AlarmNotification], None]
103
+ ) -> None:
104
+ """Register callback for received alarms.
105
+
106
+ Callbacks are invoked from the listener thread for each
107
+ successfully parsed alarm notification.
108
+
109
+ Args:
110
+ callback: Function that receives AlarmNotification
111
+ """
112
+ self._callbacks.append(callback)
113
+
114
+ def remove_callback(
115
+ self, callback: Callable[[AlarmNotification], None]
116
+ ) -> None:
117
+ """Remove a registered callback.
118
+
119
+ Args:
120
+ callback: Previously registered callback to remove
121
+ """
122
+ if callback in self._callbacks:
123
+ self._callbacks.remove(callback)
124
+
125
+ def start(self) -> None:
126
+ """Start background alarm listener.
127
+
128
+ Creates socket and spawns listener thread.
129
+ Safe to call multiple times (no-op if already running).
130
+ """
131
+ if self._running:
132
+ return
133
+
134
+ self._running = True
135
+ self._sock = self._create_socket()
136
+ self._thread = threading.Thread(
137
+ target=self._listen_loop,
138
+ daemon=True,
139
+ name=f"AlarmListener-{self.endpoint.interface}",
140
+ )
141
+ self._thread.start()
142
+ logger.info(
143
+ f"Alarm listener started on {self.endpoint.interface} "
144
+ f"(controller_ref={self.endpoint.controller_ref})"
145
+ )
146
+
147
+ def stop(self) -> None:
148
+ """Stop alarm listener.
149
+
150
+ Signals thread to stop and waits for graceful shutdown.
151
+ """
152
+ if not self._running:
153
+ return
154
+
155
+ self._running = False
156
+
157
+ # Close socket to unblock recv
158
+ if self._sock:
159
+ try:
160
+ self._sock.close()
161
+ except OSError:
162
+ pass
163
+
164
+ # Wait for thread to finish
165
+ if self._thread and self._thread.is_alive():
166
+ self._thread.join(timeout=2.0)
167
+ if self._thread.is_alive():
168
+ logger.warning("Alarm listener thread did not stop cleanly")
169
+
170
+ self._sock = None
171
+ self._thread = None
172
+ logger.info("Alarm listener stopped")
173
+
174
+ @property
175
+ def is_running(self) -> bool:
176
+ """True if listener is currently running."""
177
+ return self._running
178
+
179
+ def _create_socket(self):
180
+ """Create raw socket for Layer 2 or UDP socket.
181
+
182
+ Returns:
183
+ Configured socket for alarm reception.
184
+ For Layer 2: platform-abstracted raw socket (AF_PACKET on Linux,
185
+ NpcapSocket on Windows, PcapSocket on macOS).
186
+ For UDP: standard socket.
187
+
188
+ Raises:
189
+ PermissionError: If raw socket requires elevated privileges
190
+ """
191
+ if self.endpoint.transport == 0:
192
+ # Layer 2 raw socket via platform-abstracted ethernet_socket()
193
+ try:
194
+ sock = ethernet_socket(self.endpoint.interface, ETHERTYPE_PROFINET)
195
+ except PermissionError as e:
196
+ raise PermissionError(
197
+ f"Raw socket requires root/admin privileges: {e}"
198
+ ) from e
199
+ else:
200
+ # UDP socket (port 34964) — works on all platforms
201
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
202
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
203
+ sock.bind(("0.0.0.0", 34964))
204
+
205
+ # Non-blocking timeout for clean shutdown
206
+ sock.settimeout(1.0)
207
+ return sock
208
+
209
+ def _listen_loop(self) -> None:
210
+ """Main listening loop (runs in background thread)."""
211
+ logger.debug("Alarm listener thread started")
212
+
213
+ while self._running:
214
+ try:
215
+ if self.endpoint.transport == 0:
216
+ self._handle_layer2_frame()
217
+ else:
218
+ self._handle_udp_frame()
219
+ except TimeoutError:
220
+ # Normal timeout, check if we should stop
221
+ continue
222
+ except OSError as e:
223
+ if self._running:
224
+ logger.error(f"Alarm listener socket error: {e}")
225
+ break
226
+ except Exception as e:
227
+ logger.error(f"Alarm listener error: {e}", exc_info=True)
228
+
229
+ logger.debug("Alarm listener thread stopped")
230
+
231
+ def _handle_layer2_frame(self) -> None:
232
+ """Process Layer 2 Ethernet frame."""
233
+ data = self._sock.recv(4096)
234
+
235
+ if len(data) < 16:
236
+ return
237
+
238
+ # Parse Ethernet header (14 bytes)
239
+ _dst_mac = data[0:6]
240
+ src_mac = data[6:12]
241
+ ethertype = struct.unpack(">H", data[12:14])[0]
242
+
243
+ if ethertype != ETHERTYPE_PROFINET:
244
+ return
245
+
246
+ # Check source MAC matches device
247
+ if src_mac != self.endpoint.device_mac:
248
+ return
249
+
250
+ # Parse Frame ID
251
+ frame_id = struct.unpack(">H", data[14:16])[0]
252
+
253
+ if frame_id == FRAME_ID_ALARM_HIGH:
254
+ self._process_alarm(data[16:], high_priority=True, src_mac=src_mac)
255
+ elif frame_id == FRAME_ID_ALARM_LOW:
256
+ self._process_alarm(data[16:], high_priority=False, src_mac=src_mac)
257
+
258
+ def _handle_udp_frame(self) -> None:
259
+ """Process UDP datagram."""
260
+ data, addr = self._sock.recvfrom(4096)
261
+
262
+ if len(data) < 28:
263
+ return
264
+
265
+ # For UDP, the frame starts with the alarm block directly
266
+ # (no Ethernet header, no Frame ID)
267
+ self._process_alarm(data, high_priority=None, src_addr=addr)
268
+
269
+ def _process_alarm(
270
+ self,
271
+ payload: bytes,
272
+ high_priority: Optional[bool],
273
+ src_mac: Optional[bytes] = None,
274
+ src_addr: Optional[tuple] = None,
275
+ ) -> None:
276
+ """Parse alarm and invoke callbacks.
277
+
278
+ Args:
279
+ payload: Alarm payload (after Frame ID for Layer 2)
280
+ high_priority: True for high, False for low, None for UDP
281
+ src_mac: Source MAC for Layer 2 response
282
+ src_addr: Source address for UDP response
283
+ """
284
+ try:
285
+ # Parse RTA-PDU header if Layer 2
286
+ if self.endpoint.transport == 0 and len(payload) >= 12:
287
+ rta_header = self._parse_rta_header(payload[:12])
288
+ alarm_data = payload[12:]
289
+
290
+ # Validate alarm references
291
+ if rta_header.alarm_dst_endpoint != self.endpoint.controller_ref:
292
+ logger.debug(
293
+ f"Ignoring alarm with wrong dst ref "
294
+ f"(got {rta_header.alarm_dst_endpoint}, "
295
+ f"expected {self.endpoint.controller_ref})"
296
+ )
297
+ return
298
+
299
+ self._recv_seq_num = rta_header.send_seq_num
300
+ else:
301
+ alarm_data = payload
302
+ rta_header = None
303
+
304
+ # Parse alarm notification
305
+ alarm = parse_alarm_notification(alarm_data)
306
+
307
+ # Set priority from frame ID if not from block type
308
+ if high_priority is not None:
309
+ # Verify consistency or trust frame ID
310
+ pass
311
+
312
+ logger.debug(
313
+ f"Received alarm: {alarm.alarm_type_name} "
314
+ f"at {alarm.location} (seq={alarm.alarm_sequence_number})"
315
+ )
316
+
317
+ # Send acknowledgment
318
+ self._send_ack(alarm, src_mac, src_addr)
319
+
320
+ # Invoke callbacks
321
+ for callback in self._callbacks:
322
+ try:
323
+ callback(alarm)
324
+ except Exception as e:
325
+ logger.error(f"Alarm callback error: {e}", exc_info=True)
326
+
327
+ except ValueError as e:
328
+ logger.warning(f"Failed to parse alarm: {e}")
329
+ except Exception as e:
330
+ logger.error(f"Alarm processing error: {e}", exc_info=True)
331
+
332
+ def _parse_rta_header(self, data: bytes) -> PNRTAHeader:
333
+ """Parse RTA-PDU header."""
334
+ return PNRTAHeader(data)
335
+
336
+ def _send_ack(
337
+ self,
338
+ alarm: AlarmNotification,
339
+ src_mac: Optional[bytes],
340
+ src_addr: Optional[tuple],
341
+ ) -> None:
342
+ """Send AlarmAck-PDU back to device.
343
+
344
+ Args:
345
+ alarm: Parsed alarm to acknowledge
346
+ src_mac: Device MAC for Layer 2 response
347
+ src_addr: Device address for UDP response
348
+ """
349
+ try:
350
+ # Build AlarmAck PDU
351
+ block_type = (
352
+ 0x8001 if alarm.is_high_priority else 0x8002
353
+ ) # Ack High/Low
354
+ block_length = PNAlarmAckPDU.fmt_size - 4 # Exclude type+length
355
+
356
+ block_header = PNBlockHeader(
357
+ block_type,
358
+ block_length,
359
+ 0x01, # version high
360
+ 0x00, # version low
361
+ )
362
+
363
+ # Reconstruct alarm specifier
364
+ alarm_specifier = (
365
+ (alarm.alarm_sequence_number & 0x07FF)
366
+ | (0x0800 if alarm.channel_diagnosis else 0)
367
+ | (0x1000 if alarm.manufacturer_specific else 0)
368
+ | (0x2000 if alarm.submodule_diagnosis_state else 0)
369
+ | (0x4000 if alarm.ar_diagnosis_state else 0)
370
+ )
371
+
372
+ ack = PNAlarmAckPDU(
373
+ block_header=bytes(block_header),
374
+ alarm_type=alarm.alarm_type,
375
+ api=alarm.api,
376
+ slot_number=alarm.slot_number,
377
+ subslot_number=alarm.subslot_number,
378
+ alarm_specifier=alarm_specifier,
379
+ pnio_status=0x00000000, # OK
380
+ )
381
+ ack_data = bytes(ack)
382
+
383
+ if self.endpoint.transport == 0:
384
+ # Layer 2 with RTA header
385
+ self._send_layer2_ack(ack_data, src_mac)
386
+ else:
387
+ # UDP
388
+ self._send_udp_ack(ack_data, src_addr)
389
+
390
+ logger.debug(
391
+ f"Sent AlarmAck for {alarm.alarm_type_name} "
392
+ f"(seq={alarm.alarm_sequence_number})"
393
+ )
394
+
395
+ except Exception as e:
396
+ logger.error(f"Failed to send AlarmAck: {e}")
397
+
398
+ def _send_layer2_ack(self, ack_data: bytes, dst_mac: bytes) -> None:
399
+ """Send acknowledgment via Layer 2.
400
+
401
+ Args:
402
+ ack_data: Serialized AlarmAck PDU
403
+ dst_mac: Destination MAC address
404
+ """
405
+ # Build RTA header
406
+ self._send_seq_num = (self._send_seq_num + 1) & 0xFFFF
407
+
408
+ rta_header = PNRTAHeader(
409
+ alarm_dst_endpoint=self.endpoint.device_ref,
410
+ alarm_src_endpoint=self.endpoint.controller_ref,
411
+ pdu_type=(PNRTAHeader.RTA_TYPE_ACK << 4) | PNRTAHeader.VERSION_1,
412
+ add_flags=0,
413
+ send_seq_num=self._send_seq_num,
414
+ ack_seq_num=self._recv_seq_num,
415
+ var_part_len=len(ack_data),
416
+ )
417
+
418
+ # Build complete frame
419
+ # FrameID for alarm ack (same direction as alarm)
420
+ frame_id = struct.pack(">H", FRAME_ID_ALARM_LOW)
421
+
422
+ eth_frame = (
423
+ dst_mac
424
+ + self.controller_mac
425
+ + struct.pack(">H", ETHERTYPE_PROFINET)
426
+ + frame_id
427
+ + bytes(rta_header)
428
+ + ack_data
429
+ )
430
+
431
+ try:
432
+ self._sock.send(eth_frame)
433
+ except Exception as e:
434
+ logger.error(f"Layer 2 send error: {e}")
435
+
436
+ def _send_udp_ack(self, ack_data: bytes, dst_addr: tuple) -> None:
437
+ """Send acknowledgment via UDP.
438
+
439
+ Args:
440
+ ack_data: Serialized AlarmAck PDU
441
+ dst_addr: Destination (ip, port) tuple
442
+ """
443
+ try:
444
+ self._sock.sendto(ack_data, dst_addr)
445
+ except Exception as e:
446
+ logger.error(f"UDP send error: {e}")
447
+
448
+ def __enter__(self) -> AlarmListener:
449
+ """Context manager entry - start listener."""
450
+ self.start()
451
+ return self
452
+
453
+ def __exit__(self, *args) -> None:
454
+ """Context manager exit - stop listener."""
455
+ self.stop()