ka9q-python 3.2.6__tar.gz → 3.3.0__tar.gz

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.
Files changed (67) hide show
  1. {ka9q_python-3.2.6/ka9q_python.egg-info → ka9q_python-3.3.0}/PKG-INFO +20 -1
  2. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/README.md +19 -0
  3. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/ka9q/__init__.py +45 -13
  4. ka9q_python-3.3.0/ka9q/managed_stream.py +508 -0
  5. ka9q_python-3.3.0/ka9q/monitor.py +150 -0
  6. {ka9q_python-3.2.6 → ka9q_python-3.3.0/ka9q_python.egg-info}/PKG-INFO +20 -1
  7. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/ka9q_python.egg-info/SOURCES.txt +4 -0
  8. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/pyproject.toml +1 -1
  9. ka9q_python-3.3.0/tests/test_managed_stream_recovery.py +204 -0
  10. ka9q_python-3.3.0/tests/test_monitor.py +76 -0
  11. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/LICENSE +0 -0
  12. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/MANIFEST.in +0 -0
  13. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/examples/advanced_features_demo.py +0 -0
  14. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/examples/channel_cleanup_example.py +0 -0
  15. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/examples/codar_oceanography.py +0 -0
  16. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/examples/discover_example.py +0 -0
  17. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/examples/grape_integration_example.py +0 -0
  18. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/examples/hf_band_scanner.py +0 -0
  19. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/examples/rtp_recorder_example.py +0 -0
  20. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/examples/simple_am_radio.py +0 -0
  21. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/examples/stream_example.py +0 -0
  22. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/examples/superdarn_recorder.py +0 -0
  23. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/examples/test_channel_operations.py +0 -0
  24. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/examples/test_improvements.py +0 -0
  25. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/examples/test_timing_fields.py +0 -0
  26. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/examples/tune.py +0 -0
  27. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/examples/tune_example.py +0 -0
  28. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/ka9q/addressing.py +0 -0
  29. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/ka9q/control.py +0 -0
  30. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/ka9q/discovery.py +0 -0
  31. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/ka9q/exceptions.py +0 -0
  32. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/ka9q/resequencer.py +0 -0
  33. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/ka9q/rtp_recorder.py +0 -0
  34. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/ka9q/stream.py +0 -0
  35. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/ka9q/stream_quality.py +0 -0
  36. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/ka9q/types.py +0 -0
  37. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/ka9q/utils.py +0 -0
  38. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/ka9q_python.egg-info/dependency_links.txt +0 -0
  39. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/ka9q_python.egg-info/requires.txt +0 -0
  40. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/ka9q_python.egg-info/top_level.txt +0 -0
  41. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/setup.cfg +0 -0
  42. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/setup.py +0 -0
  43. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/__init__.py +0 -0
  44. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/conftest.py +0 -0
  45. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_addressing.py +0 -0
  46. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_channel_verification.py +0 -0
  47. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_create_split_encoding.py +0 -0
  48. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_decode_functions.py +0 -0
  49. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_encode_functions.py +0 -0
  50. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_encode_socket.py +0 -0
  51. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_ensure_channel_encoding.py +0 -0
  52. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_integration.py +0 -0
  53. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_iq_20khz_f32.py +0 -0
  54. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_listen_multicast.py +0 -0
  55. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_multihomed.py +0 -0
  56. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_native_discovery.py +0 -0
  57. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_performance_fixes.py +0 -0
  58. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_remove_channel.py +0 -0
  59. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_rtp_recorder.py +0 -0
  60. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_security_features.py +0 -0
  61. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_ssrc_dest_unit.py +0 -0
  62. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_ssrc_encoding_unit.py +0 -0
  63. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_tune.py +0 -0
  64. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_tune_cli.py +0 -0
  65. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_tune_debug.py +0 -0
  66. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_tune_live.py +0 -0
  67. {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_tune_method.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ka9q-python
3
- Version: 3.2.6
3
+ Version: 3.3.0
4
4
  Summary: Python interface for ka9q-radio control and monitoring
5
5
  Home-page: https://github.com/mijahauan/ka9q-python
6
6
  Author: Michael Hauan AC0G
@@ -192,6 +192,25 @@ control = RadiodControl("radiod.local", interface=my_interface)
192
192
  channels = discover_channels("radiod.local", interface=my_interface)
193
193
  ```
194
194
 
195
+ ### Automatic Channel Recovery
196
+
197
+ ensure your channels survive radiod restarts:
198
+
199
+ ```python
200
+ from ka9q import RadiodControl, ChannelMonitor
201
+
202
+ control = RadiodControl("radiod.local")
203
+ monitor = ChannelMonitor(control)
204
+ monitor.start()
205
+
206
+ # This channel will be automatically re-created if it disappears
207
+ monitor.monitor_channel(
208
+ frequency_hz=14.074e6,
209
+ preset="usb",
210
+ sample_rate=12000
211
+ )
212
+ ```
213
+
195
214
  ## Documentation
196
215
 
197
216
  For detailed information, please refer to the documentation in the `docs/` directory:
@@ -157,6 +157,25 @@ control = RadiodControl("radiod.local", interface=my_interface)
157
157
  channels = discover_channels("radiod.local", interface=my_interface)
158
158
  ```
159
159
 
160
+ ### Automatic Channel Recovery
161
+
162
+ ensure your channels survive radiod restarts:
163
+
164
+ ```python
165
+ from ka9q import RadiodControl, ChannelMonitor
166
+
167
+ control = RadiodControl("radiod.local")
168
+ monitor = ChannelMonitor(control)
169
+ monitor.start()
170
+
171
+ # This channel will be automatically re-created if it disappears
172
+ monitor.monitor_channel(
173
+ frequency_hz=14.074e6,
174
+ preset="usb",
175
+ sample_rate=12000
176
+ )
177
+ ```
178
+
160
179
  ## Documentation
161
180
 
162
181
  For detailed information, please refer to the documentation in the `docs/` directory:
@@ -5,21 +5,43 @@ A general-purpose library for controlling ka9q-radio channels and streams.
5
5
  No assumptions about your application - works for everything from AM radio
6
6
  listening to SuperDARN radar monitoring.
7
7
 
8
- Recommended usage (high-level API):
8
+ Recommended usage (self-healing stream):
9
+ from ka9q import RadiodControl, ManagedStream
10
+
11
+ def on_samples(samples, quality):
12
+ process(samples)
13
+
14
+ def on_dropped(reason):
15
+ print(f"Stream dropped: {reason}")
16
+
17
+ def on_restored(channel):
18
+ print(f"Stream restored: {channel.frequency/1e6:.3f} MHz")
19
+
20
+ with RadiodControl("radiod.local") as control:
21
+ # ManagedStream auto-heals through radiod restarts
22
+ stream = ManagedStream(
23
+ control=control,
24
+ frequency_hz=14.074e6,
25
+ preset="usb",
26
+ sample_rate=12000,
27
+ on_samples=on_samples,
28
+ on_stream_dropped=on_dropped,
29
+ on_stream_restored=on_restored,
30
+ )
31
+ stream.start()
32
+ # ... stream continues through disruptions ...
33
+ stream.stop()
34
+
35
+ Manual channel management:
9
36
  from ka9q import RadiodControl, RadiodStream
10
37
 
11
38
  with RadiodControl("radiod.local") as control:
12
- # Request a channel with specific characteristics
13
- # ka9q-python handles discovery, creation, and verification
39
+ # ensure_channel() verifies the channel before returning
14
40
  channel = control.ensure_channel(
15
41
  frequency_hz=14.074e6,
16
42
  preset="usb",
17
43
  sample_rate=12000
18
44
  )
19
- # Channel is verified and ready for streaming
20
- print(f"Channel ready: {channel.frequency/1e6:.3f} MHz")
21
-
22
- # Start receiving samples
23
45
  stream = RadiodStream(channel, on_samples=my_callback)
24
46
  stream.start()
25
47
 
@@ -27,18 +49,14 @@ Lower-level usage (explicit control):
27
49
  from ka9q import RadiodControl, allocate_ssrc
28
50
 
29
51
  with RadiodControl("radiod.local") as control:
30
- # SSRC-free API - SSRC auto-allocated
31
52
  ssrc = control.create_channel(
32
53
  frequency_hz=10.0e6,
33
54
  preset="am",
34
55
  sample_rate=12000
35
56
  )
36
57
  print(f"Created channel with SSRC: {ssrc}")
37
-
38
- # Or use allocate_ssrc() directly for coordination
39
- ssrc = allocate_ssrc(10.0e6, "iq", 16000)
40
58
  """
41
- __version__ = '3.2.6'
59
+ __version__ = '3.2.7'
42
60
  __author__ = 'Michael Hauan AC0G'
43
61
 
44
62
  from .control import RadiodControl, allocate_ssrc
@@ -72,6 +90,11 @@ from .resequencer import (
72
90
  from .stream import (
73
91
  RadiodStream,
74
92
  )
93
+ from .managed_stream import (
94
+ ManagedStream,
95
+ ManagedStreamStats,
96
+ StreamState,
97
+ )
75
98
 
76
99
  __all__ = [
77
100
  # Control
@@ -103,7 +126,7 @@ __all__ = [
103
126
  'parse_rtp_header',
104
127
  'rtp_to_wallclock',
105
128
 
106
- # Stream API (sample-oriented) - NEW
129
+ # Stream API (sample-oriented)
107
130
  'RadiodStream',
108
131
  'StreamQuality',
109
132
  'GapSource',
@@ -111,7 +134,16 @@ __all__ = [
111
134
  'PacketResequencer',
112
135
  'RTPPacket',
113
136
  'ResequencerStats',
137
+
138
+ # Managed Stream (self-healing)
139
+ 'ManagedStream',
140
+ 'ManagedStreamStats',
141
+ 'StreamState',
142
+
143
+ # Utilities
114
144
  'generate_multicast_ip',
145
+ 'ChannelMonitor',
115
146
  ]
116
147
 
117
148
  from .addressing import generate_multicast_ip
149
+ from .monitor import ChannelMonitor
@@ -0,0 +1,508 @@
1
+ """
2
+ ManagedStream - Self-Healing RTP Stream with Automatic Restoration
3
+
4
+ Provides a robust stream interface that automatically detects when an RTP stream
5
+ has dropped (e.g., radiod restart) and restores it as quickly as possible.
6
+
7
+ Features:
8
+ - Stream health monitoring with configurable timeout
9
+ - Automatic channel restoration via ensure_channel()
10
+ - Callbacks for stream drop and restoration events
11
+ - Continuous sample delivery through disruptions (with gap tracking)
12
+
13
+ Usage:
14
+ from ka9q import RadiodControl, ManagedStream
15
+
16
+ def on_samples(samples, quality):
17
+ process(samples)
18
+
19
+ def on_stream_dropped(reason):
20
+ print(f"Stream dropped: {reason}")
21
+
22
+ def on_stream_restored(channel):
23
+ print(f"Stream restored: {channel.frequency/1e6:.3f} MHz")
24
+
25
+ with RadiodControl("radiod.local") as control:
26
+ stream = ManagedStream(
27
+ control=control,
28
+ frequency_hz=14.074e6,
29
+ preset="usb",
30
+ sample_rate=12000,
31
+ on_samples=on_samples,
32
+ on_stream_dropped=on_stream_dropped,
33
+ on_stream_restored=on_stream_restored,
34
+ )
35
+ stream.start()
36
+ # ... stream auto-heals through radiod restarts ...
37
+ stream.stop()
38
+ """
39
+
40
+ import logging
41
+ import threading
42
+ import time
43
+ from dataclasses import dataclass, field
44
+ from datetime import datetime, timezone
45
+ from enum import Enum
46
+ from typing import Optional, Callable, List
47
+
48
+ import numpy as np
49
+
50
+ from .discovery import ChannelInfo
51
+ from .stream import RadiodStream, SampleCallback
52
+ from .stream_quality import StreamQuality, GapSource, GapEvent
53
+
54
+ logger = logging.getLogger(__name__)
55
+
56
+
57
+ class StreamState(Enum):
58
+ """Current state of the managed stream"""
59
+ STOPPED = "stopped"
60
+ STARTING = "starting"
61
+ HEALTHY = "healthy"
62
+ DROPPED = "dropped"
63
+ RESTORING = "restoring"
64
+
65
+
66
+ # Callback type aliases
67
+ StreamDroppedCallback = Callable[[str], None] # reason
68
+ StreamRestoredCallback = Callable[[ChannelInfo], None] # new channel info
69
+
70
+
71
+ @dataclass
72
+ class ManagedStreamStats:
73
+ """Statistics for managed stream health and restoration"""
74
+ state: StreamState = StreamState.STOPPED
75
+ total_drops: int = 0
76
+ total_restorations: int = 0
77
+ last_drop_time: Optional[str] = None
78
+ last_restore_time: Optional[str] = None
79
+ last_drop_reason: Optional[str] = None
80
+ current_healthy_duration_sec: float = 0.0
81
+ total_healthy_duration_sec: float = 0.0
82
+ total_dropped_duration_sec: float = 0.0
83
+
84
+ def copy(self) -> 'ManagedStreamStats':
85
+ """Return a copy of stats"""
86
+ return ManagedStreamStats(
87
+ state=self.state,
88
+ total_drops=self.total_drops,
89
+ total_restorations=self.total_restorations,
90
+ last_drop_time=self.last_drop_time,
91
+ last_restore_time=self.last_restore_time,
92
+ last_drop_reason=self.last_drop_reason,
93
+ current_healthy_duration_sec=self.current_healthy_duration_sec,
94
+ total_healthy_duration_sec=self.total_healthy_duration_sec,
95
+ total_dropped_duration_sec=self.total_dropped_duration_sec,
96
+ )
97
+
98
+
99
+ class ManagedStream:
100
+ """
101
+ Self-healing RTP stream with automatic restoration.
102
+
103
+ Monitors stream health and automatically restores the channel when
104
+ the RTP stream drops (e.g., due to radiod restart).
105
+
106
+ The stream is considered "dropped" when no packets are received for
107
+ longer than the configured timeout. Upon detection, the class will:
108
+ 1. Notify via on_stream_dropped callback
109
+ 2. Attempt to restore the channel using ensure_channel()
110
+ 3. Restart the underlying RadiodStream
111
+ 4. Notify via on_stream_restored callback
112
+
113
+ Sample delivery continues through disruptions, with gaps tracked
114
+ in the StreamQuality metadata.
115
+ """
116
+
117
+ def __init__(
118
+ self,
119
+ control, # RadiodControl instance
120
+ frequency_hz: float,
121
+ preset: str = "iq",
122
+ sample_rate: int = 16000,
123
+ agc_enable: int = 0,
124
+ gain: float = 0.0,
125
+ destination: Optional[str] = None,
126
+ on_samples: Optional[SampleCallback] = None,
127
+ on_stream_dropped: Optional[StreamDroppedCallback] = None,
128
+ on_stream_restored: Optional[StreamRestoredCallback] = None,
129
+ drop_timeout_sec: float = 3.0,
130
+ restore_interval_sec: float = 1.0,
131
+ max_restore_attempts: int = 0, # 0 = unlimited
132
+ samples_per_packet: int = 320,
133
+ resequence_buffer_size: int = 64,
134
+ deliver_interval_packets: int = 10,
135
+ ):
136
+ """
137
+ Initialize ManagedStream.
138
+
139
+ Args:
140
+ control: RadiodControl instance for channel management
141
+ frequency_hz: Center frequency in Hz
142
+ preset: Demodulation mode ("iq", "usb", "lsb", "am", "fm", "cw")
143
+ sample_rate: Output sample rate in Hz
144
+ agc_enable: Enable AGC (0=off, 1=on)
145
+ gain: Manual gain in dB (when AGC off)
146
+ destination: RTP destination multicast address (optional)
147
+ on_samples: Callback(samples, quality) for sample delivery
148
+ on_stream_dropped: Callback(reason) when stream drops
149
+ on_stream_restored: Callback(channel) when stream is restored
150
+ drop_timeout_sec: Seconds without packets before declaring drop (default: 3.0)
151
+ restore_interval_sec: Seconds between restore attempts (default: 1.0)
152
+ max_restore_attempts: Max restore attempts, 0=unlimited (default: 0)
153
+ samples_per_packet: Expected samples per RTP packet
154
+ resequence_buffer_size: Packets to buffer for resequencing
155
+ deliver_interval_packets: Deliver to callback every N packets
156
+ """
157
+ self._control = control
158
+ self._frequency_hz = frequency_hz
159
+ self._preset = preset
160
+ self._sample_rate = sample_rate
161
+ self._agc_enable = agc_enable
162
+ self._gain = gain
163
+ self._destination = destination
164
+
165
+ # Callbacks
166
+ self._on_samples = on_samples
167
+ self._on_stream_dropped = on_stream_dropped
168
+ self._on_stream_restored = on_stream_restored
169
+
170
+ # Health monitoring config
171
+ self._drop_timeout_sec = drop_timeout_sec
172
+ self._restore_interval_sec = restore_interval_sec
173
+ self._max_restore_attempts = max_restore_attempts
174
+
175
+ # RadiodStream config
176
+ self._samples_per_packet = samples_per_packet
177
+ self._resequence_buffer_size = resequence_buffer_size
178
+ self._deliver_interval_packets = deliver_interval_packets
179
+
180
+ # State
181
+ self._state = StreamState.STOPPED
182
+ self._channel: Optional[ChannelInfo] = None
183
+ self._stream: Optional[RadiodStream] = None
184
+ self._running = False
185
+
186
+ # Health monitoring
187
+ self._last_packet_time: float = 0.0
188
+ self._healthy_since: float = 0.0
189
+ self._dropped_since: float = 0.0
190
+ self._restore_attempts: int = 0
191
+
192
+ # Threading
193
+ self._monitor_thread: Optional[threading.Thread] = None
194
+ self._lock = threading.Lock()
195
+
196
+ # Statistics
197
+ self._stats = ManagedStreamStats()
198
+
199
+ # Aggregate quality across stream restarts
200
+ self._total_quality = StreamQuality()
201
+
202
+ def start(self) -> ChannelInfo:
203
+ """
204
+ Start the managed stream.
205
+
206
+ Establishes the channel and begins receiving samples.
207
+
208
+ Returns:
209
+ ChannelInfo for the established channel
210
+
211
+ Raises:
212
+ TimeoutError: If channel cannot be established
213
+ """
214
+ if self._running:
215
+ logger.warning("ManagedStream already running")
216
+ return self._channel
217
+
218
+ logger.info(
219
+ f"ManagedStream starting: {self._frequency_hz/1e6:.3f} MHz, "
220
+ f"{self._preset}, {self._sample_rate} Hz"
221
+ )
222
+
223
+ self._state = StreamState.STARTING
224
+ self._stats.state = StreamState.STARTING
225
+ self._running = True
226
+
227
+ # Establish initial channel
228
+ self._channel = self._control.ensure_channel(
229
+ frequency_hz=self._frequency_hz,
230
+ preset=self._preset,
231
+ sample_rate=self._sample_rate,
232
+ agc_enable=self._agc_enable,
233
+ gain=self._gain,
234
+ destination=self._destination,
235
+ )
236
+
237
+ # Start the underlying stream
238
+ self._start_stream()
239
+
240
+ # Start health monitor
241
+ self._monitor_thread = threading.Thread(
242
+ target=self._health_monitor_loop,
243
+ daemon=True,
244
+ name="ManagedStream-Monitor"
245
+ )
246
+ self._monitor_thread.start()
247
+
248
+ logger.info(
249
+ f"ManagedStream started: SSRC={self._channel.ssrc}, "
250
+ f"{self._channel.multicast_address}:{self._channel.port}"
251
+ )
252
+
253
+ return self._channel
254
+
255
+ def stop(self) -> ManagedStreamStats:
256
+ """
257
+ Stop the managed stream.
258
+
259
+ Returns:
260
+ Final ManagedStreamStats with health statistics
261
+ """
262
+ if not self._running:
263
+ return self._stats.copy()
264
+
265
+ logger.info("ManagedStream stopping...")
266
+ self._running = False
267
+
268
+ # Stop monitor thread
269
+ if self._monitor_thread:
270
+ self._monitor_thread.join(timeout=5.0)
271
+ self._monitor_thread = None
272
+
273
+ # Stop underlying stream
274
+ self._stop_stream()
275
+
276
+ # Update final stats
277
+ with self._lock:
278
+ if self._state == StreamState.HEALTHY:
279
+ self._stats.total_healthy_duration_sec += time.time() - self._healthy_since
280
+ elif self._state == StreamState.DROPPED:
281
+ self._stats.total_dropped_duration_sec += time.time() - self._dropped_since
282
+
283
+ self._state = StreamState.STOPPED
284
+ self._stats.state = StreamState.STOPPED
285
+
286
+ logger.info(
287
+ f"ManagedStream stopped. Drops: {self._stats.total_drops}, "
288
+ f"Restorations: {self._stats.total_restorations}"
289
+ )
290
+
291
+ return self._stats.copy()
292
+
293
+ def _start_stream(self):
294
+ """Start the underlying RadiodStream."""
295
+ if self._stream:
296
+ self._stop_stream()
297
+
298
+ self._stream = RadiodStream(
299
+ channel=self._channel,
300
+ on_samples=self._handle_samples,
301
+ samples_per_packet=self._samples_per_packet,
302
+ resequence_buffer_size=self._resequence_buffer_size,
303
+ deliver_interval_packets=self._deliver_interval_packets,
304
+ )
305
+ self._stream.start()
306
+
307
+ # Reset health tracking
308
+ self._last_packet_time = time.time()
309
+ self._healthy_since = time.time()
310
+ self._restore_attempts = 0
311
+
312
+ with self._lock:
313
+ self._state = StreamState.HEALTHY
314
+ self._stats.state = StreamState.HEALTHY
315
+
316
+ def _stop_stream(self):
317
+ """Stop the underlying RadiodStream."""
318
+ if self._stream:
319
+ quality = self._stream.stop()
320
+ # Aggregate quality stats
321
+ self._total_quality.rtp_packets_received += quality.rtp_packets_received
322
+ self._total_quality.total_samples_delivered += quality.total_samples_delivered
323
+ self._total_quality.total_gap_events += quality.total_gap_events
324
+ self._stream = None
325
+
326
+ def _handle_samples(self, samples: np.ndarray, quality: StreamQuality):
327
+ """Handle samples from underlying stream, update health tracking."""
328
+ # Update last packet time for health monitoring
329
+ self._last_packet_time = time.time()
330
+
331
+ # Forward to user callback
332
+ if self._on_samples:
333
+ try:
334
+ self._on_samples(samples, quality)
335
+ except Exception as e:
336
+ logger.error(f"Error in sample callback: {e}", exc_info=True)
337
+
338
+ def _health_monitor_loop(self):
339
+ """Monitor stream health and trigger restoration when needed."""
340
+ check_interval = min(0.5, self._drop_timeout_sec / 4)
341
+
342
+ while self._running:
343
+ time.sleep(check_interval)
344
+
345
+ if not self._running:
346
+ break
347
+
348
+ with self._lock:
349
+ current_state = self._state
350
+
351
+ if current_state == StreamState.HEALTHY:
352
+ # Check for drop
353
+ time_since_packet = time.time() - self._last_packet_time
354
+
355
+ if time_since_packet > self._drop_timeout_sec:
356
+ self._handle_stream_drop(
357
+ f"No packets for {time_since_packet:.1f}s "
358
+ f"(timeout: {self._drop_timeout_sec}s)"
359
+ )
360
+
361
+ elif current_state == StreamState.DROPPED:
362
+ # Attempt restoration
363
+ self._attempt_restore()
364
+
365
+ def _handle_stream_drop(self, reason: str):
366
+ """Handle detected stream drop."""
367
+ logger.warning(f"Stream drop detected: {reason}")
368
+
369
+ with self._lock:
370
+ # Update stats
371
+ if self._state == StreamState.HEALTHY:
372
+ self._stats.total_healthy_duration_sec += time.time() - self._healthy_since
373
+
374
+ self._state = StreamState.DROPPED
375
+ self._stats.state = StreamState.DROPPED
376
+ self._stats.total_drops += 1
377
+ self._stats.last_drop_time = datetime.now(timezone.utc).isoformat()
378
+ self._stats.last_drop_reason = reason
379
+ self._dropped_since = time.time()
380
+ self._restore_attempts = 0
381
+
382
+ # Stop current stream
383
+ self._stop_stream()
384
+
385
+ # Notify callback
386
+ if self._on_stream_dropped:
387
+ try:
388
+ self._on_stream_dropped(reason)
389
+ except Exception as e:
390
+ logger.error(f"Error in stream_dropped callback: {e}", exc_info=True)
391
+
392
+ def _attempt_restore(self):
393
+ """Attempt to restore the stream."""
394
+ # Check max attempts
395
+ if self._max_restore_attempts > 0 and self._restore_attempts >= self._max_restore_attempts:
396
+ logger.error(
397
+ f"Max restore attempts ({self._max_restore_attempts}) reached, giving up"
398
+ )
399
+ return
400
+
401
+ self._restore_attempts += 1
402
+ logger.info(f"Attempting stream restoration (attempt {self._restore_attempts})")
403
+
404
+ with self._lock:
405
+ self._state = StreamState.RESTORING
406
+ self._stats.state = StreamState.RESTORING
407
+
408
+ try:
409
+ # Re-establish channel via ensure_channel
410
+ self._channel = self._control.ensure_channel(
411
+ frequency_hz=self._frequency_hz,
412
+ preset=self._preset,
413
+ sample_rate=self._sample_rate,
414
+ agc_enable=self._agc_enable,
415
+ gain=self._gain,
416
+ destination=self._destination,
417
+ timeout=self._restore_interval_sec * 2, # Give it some time
418
+ )
419
+
420
+ # Restart stream
421
+ self._start_stream()
422
+
423
+ # Update stats
424
+ with self._lock:
425
+ self._stats.total_restorations += 1
426
+ self._stats.last_restore_time = datetime.now(timezone.utc).isoformat()
427
+ self._stats.total_dropped_duration_sec += time.time() - self._dropped_since
428
+
429
+ logger.info(
430
+ f"Stream restored: SSRC={self._channel.ssrc}, "
431
+ f"{self._channel.frequency/1e6:.3f} MHz"
432
+ )
433
+
434
+ # Notify callback
435
+ if self._on_stream_restored:
436
+ try:
437
+ self._on_stream_restored(self._channel)
438
+ except Exception as e:
439
+ logger.error(f"Error in stream_restored callback: {e}", exc_info=True)
440
+
441
+ except TimeoutError as e:
442
+ logger.warning(f"Restore attempt {self._restore_attempts} failed: {e}")
443
+ with self._lock:
444
+ self._state = StreamState.DROPPED
445
+ self._stats.state = StreamState.DROPPED
446
+
447
+ # Wait before next attempt
448
+ time.sleep(self._restore_interval_sec)
449
+
450
+ except Exception as e:
451
+ logger.error(f"Restore attempt {self._restore_attempts} error: {e}", exc_info=True)
452
+ with self._lock:
453
+ self._state = StreamState.DROPPED
454
+ self._stats.state = StreamState.DROPPED
455
+
456
+ # Wait before next attempt
457
+ time.sleep(self._restore_interval_sec)
458
+
459
+ @property
460
+ def state(self) -> StreamState:
461
+ """Current stream state."""
462
+ with self._lock:
463
+ return self._state
464
+
465
+ @property
466
+ def channel(self) -> Optional[ChannelInfo]:
467
+ """Current channel info (may change after restoration)."""
468
+ return self._channel
469
+
470
+ @property
471
+ def is_healthy(self) -> bool:
472
+ """True if stream is currently healthy and receiving packets."""
473
+ with self._lock:
474
+ return self._state == StreamState.HEALTHY
475
+
476
+ def get_stats(self) -> ManagedStreamStats:
477
+ """Get current health statistics (copy)."""
478
+ with self._lock:
479
+ stats = self._stats.copy()
480
+
481
+ # Update current duration
482
+ if self._state == StreamState.HEALTHY:
483
+ stats.current_healthy_duration_sec = time.time() - self._healthy_since
484
+
485
+ return stats
486
+
487
+ def get_quality(self) -> StreamQuality:
488
+ """Get current stream quality metrics."""
489
+ if self._stream:
490
+ return self._stream.get_quality()
491
+ return self._total_quality.copy()
492
+
493
+ def __enter__(self):
494
+ """Context manager entry."""
495
+ self.start()
496
+ return self
497
+
498
+ def __exit__(self, exc_type, exc_val, exc_tb):
499
+ """Context manager exit."""
500
+ self.stop()
501
+ return False
502
+
503
+ def __del__(self):
504
+ """Ensure stream is stopped on garbage collection."""
505
+ try:
506
+ self.stop()
507
+ except Exception:
508
+ pass