ka9q-python 3.2.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.
- ka9q/__init__.py +98 -0
- ka9q/control.py +2295 -0
- ka9q/discovery.py +485 -0
- ka9q/exceptions.py +23 -0
- ka9q/resequencer.py +389 -0
- ka9q/rtp_recorder.py +457 -0
- ka9q/stream.py +393 -0
- ka9q/stream_quality.py +215 -0
- ka9q/types.py +161 -0
- ka9q/utils.py +202 -0
- ka9q_python-3.2.0.dist-info/METADATA +237 -0
- ka9q_python-3.2.0.dist-info/RECORD +15 -0
- ka9q_python-3.2.0.dist-info/WHEEL +5 -0
- ka9q_python-3.2.0.dist-info/licenses/LICENSE +21 -0
- ka9q_python-3.2.0.dist-info/top_level.txt +1 -0
ka9q/rtp_recorder.py
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Generic RTP Recorder with Timing Support
|
|
3
|
+
|
|
4
|
+
This module provides a generic RTP recording framework with:
|
|
5
|
+
- State machine (idle → armed → recording → resync)
|
|
6
|
+
- Precise timing from radiod (GPS_TIME, RTP_TIMESNAP)
|
|
7
|
+
- Callbacks for application-specific storage
|
|
8
|
+
- Sequence number and timestamp validation
|
|
9
|
+
- Automatic resynchronization on errors
|
|
10
|
+
|
|
11
|
+
Designed to be reusable across different recording applications
|
|
12
|
+
(WSPR, FT8, general-purpose, etc.)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import socket
|
|
16
|
+
import struct
|
|
17
|
+
import logging
|
|
18
|
+
import time
|
|
19
|
+
from enum import Enum
|
|
20
|
+
from typing import Optional, Callable, NamedTuple, Dict, Any
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
import threading
|
|
23
|
+
|
|
24
|
+
from .discovery import ChannelInfo
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# RTP Constants (from radiod)
|
|
30
|
+
GPS_UTC_OFFSET = 315964800 # GPS epoch (1980-01-06) - Unix epoch (1970-01-01)
|
|
31
|
+
UNIX_EPOCH = 2208988800 # Unix epoch in NTP seconds
|
|
32
|
+
BILLION = 1_000_000_000
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RecorderState(Enum):
|
|
36
|
+
"""Recorder state machine states"""
|
|
37
|
+
IDLE = "idle" # Not recording
|
|
38
|
+
ARMED = "armed" # Waiting for trigger condition
|
|
39
|
+
RECORDING = "recording" # Actively recording
|
|
40
|
+
RESYNC = "resync" # Lost sync, trying to recover
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RTPHeader(NamedTuple):
|
|
44
|
+
"""Parsed RTP packet header"""
|
|
45
|
+
version: int
|
|
46
|
+
padding: bool
|
|
47
|
+
extension: bool
|
|
48
|
+
csrc_count: int
|
|
49
|
+
marker: bool
|
|
50
|
+
payload_type: int
|
|
51
|
+
sequence: int
|
|
52
|
+
timestamp: int
|
|
53
|
+
ssrc: int
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class RecordingMetrics:
|
|
58
|
+
"""Recording session metrics"""
|
|
59
|
+
packets_received: int = 0
|
|
60
|
+
packets_dropped: int = 0
|
|
61
|
+
packets_out_of_order: int = 0
|
|
62
|
+
bytes_received: int = 0
|
|
63
|
+
sequence_errors: int = 0
|
|
64
|
+
timestamp_jumps: int = 0
|
|
65
|
+
state_changes: int = 0
|
|
66
|
+
recording_start_time: Optional[float] = None
|
|
67
|
+
recording_stop_time: Optional[float] = None
|
|
68
|
+
|
|
69
|
+
def to_dict(self) -> dict:
|
|
70
|
+
"""Convert metrics to dictionary"""
|
|
71
|
+
return {
|
|
72
|
+
'packets_received': self.packets_received,
|
|
73
|
+
'packets_dropped': self.packets_dropped,
|
|
74
|
+
'packets_out_of_order': self.packets_out_of_order,
|
|
75
|
+
'bytes_received': self.bytes_received,
|
|
76
|
+
'sequence_errors': self.sequence_errors,
|
|
77
|
+
'timestamp_jumps': self.timestamp_jumps,
|
|
78
|
+
'state_changes': self.state_changes,
|
|
79
|
+
'recording_duration': (
|
|
80
|
+
self.recording_stop_time - self.recording_start_time
|
|
81
|
+
if self.recording_start_time and self.recording_stop_time
|
|
82
|
+
else None
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def parse_rtp_header(data: bytes) -> Optional[RTPHeader]:
|
|
88
|
+
"""
|
|
89
|
+
Parse RTP packet header
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
data: Raw packet bytes (minimum 12 bytes)
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
RTPHeader if valid, None if invalid
|
|
96
|
+
"""
|
|
97
|
+
if len(data) < 12:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
# Parse RTP header (RFC 3550)
|
|
101
|
+
byte0, byte1 = struct.unpack('!BB', data[0:2])
|
|
102
|
+
|
|
103
|
+
version = (byte0 >> 6) & 0x03
|
|
104
|
+
padding = bool(byte0 & 0x20)
|
|
105
|
+
extension = bool(byte0 & 0x10)
|
|
106
|
+
csrc_count = byte0 & 0x0F
|
|
107
|
+
|
|
108
|
+
marker = bool(byte1 & 0x80)
|
|
109
|
+
payload_type = byte1 & 0x7F
|
|
110
|
+
|
|
111
|
+
sequence, timestamp, ssrc = struct.unpack('!HIL', data[2:12])
|
|
112
|
+
|
|
113
|
+
return RTPHeader(
|
|
114
|
+
version=version,
|
|
115
|
+
padding=padding,
|
|
116
|
+
extension=extension,
|
|
117
|
+
csrc_count=csrc_count,
|
|
118
|
+
marker=marker,
|
|
119
|
+
payload_type=payload_type,
|
|
120
|
+
sequence=sequence,
|
|
121
|
+
timestamp=timestamp,
|
|
122
|
+
ssrc=ssrc
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def rtp_to_wallclock(rtp_timestamp: int, channel: ChannelInfo) -> Optional[float]:
|
|
127
|
+
"""
|
|
128
|
+
Convert RTP timestamp to Unix wall-clock time
|
|
129
|
+
|
|
130
|
+
Uses the GPS_TIME/RTP_TIMESNAP timing information from radiod.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
rtp_timestamp: RTP timestamp from packet header
|
|
134
|
+
channel: ChannelInfo with gps_time, rtp_timesnap, sample_rate
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Unix timestamp (seconds) or None if timing info unavailable
|
|
138
|
+
"""
|
|
139
|
+
if channel.gps_time is None or channel.rtp_timesnap is None:
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
# Convert GPS nanoseconds to Unix time
|
|
143
|
+
sender_time = channel.gps_time + BILLION * (UNIX_EPOCH - GPS_UTC_OFFSET)
|
|
144
|
+
|
|
145
|
+
# Add offset from RTP timestamp difference
|
|
146
|
+
# Cast to int32 for proper wrapping behavior
|
|
147
|
+
rtp_delta = int((rtp_timestamp - channel.rtp_timesnap) & 0xFFFFFFFF)
|
|
148
|
+
if rtp_delta > 0x7FFFFFFF:
|
|
149
|
+
rtp_delta -= 0x100000000
|
|
150
|
+
|
|
151
|
+
time_offset = BILLION * rtp_delta // channel.sample_rate
|
|
152
|
+
|
|
153
|
+
wall_time_ns = sender_time + time_offset
|
|
154
|
+
|
|
155
|
+
# Convert to Unix seconds
|
|
156
|
+
return wall_time_ns / BILLION
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class RTPRecorder:
|
|
160
|
+
"""
|
|
161
|
+
Generic RTP recorder with state machine and timing support
|
|
162
|
+
|
|
163
|
+
Callbacks allow application-specific behavior:
|
|
164
|
+
- on_packet: Called for each received packet
|
|
165
|
+
- on_state_change: Called when state changes
|
|
166
|
+
- on_recording_start: Called when recording starts
|
|
167
|
+
- on_recording_stop: Called when recording stops
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
def __init__(
|
|
171
|
+
self,
|
|
172
|
+
channel: ChannelInfo,
|
|
173
|
+
on_packet: Optional[Callable[[RTPHeader, bytes, float], None]] = None,
|
|
174
|
+
on_state_change: Optional[Callable[[RecorderState, RecorderState], None]] = None,
|
|
175
|
+
on_recording_start: Optional[Callable[[], None]] = None,
|
|
176
|
+
on_recording_stop: Optional[Callable[[RecordingMetrics], None]] = None,
|
|
177
|
+
max_packet_gap: int = 10,
|
|
178
|
+
resync_threshold: int = 5,
|
|
179
|
+
pass_all_packets: bool = False
|
|
180
|
+
):
|
|
181
|
+
"""
|
|
182
|
+
Initialize RTP recorder
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
channel: ChannelInfo with RTP stream details and timing
|
|
186
|
+
on_packet: Callback(header, payload, wallclock_time) for each packet
|
|
187
|
+
on_state_change: Callback(old_state, new_state) on state changes
|
|
188
|
+
on_recording_start: Callback when recording begins
|
|
189
|
+
on_recording_stop: Callback(metrics) when recording ends
|
|
190
|
+
max_packet_gap: Max sequence gap before triggering resync (ignored if pass_all_packets=True)
|
|
191
|
+
resync_threshold: Number of good packets needed to recover from resync
|
|
192
|
+
pass_all_packets: If True, pass ALL packets to callback regardless of sequence.
|
|
193
|
+
Metrics still track errors. Use when downstream has its own resequencer.
|
|
194
|
+
"""
|
|
195
|
+
self.channel = channel
|
|
196
|
+
self.on_packet = on_packet
|
|
197
|
+
self.on_state_change = on_state_change
|
|
198
|
+
self.on_recording_start = on_recording_start
|
|
199
|
+
self.on_recording_stop = on_recording_stop
|
|
200
|
+
|
|
201
|
+
self.max_packet_gap = max_packet_gap
|
|
202
|
+
self.resync_threshold = resync_threshold
|
|
203
|
+
self.pass_all_packets = pass_all_packets
|
|
204
|
+
|
|
205
|
+
self.state = RecorderState.IDLE
|
|
206
|
+
self.metrics = RecordingMetrics()
|
|
207
|
+
|
|
208
|
+
# RTP state tracking
|
|
209
|
+
self.last_sequence: Optional[int] = None
|
|
210
|
+
self.last_timestamp: Optional[int] = None
|
|
211
|
+
self.resync_good_packets = 0
|
|
212
|
+
|
|
213
|
+
# Socket
|
|
214
|
+
self.socket: Optional[socket.socket] = None
|
|
215
|
+
self.running = False
|
|
216
|
+
self.thread: Optional[threading.Thread] = None
|
|
217
|
+
|
|
218
|
+
def _change_state(self, new_state: RecorderState):
|
|
219
|
+
"""Change state and trigger callback"""
|
|
220
|
+
if new_state == self.state:
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
old_state = self.state
|
|
224
|
+
self.state = new_state
|
|
225
|
+
self.metrics.state_changes += 1
|
|
226
|
+
|
|
227
|
+
logger.info(f"State: {old_state.value} → {new_state.value}")
|
|
228
|
+
|
|
229
|
+
if self.on_state_change:
|
|
230
|
+
try:
|
|
231
|
+
self.on_state_change(old_state, new_state)
|
|
232
|
+
except Exception as e:
|
|
233
|
+
logger.error(f"Error in state_change callback: {e}", exc_info=True)
|
|
234
|
+
|
|
235
|
+
# Trigger recording callbacks
|
|
236
|
+
if new_state == RecorderState.RECORDING and self.on_recording_start:
|
|
237
|
+
self.metrics.recording_start_time = time.time()
|
|
238
|
+
try:
|
|
239
|
+
self.on_recording_start()
|
|
240
|
+
except Exception as e:
|
|
241
|
+
logger.error(f"Error in recording_start callback: {e}", exc_info=True)
|
|
242
|
+
|
|
243
|
+
elif old_state == RecorderState.RECORDING and new_state != RecorderState.RECORDING:
|
|
244
|
+
self.metrics.recording_stop_time = time.time()
|
|
245
|
+
if self.on_recording_stop:
|
|
246
|
+
try:
|
|
247
|
+
self.on_recording_stop(self.metrics)
|
|
248
|
+
except Exception as e:
|
|
249
|
+
logger.error(f"Error in recording_stop callback: {e}", exc_info=True)
|
|
250
|
+
|
|
251
|
+
def _create_socket(self) -> socket.socket:
|
|
252
|
+
"""Create and configure RTP receive socket"""
|
|
253
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
254
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
255
|
+
|
|
256
|
+
if hasattr(socket, 'SO_REUSEPORT'):
|
|
257
|
+
try:
|
|
258
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
|
|
259
|
+
except OSError:
|
|
260
|
+
pass
|
|
261
|
+
|
|
262
|
+
# Bind to port
|
|
263
|
+
sock.bind(('0.0.0.0', self.channel.port))
|
|
264
|
+
|
|
265
|
+
# Join multicast group
|
|
266
|
+
mreq = struct.pack('=4s4s',
|
|
267
|
+
socket.inet_aton(self.channel.multicast_address),
|
|
268
|
+
socket.inet_aton('0.0.0.0'))
|
|
269
|
+
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
|
|
270
|
+
|
|
271
|
+
sock.settimeout(1.0) # Allow periodic checks of self.running
|
|
272
|
+
|
|
273
|
+
logger.info(f"Listening on {self.channel.multicast_address}:{self.channel.port}")
|
|
274
|
+
|
|
275
|
+
return sock
|
|
276
|
+
|
|
277
|
+
def _validate_packet(self, header: RTPHeader) -> bool:
|
|
278
|
+
"""
|
|
279
|
+
Validate RTP packet and update state
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
True if packet should be processed, False if dropped
|
|
283
|
+
"""
|
|
284
|
+
# Check SSRC - always filter wrong SSRC
|
|
285
|
+
if header.ssrc != self.channel.ssrc:
|
|
286
|
+
logger.debug(f"Wrong SSRC: {header.ssrc} (expected {self.channel.ssrc})")
|
|
287
|
+
return False
|
|
288
|
+
|
|
289
|
+
# First packet
|
|
290
|
+
if self.last_sequence is None:
|
|
291
|
+
self.last_sequence = header.sequence
|
|
292
|
+
self.last_timestamp = header.timestamp
|
|
293
|
+
return True
|
|
294
|
+
|
|
295
|
+
# Check sequence number (track metrics even in pass_all mode)
|
|
296
|
+
expected_seq = (self.last_sequence + 1) & 0xFFFF
|
|
297
|
+
if header.sequence != expected_seq:
|
|
298
|
+
seq_gap = (header.sequence - expected_seq) & 0xFFFF
|
|
299
|
+
|
|
300
|
+
if seq_gap > self.max_packet_gap:
|
|
301
|
+
logger.warning(
|
|
302
|
+
f"Large sequence gap: {seq_gap} packets "
|
|
303
|
+
f"(got {header.sequence}, expected {expected_seq})"
|
|
304
|
+
)
|
|
305
|
+
self.metrics.sequence_errors += 1
|
|
306
|
+
|
|
307
|
+
# In pass_all mode, don't trigger resync - just log and continue
|
|
308
|
+
if not self.pass_all_packets:
|
|
309
|
+
# Trigger resync if recording
|
|
310
|
+
if self.state == RecorderState.RECORDING:
|
|
311
|
+
self._change_state(RecorderState.RESYNC)
|
|
312
|
+
self.resync_good_packets = 0
|
|
313
|
+
return False
|
|
314
|
+
else:
|
|
315
|
+
self.metrics.packets_dropped += seq_gap - 1
|
|
316
|
+
|
|
317
|
+
# Check timestamp progression
|
|
318
|
+
if self.last_timestamp is not None:
|
|
319
|
+
ts_delta = (header.timestamp - self.last_timestamp) & 0xFFFFFFFF
|
|
320
|
+
|
|
321
|
+
# Detect large jumps (more than 1 second worth)
|
|
322
|
+
if ts_delta > self.channel.sample_rate:
|
|
323
|
+
logger.warning(
|
|
324
|
+
f"Timestamp jump: {ts_delta} samples "
|
|
325
|
+
f"({ts_delta / self.channel.sample_rate:.2f}s)"
|
|
326
|
+
)
|
|
327
|
+
self.metrics.timestamp_jumps += 1
|
|
328
|
+
|
|
329
|
+
self.last_sequence = header.sequence
|
|
330
|
+
self.last_timestamp = header.timestamp
|
|
331
|
+
|
|
332
|
+
# In pass_all mode, skip resync state handling - always deliver
|
|
333
|
+
if self.pass_all_packets:
|
|
334
|
+
return True
|
|
335
|
+
|
|
336
|
+
# Handle resync state (original behavior)
|
|
337
|
+
if self.state == RecorderState.RESYNC:
|
|
338
|
+
self.resync_good_packets += 1
|
|
339
|
+
if self.resync_good_packets >= self.resync_threshold:
|
|
340
|
+
logger.info(f"Resync successful after {self.resync_good_packets} packets")
|
|
341
|
+
self._change_state(RecorderState.RECORDING)
|
|
342
|
+
return True
|
|
343
|
+
else:
|
|
344
|
+
return False # Drop packets during resync
|
|
345
|
+
|
|
346
|
+
return True
|
|
347
|
+
|
|
348
|
+
def _receive_loop(self):
|
|
349
|
+
"""Main packet receiving loop"""
|
|
350
|
+
try:
|
|
351
|
+
self.socket = self._create_socket()
|
|
352
|
+
|
|
353
|
+
while self.running:
|
|
354
|
+
try:
|
|
355
|
+
data, addr = self.socket.recvfrom(8192)
|
|
356
|
+
|
|
357
|
+
self.metrics.packets_received += 1
|
|
358
|
+
self.metrics.bytes_received += len(data)
|
|
359
|
+
|
|
360
|
+
# Parse RTP header
|
|
361
|
+
header = parse_rtp_header(data)
|
|
362
|
+
if not header:
|
|
363
|
+
logger.debug("Invalid RTP packet")
|
|
364
|
+
continue
|
|
365
|
+
|
|
366
|
+
# Validate packet
|
|
367
|
+
if not self._validate_packet(header):
|
|
368
|
+
continue
|
|
369
|
+
|
|
370
|
+
# Extract payload (skip RTP header + CSRC)
|
|
371
|
+
header_len = 12 + (4 * header.csrc_count)
|
|
372
|
+
payload = data[header_len:]
|
|
373
|
+
|
|
374
|
+
# Compute wall-clock time
|
|
375
|
+
wallclock = rtp_to_wallclock(header.timestamp, self.channel)
|
|
376
|
+
|
|
377
|
+
# Call packet callback
|
|
378
|
+
if self.on_packet:
|
|
379
|
+
try:
|
|
380
|
+
self.on_packet(header, payload, wallclock)
|
|
381
|
+
except Exception as e:
|
|
382
|
+
logger.error(f"Error in packet callback: {e}", exc_info=True)
|
|
383
|
+
|
|
384
|
+
except socket.timeout:
|
|
385
|
+
continue
|
|
386
|
+
except Exception as e:
|
|
387
|
+
logger.error(f"Error receiving packet: {e}", exc_info=True)
|
|
388
|
+
|
|
389
|
+
finally:
|
|
390
|
+
if self.socket:
|
|
391
|
+
try:
|
|
392
|
+
self.socket.close()
|
|
393
|
+
except Exception:
|
|
394
|
+
pass # Ignore errors during cleanup
|
|
395
|
+
self.socket = None
|
|
396
|
+
|
|
397
|
+
def start(self):
|
|
398
|
+
"""Start receiving RTP packets"""
|
|
399
|
+
if self.running:
|
|
400
|
+
logger.warning("Recorder already running")
|
|
401
|
+
return
|
|
402
|
+
|
|
403
|
+
logger.info(f"Starting RTP recorder for SSRC {self.channel.ssrc}")
|
|
404
|
+
|
|
405
|
+
self.running = True
|
|
406
|
+
self.thread = threading.Thread(target=self._receive_loop, daemon=True)
|
|
407
|
+
self.thread.start()
|
|
408
|
+
|
|
409
|
+
self._change_state(RecorderState.ARMED)
|
|
410
|
+
|
|
411
|
+
def stop(self):
|
|
412
|
+
"""Stop receiving RTP packets"""
|
|
413
|
+
if not self.running:
|
|
414
|
+
return
|
|
415
|
+
|
|
416
|
+
logger.info("Stopping RTP recorder")
|
|
417
|
+
|
|
418
|
+
self.running = False
|
|
419
|
+
if self.thread:
|
|
420
|
+
self.thread.join(timeout=5.0)
|
|
421
|
+
self.thread = None
|
|
422
|
+
|
|
423
|
+
self._change_state(RecorderState.IDLE)
|
|
424
|
+
|
|
425
|
+
def start_recording(self):
|
|
426
|
+
"""Transition from ARMED to RECORDING"""
|
|
427
|
+
if self.state == RecorderState.ARMED:
|
|
428
|
+
self._change_state(RecorderState.RECORDING)
|
|
429
|
+
else:
|
|
430
|
+
logger.warning(f"Cannot start recording from state {self.state.value}")
|
|
431
|
+
|
|
432
|
+
def stop_recording(self):
|
|
433
|
+
"""Transition from RECORDING back to ARMED"""
|
|
434
|
+
if self.state in (RecorderState.RECORDING, RecorderState.RESYNC):
|
|
435
|
+
self._change_state(RecorderState.ARMED)
|
|
436
|
+
else:
|
|
437
|
+
logger.warning(f"Cannot stop recording from state {self.state.value}")
|
|
438
|
+
|
|
439
|
+
def get_metrics(self) -> Dict[str, Any]:
|
|
440
|
+
"""Get current recording metrics"""
|
|
441
|
+
return self.metrics.to_dict()
|
|
442
|
+
|
|
443
|
+
def reset_metrics(self):
|
|
444
|
+
"""Reset all metrics"""
|
|
445
|
+
self.metrics = RecordingMetrics()
|
|
446
|
+
|
|
447
|
+
def __del__(self):
|
|
448
|
+
"""
|
|
449
|
+
Ensure recorder is stopped on garbage collection
|
|
450
|
+
|
|
451
|
+
This provides a safety net for unclosed recorders and helps
|
|
452
|
+
detect resource leaks during development.
|
|
453
|
+
"""
|
|
454
|
+
try:
|
|
455
|
+
self.stop()
|
|
456
|
+
except Exception:
|
|
457
|
+
pass # Can't raise exceptions in __del__
|