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
|
@@ -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()
|