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.
Files changed (65) hide show
  1. {ka9q_python-3.2.5/ka9q_python.egg-info → ka9q_python-3.2.7}/PKG-INFO +36 -1
  2. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/README.md +35 -0
  3. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/ka9q/__init__.py +3 -1
  4. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/ka9q/control.py +49 -9
  5. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/ka9q/discovery.py +5 -2
  6. ka9q_python-3.2.7/ka9q/monitor.py +150 -0
  7. {ka9q_python-3.2.5 → ka9q_python-3.2.7/ka9q_python.egg-info}/PKG-INFO +36 -1
  8. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/ka9q_python.egg-info/SOURCES.txt +5 -0
  9. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/pyproject.toml +1 -1
  10. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/test_channel_verification.py +22 -14
  11. ka9q_python-3.2.7/tests/test_create_split_encoding.py +33 -0
  12. ka9q_python-3.2.7/tests/test_ensure_channel_encoding.py +75 -0
  13. ka9q_python-3.2.7/tests/test_monitor.py +76 -0
  14. ka9q_python-3.2.7/tests/test_ssrc_encoding_unit.py +35 -0
  15. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/LICENSE +0 -0
  16. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/MANIFEST.in +0 -0
  17. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/examples/advanced_features_demo.py +0 -0
  18. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/examples/channel_cleanup_example.py +0 -0
  19. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/examples/codar_oceanography.py +0 -0
  20. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/examples/discover_example.py +0 -0
  21. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/examples/grape_integration_example.py +0 -0
  22. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/examples/hf_band_scanner.py +0 -0
  23. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/examples/rtp_recorder_example.py +0 -0
  24. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/examples/simple_am_radio.py +0 -0
  25. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/examples/stream_example.py +0 -0
  26. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/examples/superdarn_recorder.py +0 -0
  27. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/examples/test_channel_operations.py +0 -0
  28. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/examples/test_improvements.py +0 -0
  29. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/examples/test_timing_fields.py +0 -0
  30. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/examples/tune.py +0 -0
  31. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/examples/tune_example.py +0 -0
  32. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/ka9q/addressing.py +0 -0
  33. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/ka9q/exceptions.py +0 -0
  34. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/ka9q/resequencer.py +0 -0
  35. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/ka9q/rtp_recorder.py +0 -0
  36. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/ka9q/stream.py +0 -0
  37. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/ka9q/stream_quality.py +0 -0
  38. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/ka9q/types.py +0 -0
  39. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/ka9q/utils.py +0 -0
  40. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/ka9q_python.egg-info/dependency_links.txt +0 -0
  41. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/ka9q_python.egg-info/requires.txt +0 -0
  42. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/ka9q_python.egg-info/top_level.txt +0 -0
  43. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/setup.cfg +0 -0
  44. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/setup.py +0 -0
  45. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/__init__.py +0 -0
  46. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/conftest.py +0 -0
  47. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/test_addressing.py +0 -0
  48. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/test_decode_functions.py +0 -0
  49. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/test_encode_functions.py +0 -0
  50. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/test_encode_socket.py +0 -0
  51. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/test_integration.py +0 -0
  52. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/test_iq_20khz_f32.py +0 -0
  53. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/test_listen_multicast.py +0 -0
  54. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/test_multihomed.py +0 -0
  55. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/test_native_discovery.py +0 -0
  56. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/test_performance_fixes.py +0 -0
  57. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/test_remove_channel.py +0 -0
  58. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/test_rtp_recorder.py +0 -0
  59. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/test_security_features.py +0 -0
  60. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/test_ssrc_dest_unit.py +0 -0
  61. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/test_tune.py +0 -0
  62. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/test_tune_cli.py +0 -0
  63. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/test_tune_debug.py +0 -0
  64. {ka9q_python-3.2.5 → ka9q_python-3.2.7}/tests/test_tune_live.py +0 -0
  65. {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.5
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.5'
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 # Destination address (None or string)
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 single packet
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 destination mismatch "
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.5
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ka9q-python"
7
- version = "3.2.5"
7
+ version = "3.2.7"
8
8
  description = "Python interface for ka9q-radio control and monitoring"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -662,12 +662,11 @@ def verify_channel(
662
662
  )
663
663
 
664
664
  try:
665
- # 1. Create and configure channel using tune()
666
- # tune() supports encoding parameter and returns status
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
- tune_kwargs = {
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
- tune_kwargs['agc_enable'] = True
678
+ ensure_kwargs['agc_enable'] = 1
680
679
  else:
681
- tune_kwargs['gain'] = test_case.gain
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
- tune_kwargs['encoding'] = test_case.encoding
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
- tune_kwargs['destination'] = test_case.destination
690
-
688
+ ensure_kwargs['destination'] = test_case.destination
689
+
691
690
  try:
692
- status = control.tune(**tune_kwargs)
691
+ # ensure_channel returns the discovered ChannelInfo
692
+ channel_info = control.ensure_channel(**ensure_kwargs)
693
693
  result.channel_created = True
694
- logger.info(f"tune() returned status: {status}")
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
- # Try to verify via discovery
698
- result.channel_created = True
706
+ result.channel_created = False # ensure_channel should have raised if verification failed
699
707
  status = {}
700
- logger.warning("tune() timed out waiting for status, continuing with discovery")
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