ka9q-python 3.2.5__tar.gz → 3.2.7__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.5/ka9q_python.egg-info → ka9q_python-3.2.7}/PKG-INFO +36 -1
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/README.md +35 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/ka9q/__init__.py +3 -1
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/ka9q/control.py +49 -9
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/ka9q/discovery.py +5 -2
- ka9q_python-3.2.7/ka9q/monitor.py +150 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7/ka9q_python.egg-info}/PKG-INFO +36 -1
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/ka9q_python.egg-info/SOURCES.txt +5 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/pyproject.toml +1 -1
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/test_channel_verification.py +22 -14
- ka9q_python-3.2.7/tests/test_create_split_encoding.py +33 -0
- ka9q_python-3.2.7/tests/test_ensure_channel_encoding.py +75 -0
- ka9q_python-3.2.7/tests/test_monitor.py +76 -0
- ka9q_python-3.2.7/tests/test_ssrc_encoding_unit.py +35 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/LICENSE +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/MANIFEST.in +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/examples/advanced_features_demo.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/examples/channel_cleanup_example.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/examples/codar_oceanography.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/examples/discover_example.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/examples/grape_integration_example.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/examples/hf_band_scanner.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/examples/rtp_recorder_example.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/examples/simple_am_radio.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/examples/stream_example.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/examples/superdarn_recorder.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/examples/test_channel_operations.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/examples/test_improvements.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/examples/test_timing_fields.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/examples/tune.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/examples/tune_example.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/ka9q/addressing.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/ka9q/exceptions.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/ka9q/resequencer.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/ka9q/rtp_recorder.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/ka9q/stream.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/ka9q/stream_quality.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/ka9q/types.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/ka9q/utils.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/ka9q_python.egg-info/dependency_links.txt +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/ka9q_python.egg-info/requires.txt +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/ka9q_python.egg-info/top_level.txt +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/setup.cfg +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/setup.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/__init__.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/conftest.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/test_addressing.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/test_decode_functions.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/test_encode_functions.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/test_encode_socket.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/test_integration.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/test_iq_20khz_f32.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/test_listen_multicast.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/test_multihomed.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/test_native_discovery.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/test_performance_fixes.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/test_remove_channel.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/test_rtp_recorder.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/test_security_features.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/test_ssrc_dest_unit.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/test_tune.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/test_tune_cli.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/test_tune_debug.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/test_tune_live.py +0 -0
- {ka9q_python-3.2.5 → ka9q_python-3.2.7}/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.
|
|
3
|
+
Version: 3.2.7
|
|
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
|
|
@@ -99,6 +99,22 @@ control.create_channel(
|
|
|
99
99
|
)
|
|
100
100
|
|
|
101
101
|
# RTP stream now available with SSRC 10000000
|
|
102
|
+
|
|
103
|
+
### Request Specific Output Encoding
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from ka9q import RadiodControl, Encoding
|
|
107
|
+
|
|
108
|
+
control = RadiodControl("radiod.local")
|
|
109
|
+
|
|
110
|
+
# Create a channel with 32-bit float output (highest quality)
|
|
111
|
+
control.ensure_channel(
|
|
112
|
+
frequency_hz=14.074e6,
|
|
113
|
+
preset="usb",
|
|
114
|
+
sample_rate=12000,
|
|
115
|
+
encoding=Encoding.F32
|
|
116
|
+
)
|
|
117
|
+
```
|
|
102
118
|
```
|
|
103
119
|
|
|
104
120
|
### Monitor WSPR Bands
|
|
@@ -176,6 +192,25 @@ control = RadiodControl("radiod.local", interface=my_interface)
|
|
|
176
192
|
channels = discover_channels("radiod.local", interface=my_interface)
|
|
177
193
|
```
|
|
178
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
|
+
|
|
179
214
|
## Documentation
|
|
180
215
|
|
|
181
216
|
For detailed information, please refer to the documentation in the `docs/` directory:
|
|
@@ -64,6 +64,22 @@ control.create_channel(
|
|
|
64
64
|
)
|
|
65
65
|
|
|
66
66
|
# RTP stream now available with SSRC 10000000
|
|
67
|
+
|
|
68
|
+
### Request Specific Output Encoding
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from ka9q import RadiodControl, Encoding
|
|
72
|
+
|
|
73
|
+
control = RadiodControl("radiod.local")
|
|
74
|
+
|
|
75
|
+
# Create a channel with 32-bit float output (highest quality)
|
|
76
|
+
control.ensure_channel(
|
|
77
|
+
frequency_hz=14.074e6,
|
|
78
|
+
preset="usb",
|
|
79
|
+
sample_rate=12000,
|
|
80
|
+
encoding=Encoding.F32
|
|
81
|
+
)
|
|
82
|
+
```
|
|
67
83
|
```
|
|
68
84
|
|
|
69
85
|
### Monitor WSPR Bands
|
|
@@ -141,6 +157,25 @@ control = RadiodControl("radiod.local", interface=my_interface)
|
|
|
141
157
|
channels = discover_channels("radiod.local", interface=my_interface)
|
|
142
158
|
```
|
|
143
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
|
+
|
|
144
179
|
## Documentation
|
|
145
180
|
|
|
146
181
|
For detailed information, please refer to the documentation in the `docs/` directory:
|
|
@@ -38,7 +38,7 @@ Lower-level usage (explicit control):
|
|
|
38
38
|
# Or use allocate_ssrc() directly for coordination
|
|
39
39
|
ssrc = allocate_ssrc(10.0e6, "iq", 16000)
|
|
40
40
|
"""
|
|
41
|
-
__version__ = '3.2.
|
|
41
|
+
__version__ = '3.2.7'
|
|
42
42
|
__author__ = 'Michael Hauan AC0G'
|
|
43
43
|
|
|
44
44
|
from .control import RadiodControl, allocate_ssrc
|
|
@@ -112,6 +112,8 @@ __all__ = [
|
|
|
112
112
|
'RTPPacket',
|
|
113
113
|
'ResequencerStats',
|
|
114
114
|
'generate_multicast_ip',
|
|
115
|
+
'ChannelMonitor',
|
|
115
116
|
]
|
|
116
117
|
|
|
117
118
|
from .addressing import generate_multicast_ip
|
|
119
|
+
from .monitor import ChannelMonitor
|
|
@@ -73,7 +73,8 @@ def allocate_ssrc(
|
|
|
73
73
|
sample_rate: int = 16000,
|
|
74
74
|
agc: bool = False,
|
|
75
75
|
gain: float = 0.0,
|
|
76
|
-
destination: Optional[str] = None
|
|
76
|
+
destination: Optional[str] = None,
|
|
77
|
+
encoding: int = 0
|
|
77
78
|
) -> int:
|
|
78
79
|
"""
|
|
79
80
|
Allocate a deterministic SSRC from channel parameters.
|
|
@@ -110,7 +111,8 @@ def allocate_ssrc(
|
|
|
110
111
|
sample_rate, # Sample rate as-is
|
|
111
112
|
agc, # AGC boolean
|
|
112
113
|
round(gain, 1), # Gain rounded to 0.1 dB
|
|
113
|
-
destination
|
|
114
|
+
destination, # Destination address (None or string)
|
|
115
|
+
encoding # Encoding type (int)
|
|
114
116
|
)
|
|
115
117
|
# Keep positive, 31 bits (matches signal-recorder)
|
|
116
118
|
return hash(key) & 0x7FFFFFFF
|
|
@@ -1072,6 +1074,7 @@ class RadiodControl:
|
|
|
1072
1074
|
preset: str = "iq", sample_rate: Optional[int] = None,
|
|
1073
1075
|
agc_enable: int = 0, gain: float = 0.0,
|
|
1074
1076
|
destination: Optional[str] = None,
|
|
1077
|
+
encoding: int = 0,
|
|
1075
1078
|
ssrc: Optional[int] = None) -> int:
|
|
1076
1079
|
"""
|
|
1077
1080
|
Create a new channel with specified configuration
|
|
@@ -1098,6 +1101,7 @@ class RadiodControl:
|
|
|
1098
1101
|
destination: RTP destination multicast address (optional). Format: "address" or "address:port"
|
|
1099
1102
|
Examples: "239.1.2.3", "wspr.local", "239.1.2.3:5004"
|
|
1100
1103
|
If not specified, uses radiod's default from config file.
|
|
1104
|
+
encoding: Output encoding (0=none, 4=F32, etc.) - see Encoding class
|
|
1101
1105
|
ssrc: SSRC (channel identifier). If None, auto-allocated from parameters.
|
|
1102
1106
|
Auto-allocation uses allocate_ssrc() for deterministic, shareable SSRCs.
|
|
1103
1107
|
|
|
@@ -1133,7 +1137,9 @@ class RadiodControl:
|
|
|
1133
1137
|
preset=preset,
|
|
1134
1138
|
sample_rate=sample_rate or 16000, # Default for allocation
|
|
1135
1139
|
agc=bool(agc_enable),
|
|
1136
|
-
gain=gain
|
|
1140
|
+
gain=gain,
|
|
1141
|
+
destination=destination,
|
|
1142
|
+
encoding=encoding
|
|
1137
1143
|
)
|
|
1138
1144
|
logger.info(f"Auto-allocated SSRC: {ssrc}")
|
|
1139
1145
|
|
|
@@ -1145,7 +1151,7 @@ class RadiodControl:
|
|
|
1145
1151
|
_validate_gain(gain)
|
|
1146
1152
|
|
|
1147
1153
|
logger.info(f"Creating channel: SSRC={ssrc}, freq={frequency_hz/1e6:.3f} MHz, "
|
|
1148
|
-
f"demod={preset}, rate={sample_rate}Hz, agc={agc_enable}, gain={gain}dB")
|
|
1154
|
+
f"demod={preset}, rate={sample_rate}Hz, agc={agc_enable}, gain={gain}dB, enc={encoding}")
|
|
1149
1155
|
|
|
1150
1156
|
# Build a single command packet with ALL parameters
|
|
1151
1157
|
# This ensures radiod creates the channel with the correct settings
|
|
@@ -1180,6 +1186,12 @@ class RadiodControl:
|
|
|
1180
1186
|
encode_double(cmdbuffer, StatusType.GAIN, gain)
|
|
1181
1187
|
logger.info(f"Setting GAIN for SSRC {ssrc} to {gain} dB")
|
|
1182
1188
|
|
|
1189
|
+
# Encoding setting - NOT sending in main buffer anymore
|
|
1190
|
+
# Radiod requires OUTPUT_ENCODING to be sent in a separate command after creation
|
|
1191
|
+
# if encoding > 0:
|
|
1192
|
+
# encode_int(cmdbuffer, StatusType.OUTPUT_ENCODING, encoding)
|
|
1193
|
+
# logger.info(f"Setting OUTPUT_ENCODING for SSRC {ssrc} to {encoding}")
|
|
1194
|
+
|
|
1183
1195
|
# Destination address (if specified)
|
|
1184
1196
|
if destination is not None:
|
|
1185
1197
|
_validate_multicast_address(destination)
|
|
@@ -1203,9 +1215,25 @@ class RadiodControl:
|
|
|
1203
1215
|
encode_int(cmdbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
1204
1216
|
encode_eol(cmdbuffer)
|
|
1205
1217
|
|
|
1206
|
-
# Send the
|
|
1218
|
+
# Send the main creation packet
|
|
1207
1219
|
self.send_command(cmdbuffer)
|
|
1208
1220
|
|
|
1221
|
+
# Send separate encoding command if requested (radiod requirement)
|
|
1222
|
+
if encoding > 0:
|
|
1223
|
+
encbuffer = bytearray()
|
|
1224
|
+
encbuffer.append(CMD)
|
|
1225
|
+
|
|
1226
|
+
# Target the SSRC we just created
|
|
1227
|
+
encode_int(encbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
1228
|
+
encode_int(encbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
1229
|
+
|
|
1230
|
+
# Set the encoding
|
|
1231
|
+
encode_int(encbuffer, StatusType.OUTPUT_ENCODING, encoding)
|
|
1232
|
+
encode_eol(encbuffer)
|
|
1233
|
+
|
|
1234
|
+
self.send_command(encbuffer)
|
|
1235
|
+
logger.info(f"Sent separate OUTPUT_ENCODING command for SSRC {ssrc}: {encoding}")
|
|
1236
|
+
|
|
1209
1237
|
logger.info(f"Channel {ssrc} created and configured")
|
|
1210
1238
|
return ssrc
|
|
1211
1239
|
|
|
@@ -1248,6 +1276,7 @@ class RadiodControl:
|
|
|
1248
1276
|
agc_enable: int = 0,
|
|
1249
1277
|
gain: float = 0.0,
|
|
1250
1278
|
destination: Optional[str] = None,
|
|
1279
|
+
encoding: int = 0,
|
|
1251
1280
|
timeout: float = 5.0,
|
|
1252
1281
|
frequency_tolerance: float = 1.0,
|
|
1253
1282
|
):
|
|
@@ -1274,6 +1303,7 @@ class RadiodControl:
|
|
|
1274
1303
|
destination: RTP destination multicast address (optional).
|
|
1275
1304
|
If not specified, uses radiod's default from config file.
|
|
1276
1305
|
If specified, becomes part of the channel identity (SSRC).
|
|
1306
|
+
encoding: Output encoding (0=none, 4=F32, etc.) - see Encoding class
|
|
1277
1307
|
timeout: Maximum time to wait for channel verification (default: 5.0s)
|
|
1278
1308
|
frequency_tolerance: Acceptable frequency deviation in Hz (default: 1.0)
|
|
1279
1309
|
|
|
@@ -1322,9 +1352,10 @@ class RadiodControl:
|
|
|
1322
1352
|
sample_rate=sample_rate,
|
|
1323
1353
|
agc=bool(agc_enable),
|
|
1324
1354
|
gain=gain,
|
|
1325
|
-
destination=destination
|
|
1355
|
+
destination=destination,
|
|
1356
|
+
encoding=encoding
|
|
1326
1357
|
)
|
|
1327
|
-
logger.info(f"ensure_channel: computed SSRC {ssrc} for {frequency_hz/1e6:.3f} MHz {preset} dest={destination}")
|
|
1358
|
+
logger.info(f"ensure_channel: computed SSRC {ssrc} for {frequency_hz/1e6:.3f} MHz {preset} dest={destination} enc={encoding}")
|
|
1328
1359
|
|
|
1329
1360
|
# Check if channel already exists with matching parameters
|
|
1330
1361
|
existing_channels = discover_channels(self.status_address, listen_duration=1.0)
|
|
@@ -1343,6 +1374,15 @@ class RadiodControl:
|
|
|
1343
1374
|
if destination not in (existing.multicast_address or ""):
|
|
1344
1375
|
dest_ok = False
|
|
1345
1376
|
|
|
1377
|
+
# Check encoding if requested
|
|
1378
|
+
if dest_ok and encoding != 0:
|
|
1379
|
+
if existing.encoding != encoding:
|
|
1380
|
+
dest_ok = False
|
|
1381
|
+
logger.info(
|
|
1382
|
+
f"ensure_channel: existing channel encoding mismatch "
|
|
1383
|
+
f"({existing.encoding} vs {encoding}), will reconfigure"
|
|
1384
|
+
)
|
|
1385
|
+
|
|
1346
1386
|
if dest_ok:
|
|
1347
1387
|
logger.info(
|
|
1348
1388
|
f"ensure_channel: reusing existing channel SSRC {ssrc} "
|
|
@@ -1351,8 +1391,7 @@ class RadiodControl:
|
|
|
1351
1391
|
return existing
|
|
1352
1392
|
else:
|
|
1353
1393
|
logger.info(
|
|
1354
|
-
f"ensure_channel: existing channel
|
|
1355
|
-
f"({existing.multicast_address} vs {destination}), will reconfigure"
|
|
1394
|
+
f"ensure_channel: existing channel configuration mismatch, will reconfigure"
|
|
1356
1395
|
)
|
|
1357
1396
|
else:
|
|
1358
1397
|
logger.info(
|
|
@@ -1374,6 +1413,7 @@ class RadiodControl:
|
|
|
1374
1413
|
agc_enable=agc_enable,
|
|
1375
1414
|
gain=gain,
|
|
1376
1415
|
destination=destination,
|
|
1416
|
+
encoding=encoding,
|
|
1377
1417
|
ssrc=ssrc
|
|
1378
1418
|
)
|
|
1379
1419
|
|
|
@@ -32,6 +32,7 @@ class ChannelInfo:
|
|
|
32
32
|
port: int
|
|
33
33
|
gps_time: Optional[int] = None # GPS nanoseconds when RTP_TIMESNAP was captured
|
|
34
34
|
rtp_timesnap: Optional[int] = None # RTP timestamp at GPS_TIME
|
|
35
|
+
encoding: int = 0 # stream encoding (0=none, 4=F32, etc)
|
|
35
36
|
|
|
36
37
|
|
|
37
38
|
def _create_status_listener_socket(multicast_addr: str, interface: Optional[str] = None) -> socket.socket:
|
|
@@ -206,7 +207,8 @@ def discover_channels_native(status_address: str, listen_duration: float = 2.0,
|
|
|
206
207
|
multicast_address=mcast_addr,
|
|
207
208
|
port=port,
|
|
208
209
|
gps_time=status.get('gps_time'),
|
|
209
|
-
rtp_timesnap=status.get('rtp_timesnap')
|
|
210
|
+
rtp_timesnap=status.get('rtp_timesnap'),
|
|
211
|
+
encoding=status.get('encoding', 0)
|
|
210
212
|
)
|
|
211
213
|
|
|
212
214
|
# Store or update channel info
|
|
@@ -316,7 +318,8 @@ def discover_channels_via_control(status_address: str, timeout: float = 30.0) ->
|
|
|
316
318
|
frequency=frequency,
|
|
317
319
|
snr=snr,
|
|
318
320
|
multicast_address=addr,
|
|
319
|
-
port=port
|
|
321
|
+
port=port,
|
|
322
|
+
encoding=0 # Control utility text output may not include encoding explicitly
|
|
320
323
|
)
|
|
321
324
|
|
|
322
325
|
channels[ssrc] = channel
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ChannelMonitor service for automatic channel recovery.
|
|
3
|
+
|
|
4
|
+
This module provides a watchdog service that monitors active channels
|
|
5
|
+
and automatically re-creates them if they disappear (e.g., due to a
|
|
6
|
+
radiod restart).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Dict, Any, Optional
|
|
13
|
+
|
|
14
|
+
from .control import RadiodControl
|
|
15
|
+
from .discovery import discover_channels, ChannelInfo
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ChannelMonitor:
|
|
21
|
+
"""
|
|
22
|
+
Background service to monitor and recover channels.
|
|
23
|
+
|
|
24
|
+
The ChannelMonitor maintains a list of "desired" channels and their
|
|
25
|
+
configuration. It periodically queries `radiod` to see what channels
|
|
26
|
+
actually exist. If a desired channel is missing, it automatically
|
|
27
|
+
calls `ensure_channel` to restore it.
|
|
28
|
+
|
|
29
|
+
Usage:
|
|
30
|
+
control = RadiodControl("radiod.local")
|
|
31
|
+
monitor = ChannelMonitor(control)
|
|
32
|
+
monitor.start()
|
|
33
|
+
|
|
34
|
+
# Register a channel to keep alive
|
|
35
|
+
monitor.monitor_channel(
|
|
36
|
+
frequency_hz=14.074e6,
|
|
37
|
+
preset="usb",
|
|
38
|
+
sample_rate=12000
|
|
39
|
+
)
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, control: RadiodControl, check_interval: float = 2.0):
|
|
43
|
+
"""
|
|
44
|
+
Initialize the monitor.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
control: RadiodControl instance to use for discovery and creation
|
|
48
|
+
check_interval: How often to check channel status (seconds)
|
|
49
|
+
"""
|
|
50
|
+
self.control = control
|
|
51
|
+
self.check_interval = check_interval
|
|
52
|
+
self._monitored_channels: Dict[int, Dict[str, Any]] = {}
|
|
53
|
+
self._running = False
|
|
54
|
+
self._thread: Optional[threading.Thread] = None
|
|
55
|
+
self._lock = threading.Lock()
|
|
56
|
+
|
|
57
|
+
def start(self):
|
|
58
|
+
"""Start the monitoring thread."""
|
|
59
|
+
if self._running:
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
self._running = True
|
|
63
|
+
self._thread = threading.Thread(target=self._monitor_loop, daemon=True)
|
|
64
|
+
self._thread.start()
|
|
65
|
+
logger.info(f"ChannelMonitor started (interval={self.check_interval}s)")
|
|
66
|
+
|
|
67
|
+
def stop(self):
|
|
68
|
+
"""Stop the monitoring thread."""
|
|
69
|
+
self._running = False
|
|
70
|
+
if self._thread:
|
|
71
|
+
self._thread.join(timeout=self.check_interval * 2)
|
|
72
|
+
self._thread = None
|
|
73
|
+
logger.info("ChannelMonitor stopped")
|
|
74
|
+
|
|
75
|
+
def monitor_channel(self, **kwargs) -> int:
|
|
76
|
+
"""
|
|
77
|
+
Register a channel to be monitored/kept alive.
|
|
78
|
+
|
|
79
|
+
Calls `ensure_channel` immediately to create/verify the channel,
|
|
80
|
+
then adds it to the monitoring list.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
**kwargs: Arguments to pass to `ensure_channel`
|
|
84
|
+
(frequency_hz, preset, sample_rate, encoding, etc.)
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
SSRC of the channel
|
|
88
|
+
"""
|
|
89
|
+
# First ensure the channel exists
|
|
90
|
+
channel_info = self.control.ensure_channel(**kwargs)
|
|
91
|
+
ssrc = channel_info.ssrc
|
|
92
|
+
|
|
93
|
+
with self._lock:
|
|
94
|
+
# Store parameters for future recovery
|
|
95
|
+
# We filter out 'timeout' as it's a runtime param, not a channel property
|
|
96
|
+
recovery_params = kwargs.copy()
|
|
97
|
+
if 'timeout' in recovery_params:
|
|
98
|
+
del recovery_params['timeout']
|
|
99
|
+
|
|
100
|
+
self._monitored_channels[ssrc] = recovery_params
|
|
101
|
+
logger.info(f"Now monitoring channel {ssrc} for recovery")
|
|
102
|
+
|
|
103
|
+
return ssrc
|
|
104
|
+
|
|
105
|
+
def unmonitor_channel(self, ssrc: int):
|
|
106
|
+
"""Stop monitoring a specific channel."""
|
|
107
|
+
with self._lock:
|
|
108
|
+
if ssrc in self._monitored_channels:
|
|
109
|
+
del self._monitored_channels[ssrc]
|
|
110
|
+
logger.info(f"Stopped monitoring channel {ssrc}")
|
|
111
|
+
|
|
112
|
+
def _monitor_loop(self):
|
|
113
|
+
"""Main monitoring loop."""
|
|
114
|
+
while self._running:
|
|
115
|
+
try:
|
|
116
|
+
self._check_and_recover()
|
|
117
|
+
except Exception as e:
|
|
118
|
+
logger.error(f"Error in monitor loop: {e}")
|
|
119
|
+
|
|
120
|
+
time.sleep(self.check_interval)
|
|
121
|
+
|
|
122
|
+
def _check_and_recover(self):
|
|
123
|
+
"""Perform one check cycle: discover and recover missing channels."""
|
|
124
|
+
# Get list of desired channels safely
|
|
125
|
+
with self._lock:
|
|
126
|
+
if not self._monitored_channels:
|
|
127
|
+
return
|
|
128
|
+
desired = self._monitored_channels.copy()
|
|
129
|
+
|
|
130
|
+
# Discover actual running channels
|
|
131
|
+
try:
|
|
132
|
+
# Use quick discovery
|
|
133
|
+
actual_channels = discover_channels(
|
|
134
|
+
self.control.status_address,
|
|
135
|
+
listen_duration=0.5
|
|
136
|
+
)
|
|
137
|
+
except Exception as e:
|
|
138
|
+
logger.warning(f"Discovery failed during check (radiod down?): {e}")
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
# Check for missing channels
|
|
142
|
+
for ssrc, params in desired.items():
|
|
143
|
+
if ssrc not in actual_channels:
|
|
144
|
+
logger.warning(f"Channel {ssrc} is missing! Attempting recovery...")
|
|
145
|
+
try:
|
|
146
|
+
# Attempt to restore
|
|
147
|
+
self.control.ensure_channel(**params)
|
|
148
|
+
logger.info(f"Successfully recovered channel {ssrc}")
|
|
149
|
+
except Exception as e:
|
|
150
|
+
logger.error(f"Failed to recover channel {ssrc}: {e}")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ka9q-python
|
|
3
|
-
Version: 3.2.
|
|
3
|
+
Version: 3.2.7
|
|
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
|
|
@@ -99,6 +99,22 @@ control.create_channel(
|
|
|
99
99
|
)
|
|
100
100
|
|
|
101
101
|
# RTP stream now available with SSRC 10000000
|
|
102
|
+
|
|
103
|
+
### Request Specific Output Encoding
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from ka9q import RadiodControl, Encoding
|
|
107
|
+
|
|
108
|
+
control = RadiodControl("radiod.local")
|
|
109
|
+
|
|
110
|
+
# Create a channel with 32-bit float output (highest quality)
|
|
111
|
+
control.ensure_channel(
|
|
112
|
+
frequency_hz=14.074e6,
|
|
113
|
+
preset="usb",
|
|
114
|
+
sample_rate=12000,
|
|
115
|
+
encoding=Encoding.F32
|
|
116
|
+
)
|
|
117
|
+
```
|
|
102
118
|
```
|
|
103
119
|
|
|
104
120
|
### Monitor WSPR Bands
|
|
@@ -176,6 +192,25 @@ control = RadiodControl("radiod.local", interface=my_interface)
|
|
|
176
192
|
channels = discover_channels("radiod.local", interface=my_interface)
|
|
177
193
|
```
|
|
178
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
|
+
|
|
179
214
|
## Documentation
|
|
180
215
|
|
|
181
216
|
For detailed information, please refer to the documentation in the `docs/` directory:
|
|
@@ -23,6 +23,7 @@ ka9q/addressing.py
|
|
|
23
23
|
ka9q/control.py
|
|
24
24
|
ka9q/discovery.py
|
|
25
25
|
ka9q/exceptions.py
|
|
26
|
+
ka9q/monitor.py
|
|
26
27
|
ka9q/resequencer.py
|
|
27
28
|
ka9q/rtp_recorder.py
|
|
28
29
|
ka9q/stream.py
|
|
@@ -38,12 +39,15 @@ tests/__init__.py
|
|
|
38
39
|
tests/conftest.py
|
|
39
40
|
tests/test_addressing.py
|
|
40
41
|
tests/test_channel_verification.py
|
|
42
|
+
tests/test_create_split_encoding.py
|
|
41
43
|
tests/test_decode_functions.py
|
|
42
44
|
tests/test_encode_functions.py
|
|
43
45
|
tests/test_encode_socket.py
|
|
46
|
+
tests/test_ensure_channel_encoding.py
|
|
44
47
|
tests/test_integration.py
|
|
45
48
|
tests/test_iq_20khz_f32.py
|
|
46
49
|
tests/test_listen_multicast.py
|
|
50
|
+
tests/test_monitor.py
|
|
47
51
|
tests/test_multihomed.py
|
|
48
52
|
tests/test_native_discovery.py
|
|
49
53
|
tests/test_performance_fixes.py
|
|
@@ -51,6 +55,7 @@ tests/test_remove_channel.py
|
|
|
51
55
|
tests/test_rtp_recorder.py
|
|
52
56
|
tests/test_security_features.py
|
|
53
57
|
tests/test_ssrc_dest_unit.py
|
|
58
|
+
tests/test_ssrc_encoding_unit.py
|
|
54
59
|
tests/test_tune.py
|
|
55
60
|
tests/test_tune_cli.py
|
|
56
61
|
tests/test_tune_debug.py
|
|
@@ -662,12 +662,11 @@ def verify_channel(
|
|
|
662
662
|
)
|
|
663
663
|
|
|
664
664
|
try:
|
|
665
|
-
# 1. Create and configure channel using
|
|
666
|
-
#
|
|
665
|
+
# 1. Create and configure channel using ensure_channel()
|
|
666
|
+
# This tests the high-level API which handles encoding commands
|
|
667
667
|
logger.info(f"Creating channel: {test_case.name} (SSRC {ssrc})")
|
|
668
668
|
|
|
669
|
-
|
|
670
|
-
'ssrc': ssrc,
|
|
669
|
+
ensure_kwargs = {
|
|
671
670
|
'frequency_hz': test_case.frequency_hz,
|
|
672
671
|
'preset': test_case.preset,
|
|
673
672
|
'sample_rate': test_case.sample_rate,
|
|
@@ -676,28 +675,37 @@ def verify_channel(
|
|
|
676
675
|
|
|
677
676
|
# Add gain/AGC settings
|
|
678
677
|
if test_case.agc_enable:
|
|
679
|
-
|
|
678
|
+
ensure_kwargs['agc_enable'] = 1
|
|
680
679
|
else:
|
|
681
|
-
|
|
680
|
+
ensure_kwargs['gain'] = test_case.gain
|
|
682
681
|
|
|
683
682
|
# Add encoding if specified
|
|
684
683
|
if test_case.encoding is not None:
|
|
685
|
-
|
|
684
|
+
ensure_kwargs['encoding'] = test_case.encoding
|
|
686
685
|
|
|
687
686
|
# Add destination if specified
|
|
688
687
|
if test_case.destination is not None:
|
|
689
|
-
|
|
690
|
-
|
|
688
|
+
ensure_kwargs['destination'] = test_case.destination
|
|
689
|
+
|
|
691
690
|
try:
|
|
692
|
-
|
|
691
|
+
# ensure_channel returns the discovered ChannelInfo
|
|
692
|
+
channel_info = control.ensure_channel(**ensure_kwargs)
|
|
693
693
|
result.channel_created = True
|
|
694
|
-
|
|
694
|
+
|
|
695
|
+
# Since ensure_channel doesn't return the raw status dict from tune,
|
|
696
|
+
# we'll synthesize a status dict for subsequent checks or rely on discovery
|
|
697
|
+
status = {
|
|
698
|
+
'ssrc': channel_info.ssrc,
|
|
699
|
+
'frequency': channel_info.frequency,
|
|
700
|
+
'encoding': getattr(channel_info, 'encoding', 0),
|
|
701
|
+
# Add other fields if needed for compatibility
|
|
702
|
+
}
|
|
703
|
+
logger.info(f"ensure_channel returned: {channel_info}")
|
|
695
704
|
except TimeoutError:
|
|
696
705
|
# Channel may have been created but status not received
|
|
697
|
-
#
|
|
698
|
-
result.channel_created = True
|
|
706
|
+
result.channel_created = False # ensure_channel should have raised if verification failed
|
|
699
707
|
status = {}
|
|
700
|
-
logger.warning("
|
|
708
|
+
logger.warning("ensure_channel timed out")
|
|
701
709
|
|
|
702
710
|
# Wait for channel to stabilize
|
|
703
711
|
time.sleep(0.5)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from unittest.mock import MagicMock
|
|
3
|
+
from ka9q.control import RadiodControl
|
|
4
|
+
from ka9q.types import Encoding
|
|
5
|
+
|
|
6
|
+
class TestCreateChannelSplit(unittest.TestCase):
|
|
7
|
+
@unittest.mock.patch('ka9q.control.RadiodControl._connect')
|
|
8
|
+
def test_create_channel_splits_encoding(self, mock_connect):
|
|
9
|
+
"""Verify that create_channel sends two packets when encoding is specified"""
|
|
10
|
+
control = RadiodControl("radiod.local")
|
|
11
|
+
control.send_command = MagicMock()
|
|
12
|
+
|
|
13
|
+
# 1. Create channel WITH encoding
|
|
14
|
+
control.create_channel(
|
|
15
|
+
frequency_hz=14074000.0,
|
|
16
|
+
encoding=Encoding.F32
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Should call send_command twice
|
|
20
|
+
self.assertEqual(control.send_command.call_count, 2, "Should call send_command twice")
|
|
21
|
+
|
|
22
|
+
# 2. Create channel WITHOUT encoding
|
|
23
|
+
control.send_command.reset_mock()
|
|
24
|
+
control.create_channel(
|
|
25
|
+
frequency_hz=14074000.0,
|
|
26
|
+
encoding=Encoding.NO_ENCODING # or 0
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Should call send_command once
|
|
30
|
+
self.assertEqual(control.send_command.call_count, 1, "Should call send_command once")
|
|
31
|
+
|
|
32
|
+
if __name__ == '__main__':
|
|
33
|
+
unittest.main()
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from unittest.mock import MagicMock, patch
|
|
3
|
+
from ka9q.control import RadiodControl
|
|
4
|
+
from ka9q.discovery import ChannelInfo
|
|
5
|
+
from ka9q.types import Encoding
|
|
6
|
+
|
|
7
|
+
class TestEnsureChannelEncoding(unittest.TestCase):
|
|
8
|
+
@patch('ka9q.control.RadiodControl._connect')
|
|
9
|
+
def setUp(self, mock_connect):
|
|
10
|
+
self.control = RadiodControl("radiod.local")
|
|
11
|
+
self.control.create_channel = MagicMock()
|
|
12
|
+
self.control.verify_channel = MagicMock(return_value=True)
|
|
13
|
+
|
|
14
|
+
@patch('ka9q.discovery.discover_channels')
|
|
15
|
+
def test_ensure_channel_encoding_match(self, mock_discover):
|
|
16
|
+
"""Verify that matching encoding reuses channel"""
|
|
17
|
+
# Mock existing channel with F32 encoding
|
|
18
|
+
ssrc = 12345
|
|
19
|
+
existing = ChannelInfo(
|
|
20
|
+
ssrc=ssrc,
|
|
21
|
+
preset="iq",
|
|
22
|
+
sample_rate=16000,
|
|
23
|
+
frequency=14074000.0,
|
|
24
|
+
snr=0.0,
|
|
25
|
+
multicast_address="239.1.1.1",
|
|
26
|
+
port=5004,
|
|
27
|
+
encoding=Encoding.F32
|
|
28
|
+
)
|
|
29
|
+
mock_discover.return_value = {ssrc: existing}
|
|
30
|
+
|
|
31
|
+
# Patch allocate_ssrc to return our mock SSRC
|
|
32
|
+
with patch('ka9q.control.allocate_ssrc', return_value=ssrc):
|
|
33
|
+
# Call ensure_channel asking for F32
|
|
34
|
+
result = self.control.ensure_channel(
|
|
35
|
+
frequency_hz=14.074e6,
|
|
36
|
+
encoding=Encoding.F32
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Should return existing channel
|
|
40
|
+
self.assertEqual(result, existing)
|
|
41
|
+
# Should NOT call create_channel
|
|
42
|
+
self.control.create_channel.assert_not_called()
|
|
43
|
+
|
|
44
|
+
@patch('ka9q.discovery.discover_channels')
|
|
45
|
+
def test_ensure_channel_encoding_mismatch(self, mock_discover):
|
|
46
|
+
"""Verify that mismatching encoding triggers recreation"""
|
|
47
|
+
# Mock existing channel with S16 encoding
|
|
48
|
+
ssrc = 12345
|
|
49
|
+
existing = ChannelInfo(
|
|
50
|
+
ssrc=ssrc,
|
|
51
|
+
preset="iq",
|
|
52
|
+
sample_rate=16000,
|
|
53
|
+
frequency=14074000.0,
|
|
54
|
+
snr=0.0,
|
|
55
|
+
multicast_address="239.1.1.1",
|
|
56
|
+
port=5004,
|
|
57
|
+
encoding=Encoding.S16LE # Different encoding
|
|
58
|
+
)
|
|
59
|
+
mock_discover.return_value = {ssrc: existing}
|
|
60
|
+
|
|
61
|
+
# Patch allocate_ssrc to return our mock SSRC
|
|
62
|
+
with patch('ka9q.control.allocate_ssrc', return_value=ssrc):
|
|
63
|
+
# Call ensure_channel asking for F32
|
|
64
|
+
result = self.control.ensure_channel(
|
|
65
|
+
frequency_hz=14.074e6,
|
|
66
|
+
encoding=Encoding.F32
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Should call create_channel with correct encoding
|
|
70
|
+
self.control.create_channel.assert_called_once()
|
|
71
|
+
_, kwargs = self.control.create_channel.call_args
|
|
72
|
+
self.assertEqual(kwargs['encoding'], Encoding.F32)
|
|
73
|
+
|
|
74
|
+
if __name__ == '__main__':
|
|
75
|
+
unittest.main()
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from unittest.mock import MagicMock, patch
|
|
3
|
+
import time
|
|
4
|
+
from ka9q.monitor import ChannelMonitor
|
|
5
|
+
from ka9q.discovery import ChannelInfo
|
|
6
|
+
|
|
7
|
+
class TestChannelMonitor(unittest.TestCase):
|
|
8
|
+
def setUp(self):
|
|
9
|
+
self.control = MagicMock()
|
|
10
|
+
# Mock ensure_channel to return a ChannelInfo object
|
|
11
|
+
self.control.ensure_channel.return_value = ChannelInfo(
|
|
12
|
+
ssrc=12345,
|
|
13
|
+
preset="usb",
|
|
14
|
+
sample_rate=12000,
|
|
15
|
+
frequency=14.074e6,
|
|
16
|
+
snr=30.0,
|
|
17
|
+
multicast_address="239.1.1.1",
|
|
18
|
+
port=5004
|
|
19
|
+
)
|
|
20
|
+
self.monitor = ChannelMonitor(self.control, check_interval=0.1)
|
|
21
|
+
|
|
22
|
+
def tearDown(self):
|
|
23
|
+
self.monitor.stop()
|
|
24
|
+
|
|
25
|
+
def test_monitor_registration(self):
|
|
26
|
+
"""Verify channels are registered for monitoring"""
|
|
27
|
+
ssrc = self.monitor.monitor_channel(frequency_hz=14.074e6, preset="usb")
|
|
28
|
+
|
|
29
|
+
self.assertEqual(ssrc, 12345)
|
|
30
|
+
self.assertIn(12345, self.monitor._monitored_channels)
|
|
31
|
+
self.assertEqual(
|
|
32
|
+
self.monitor._monitored_channels[12345],
|
|
33
|
+
{'frequency_hz': 14.074e6, 'preset': 'usb'}
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Verify ensure_channel was called
|
|
37
|
+
self.control.ensure_channel.assert_called_with(frequency_hz=14.074e6, preset="usb")
|
|
38
|
+
|
|
39
|
+
def test_unmonitor(self):
|
|
40
|
+
"""Verify unmonitoring works"""
|
|
41
|
+
ssrc = self.monitor.monitor_channel(frequency_hz=14.074e6)
|
|
42
|
+
self.monitor.unmonitor_channel(ssrc)
|
|
43
|
+
self.assertNotIn(ssrc, self.monitor._monitored_channels)
|
|
44
|
+
|
|
45
|
+
@patch('ka9q.monitor.discover_channels')
|
|
46
|
+
def test_recovery(self, mock_discover):
|
|
47
|
+
"""Verify recovery triggers when channel is missing"""
|
|
48
|
+
# Register channel
|
|
49
|
+
self.monitor.monitor_channel(frequency_hz=14.074e6, preset="usb")
|
|
50
|
+
self.control.ensure_channel.reset_mock()
|
|
51
|
+
|
|
52
|
+
# Scenario 1: Channel exists
|
|
53
|
+
mock_discover.return_value = {
|
|
54
|
+
12345: ChannelInfo(
|
|
55
|
+
ssrc=12345, frequency=14.074e6, preset="usb", sample_rate=12000,
|
|
56
|
+
snr=0, multicast_address="239.1.1.1", port=5004
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# Run one loop cycle
|
|
61
|
+
self.monitor._check_and_recover()
|
|
62
|
+
|
|
63
|
+
# Should NOT call ensure_channel
|
|
64
|
+
self.control.ensure_channel.assert_not_called()
|
|
65
|
+
|
|
66
|
+
# Scenario 2: Channel missing (radiod restarted)
|
|
67
|
+
mock_discover.return_value = {} # Empty discovery
|
|
68
|
+
|
|
69
|
+
# Run one loop cycle
|
|
70
|
+
self.monitor._check_and_recover()
|
|
71
|
+
|
|
72
|
+
# Should call ensure_channel to restore it
|
|
73
|
+
self.control.ensure_channel.assert_called_with(frequency_hz=14.074e6, preset="usb")
|
|
74
|
+
|
|
75
|
+
if __name__ == '__main__':
|
|
76
|
+
unittest.main()
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from ka9q.control import allocate_ssrc
|
|
3
|
+
from ka9q.types import Encoding
|
|
4
|
+
|
|
5
|
+
class TestEncodingSSRC(unittest.TestCase):
|
|
6
|
+
def test_ssrc_uniqueness_with_encoding(self):
|
|
7
|
+
"""Verify that providing encoding changes the SSRC"""
|
|
8
|
+
params = {
|
|
9
|
+
'frequency_hz': 14074000.0,
|
|
10
|
+
'preset': 'iq',
|
|
11
|
+
'sample_rate': 16000,
|
|
12
|
+
'agc': False,
|
|
13
|
+
'gain': 0.0,
|
|
14
|
+
'destination': None
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
# 1. Base SSRC (no encoding or 0)
|
|
18
|
+
ssrc_base = allocate_ssrc(**params)
|
|
19
|
+
|
|
20
|
+
# 2. SSRC with F32
|
|
21
|
+
ssrc_f32 = allocate_ssrc(**params, encoding=Encoding.F32)
|
|
22
|
+
|
|
23
|
+
# 3. SSRC with S16LE
|
|
24
|
+
ssrc_s16 = allocate_ssrc(**params, encoding=Encoding.S16LE)
|
|
25
|
+
|
|
26
|
+
# Verify all are different
|
|
27
|
+
self.assertNotEqual(ssrc_base, ssrc_f32, "SSRC with encoding should differ from base")
|
|
28
|
+
self.assertNotEqual(ssrc_f32, ssrc_s16, "SSRCs for different encodings should differ")
|
|
29
|
+
|
|
30
|
+
# Verify determinism
|
|
31
|
+
ssrc_f32_2 = allocate_ssrc(**params, encoding=Encoding.F32)
|
|
32
|
+
self.assertEqual(ssrc_f32, ssrc_f32_2, "SSRC should be deterministic for same encoding")
|
|
33
|
+
|
|
34
|
+
if __name__ == '__main__':
|
|
35
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|