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.
- {ka9q_python-3.2.6/ka9q_python.egg-info → ka9q_python-3.3.0}/PKG-INFO +20 -1
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/README.md +19 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/ka9q/__init__.py +45 -13
- ka9q_python-3.3.0/ka9q/managed_stream.py +508 -0
- ka9q_python-3.3.0/ka9q/monitor.py +150 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0/ka9q_python.egg-info}/PKG-INFO +20 -1
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/ka9q_python.egg-info/SOURCES.txt +4 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/pyproject.toml +1 -1
- ka9q_python-3.3.0/tests/test_managed_stream_recovery.py +204 -0
- ka9q_python-3.3.0/tests/test_monitor.py +76 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/LICENSE +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/MANIFEST.in +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/examples/advanced_features_demo.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/examples/channel_cleanup_example.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/examples/codar_oceanography.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/examples/discover_example.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/examples/grape_integration_example.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/examples/hf_band_scanner.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/examples/rtp_recorder_example.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/examples/simple_am_radio.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/examples/stream_example.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/examples/superdarn_recorder.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/examples/test_channel_operations.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/examples/test_improvements.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/examples/test_timing_fields.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/examples/tune.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/examples/tune_example.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/ka9q/addressing.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/ka9q/control.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/ka9q/discovery.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/ka9q/exceptions.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/ka9q/resequencer.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/ka9q/rtp_recorder.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/ka9q/stream.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/ka9q/stream_quality.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/ka9q/types.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/ka9q/utils.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/ka9q_python.egg-info/dependency_links.txt +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/ka9q_python.egg-info/requires.txt +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/ka9q_python.egg-info/top_level.txt +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/setup.cfg +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/setup.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/__init__.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/conftest.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_addressing.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_channel_verification.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_create_split_encoding.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_decode_functions.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_encode_functions.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_encode_socket.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_ensure_channel_encoding.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_integration.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_iq_20khz_f32.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_listen_multicast.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_multihomed.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_native_discovery.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_performance_fixes.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_remove_channel.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_rtp_recorder.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_security_features.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_ssrc_dest_unit.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_ssrc_encoding_unit.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_tune.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_tune_cli.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_tune_debug.py +0 -0
- {ka9q_python-3.2.6 → ka9q_python-3.3.0}/tests/test_tune_live.py +0 -0
- {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.
|
|
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 (
|
|
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
|
-
#
|
|
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.
|
|
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)
|
|
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
|