ka9q-python 3.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ka9q/__init__.py +98 -0
- ka9q/control.py +2295 -0
- ka9q/discovery.py +485 -0
- ka9q/exceptions.py +23 -0
- ka9q/resequencer.py +389 -0
- ka9q/rtp_recorder.py +457 -0
- ka9q/stream.py +393 -0
- ka9q/stream_quality.py +215 -0
- ka9q/types.py +161 -0
- ka9q/utils.py +202 -0
- ka9q_python-3.2.0.dist-info/METADATA +237 -0
- ka9q_python-3.2.0.dist-info/RECORD +15 -0
- ka9q_python-3.2.0.dist-info/WHEEL +5 -0
- ka9q_python-3.2.0.dist-info/licenses/LICENSE +21 -0
- ka9q_python-3.2.0.dist-info/top_level.txt +1 -0
ka9q/control.py
ADDED
|
@@ -0,0 +1,2295 @@
|
|
|
1
|
+
"""
|
|
2
|
+
General-purpose radiod control interface via TLV command protocol
|
|
3
|
+
|
|
4
|
+
This module provides a complete interface to radiod's control protocol using
|
|
5
|
+
TLV (Type-Length-Value) encoding, based on ka9q-radio's status.c/status.h.
|
|
6
|
+
|
|
7
|
+
ARCHITECTURE:
|
|
8
|
+
- This module exposes ALL radiod capabilities and parameters
|
|
9
|
+
- Application-specific defaults should be implemented in higher-level modules
|
|
10
|
+
or configuration files
|
|
11
|
+
- Reusable for any application needing radiod channel control
|
|
12
|
+
|
|
13
|
+
USAGE:
|
|
14
|
+
1. Create RadiodControl instance with status address
|
|
15
|
+
2. Use granular setters (set_frequency, set_preset, etc.) OR
|
|
16
|
+
3. Use create_and_configure_channel() for common channel creation patterns
|
|
17
|
+
|
|
18
|
+
PARAMETERS:
|
|
19
|
+
- All StatusType enum values from ka9q-radio/status.h are supported
|
|
20
|
+
- See individual methods for parameter descriptions and valid ranges
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import socket
|
|
24
|
+
import struct
|
|
25
|
+
import secrets
|
|
26
|
+
import logging
|
|
27
|
+
import threading
|
|
28
|
+
import re
|
|
29
|
+
import time
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from typing import Optional, Dict, Union
|
|
32
|
+
from .types import StatusType, CMD
|
|
33
|
+
from .discovery import discover_channels
|
|
34
|
+
from .exceptions import ConnectionError, CommandError, ValidationError
|
|
35
|
+
from .utils import resolve_multicast_address
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Command packet type
|
|
41
|
+
CMD = 1
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Metrics:
|
|
46
|
+
"""Metrics for monitoring RadiodControl operations"""
|
|
47
|
+
commands_sent: int = 0
|
|
48
|
+
commands_failed: int = 0
|
|
49
|
+
status_received: int = 0
|
|
50
|
+
last_error: str = ""
|
|
51
|
+
last_error_time: float = 0.0
|
|
52
|
+
errors_by_type: Dict[str, int] = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict:
|
|
55
|
+
"""Convert metrics to dictionary for easy inspection"""
|
|
56
|
+
total = max(1, self.commands_sent) # Avoid division by zero
|
|
57
|
+
return {
|
|
58
|
+
'commands_sent': self.commands_sent,
|
|
59
|
+
'commands_failed': self.commands_failed,
|
|
60
|
+
'commands_succeeded': self.commands_sent - self.commands_failed,
|
|
61
|
+
'success_rate': (self.commands_sent - self.commands_failed) / total,
|
|
62
|
+
'status_received': self.status_received,
|
|
63
|
+
'last_error': self.last_error,
|
|
64
|
+
'last_error_time': self.last_error_time,
|
|
65
|
+
'errors_by_type': dict(self.errors_by_type),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# SSRC allocation function - compatible with signal-recorder
|
|
70
|
+
def allocate_ssrc(
|
|
71
|
+
frequency_hz: float,
|
|
72
|
+
preset: str = "iq",
|
|
73
|
+
sample_rate: int = 16000,
|
|
74
|
+
agc: bool = False,
|
|
75
|
+
gain: float = 0.0
|
|
76
|
+
) -> int:
|
|
77
|
+
"""
|
|
78
|
+
Allocate a deterministic SSRC from channel parameters.
|
|
79
|
+
|
|
80
|
+
This function generates a consistent SSRC for a given set of channel
|
|
81
|
+
parameters. The same parameters will always produce the same SSRC,
|
|
82
|
+
enabling stream sharing and coordination between applications.
|
|
83
|
+
|
|
84
|
+
The algorithm matches signal-recorder's StreamSpec.ssrc_hash() for
|
|
85
|
+
cross-library compatibility.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
frequency_hz: Center frequency in Hz
|
|
89
|
+
preset: Demodulation mode ("iq", "usb", "lsb", "am", "fm", "cw")
|
|
90
|
+
sample_rate: Output sample rate in Hz
|
|
91
|
+
agc: Automatic gain control enabled
|
|
92
|
+
gain: Manual gain in dB
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Deterministic 31-bit positive SSRC value
|
|
96
|
+
|
|
97
|
+
Example:
|
|
98
|
+
>>> ssrc = allocate_ssrc(10.0e6, "iq", 16000)
|
|
99
|
+
>>> print(f"SSRC for 10 MHz IQ @ 16kHz: {ssrc}")
|
|
100
|
+
|
|
101
|
+
# Same parameters always give same SSRC
|
|
102
|
+
>>> ssrc2 = allocate_ssrc(10.0e6, "iq", 16000)
|
|
103
|
+
>>> assert ssrc == ssrc2
|
|
104
|
+
"""
|
|
105
|
+
# Match signal-recorder's StreamSpec.__hash__() algorithm
|
|
106
|
+
key = (
|
|
107
|
+
round(frequency_hz), # Frequency rounded to nearest Hz
|
|
108
|
+
preset.lower(), # Preset normalized to lowercase
|
|
109
|
+
sample_rate, # Sample rate as-is
|
|
110
|
+
agc, # AGC boolean
|
|
111
|
+
round(gain, 1) # Gain rounded to 0.1 dB
|
|
112
|
+
)
|
|
113
|
+
# Keep positive, 31 bits (matches signal-recorder)
|
|
114
|
+
return hash(key) & 0x7FFFFFFF
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# Input validation functions
|
|
118
|
+
def _validate_ssrc(ssrc: int) -> None:
|
|
119
|
+
"""Validate SSRC fits in 32-bit unsigned integer"""
|
|
120
|
+
if not isinstance(ssrc, int):
|
|
121
|
+
raise ValidationError(f"SSRC must be an integer, got {type(ssrc).__name__}")
|
|
122
|
+
if not (0 <= ssrc <= 0xFFFFFFFF):
|
|
123
|
+
raise ValidationError(f"Invalid SSRC: {ssrc} (must be 0-4294967295)")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _validate_frequency(freq_hz: float) -> None:
|
|
127
|
+
"""Validate frequency is within reasonable SDR range"""
|
|
128
|
+
if not isinstance(freq_hz, (int, float)):
|
|
129
|
+
raise ValidationError(f"Frequency must be a number, got {type(freq_hz).__name__}")
|
|
130
|
+
if not (0 < freq_hz < 10e12): # 10 THz max
|
|
131
|
+
raise ValidationError(f"Invalid frequency: {freq_hz} Hz (must be 0 < freq < 10 THz)")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _validate_sample_rate(rate: int) -> None:
|
|
135
|
+
"""Validate sample rate is positive and reasonable"""
|
|
136
|
+
if not isinstance(rate, int):
|
|
137
|
+
raise ValidationError(f"Sample rate must be an integer, got {type(rate).__name__}")
|
|
138
|
+
if not (1 <= rate <= 100e6): # 100 MHz max
|
|
139
|
+
raise ValidationError(f"Invalid sample rate: {rate} Hz (must be 1-100000000)")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _validate_timeout(timeout: float) -> None:
|
|
143
|
+
"""Validate timeout is positive"""
|
|
144
|
+
if not isinstance(timeout, (int, float)):
|
|
145
|
+
raise ValidationError(f"Timeout must be a number, got {type(timeout).__name__}")
|
|
146
|
+
if timeout <= 0:
|
|
147
|
+
raise ValidationError(f"Timeout must be positive, got {timeout}")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _validate_gain(gain_db: float) -> None:
|
|
151
|
+
"""Validate gain is within reasonable range"""
|
|
152
|
+
if not isinstance(gain_db, (int, float)):
|
|
153
|
+
raise ValidationError(f"Gain must be a number, got {type(gain_db).__name__}")
|
|
154
|
+
if not (-100 <= gain_db <= 100): # Reasonable range for most SDRs
|
|
155
|
+
raise ValidationError(f"Invalid gain: {gain_db} dB (must be -100 to +100)")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _validate_positive(value: float, name: str) -> None:
|
|
159
|
+
"""Validate that a value is positive"""
|
|
160
|
+
if not isinstance(value, (int, float)):
|
|
161
|
+
raise ValidationError(f"{name} must be a number, got {type(value).__name__}")
|
|
162
|
+
if value <= 0:
|
|
163
|
+
raise ValidationError(f"{name} must be positive, got {value}")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _validate_preset(preset: str) -> None:
|
|
167
|
+
"""
|
|
168
|
+
Validate preset name is safe and within reasonable bounds
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
preset: Preset name to validate
|
|
172
|
+
|
|
173
|
+
Raises:
|
|
174
|
+
ValidationError: If preset name is invalid
|
|
175
|
+
"""
|
|
176
|
+
if not isinstance(preset, str):
|
|
177
|
+
raise ValidationError(f"Preset must be a string, got {type(preset).__name__}")
|
|
178
|
+
if not preset:
|
|
179
|
+
raise ValidationError("Preset name cannot be empty")
|
|
180
|
+
if len(preset) > 32:
|
|
181
|
+
raise ValidationError(f"Preset name too long: {len(preset)} chars (max 32)")
|
|
182
|
+
# Check control characters FIRST (before regex)
|
|
183
|
+
if any(ord(c) < 32 or ord(c) == 127 for c in preset):
|
|
184
|
+
raise ValidationError(f"Preset name contains control characters")
|
|
185
|
+
# Allow alphanumeric, dash, underscore only
|
|
186
|
+
if not re.match(r'^[a-zA-Z0-9_-]+$', preset):
|
|
187
|
+
raise ValidationError(f"Invalid preset name '{preset}': only alphanumeric, dash, and underscore allowed")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _validate_string_param(value: str, param_name: str, max_length: int = 256) -> None:
|
|
191
|
+
"""
|
|
192
|
+
Validate a generic string parameter
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
value: String value to validate
|
|
196
|
+
param_name: Name of parameter (for error messages)
|
|
197
|
+
max_length: Maximum allowed length
|
|
198
|
+
|
|
199
|
+
Raises:
|
|
200
|
+
ValidationError: If string is invalid
|
|
201
|
+
"""
|
|
202
|
+
if not isinstance(value, str):
|
|
203
|
+
raise ValidationError(f"{param_name} must be a string, got {type(value).__name__}")
|
|
204
|
+
if not value:
|
|
205
|
+
raise ValidationError(f"{param_name} cannot be empty")
|
|
206
|
+
if len(value) > max_length:
|
|
207
|
+
raise ValidationError(f"{param_name} too long: {len(value)} chars (max {max_length})")
|
|
208
|
+
# Check null bytes FIRST
|
|
209
|
+
if '\x00' in value:
|
|
210
|
+
raise ValidationError(f"{param_name} contains null bytes")
|
|
211
|
+
# Then check other control characters (except newline/tab if needed)
|
|
212
|
+
if any(ord(c) < 32 and c not in '\n\t' for c in value):
|
|
213
|
+
raise ValidationError(f"{param_name} contains control characters")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _validate_multicast_address(address: str) -> None:
|
|
217
|
+
"""
|
|
218
|
+
Validate multicast address format
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
address: Multicast address (IP or hostname)
|
|
222
|
+
|
|
223
|
+
Raises:
|
|
224
|
+
ValidationError: If address format is invalid
|
|
225
|
+
"""
|
|
226
|
+
if not isinstance(address, str):
|
|
227
|
+
raise ValidationError(f"Multicast address must be a string, got {type(address).__name__}")
|
|
228
|
+
if not address:
|
|
229
|
+
raise ValidationError("Multicast address cannot be empty")
|
|
230
|
+
|
|
231
|
+
# Try to parse as IP address
|
|
232
|
+
try:
|
|
233
|
+
# Simple validation - socket.inet_aton will catch malformed addresses
|
|
234
|
+
socket.inet_aton(address)
|
|
235
|
+
except OSError:
|
|
236
|
+
# Not a valid IP, could be hostname - allow it
|
|
237
|
+
# radiod will resolve it or hash it to generate multicast address
|
|
238
|
+
if len(address) > 255:
|
|
239
|
+
raise ValidationError(f"Hostname too long: {len(address)} chars (max 255)")
|
|
240
|
+
if '\x00' in address:
|
|
241
|
+
raise ValidationError("Multicast address contains null bytes")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def encode_int64(buf: bytearray, type_val: int, x: int) -> int:
|
|
245
|
+
"""
|
|
246
|
+
Encode a 64-bit integer in TLV format
|
|
247
|
+
|
|
248
|
+
Format: [type:1][length:1][value:variable]
|
|
249
|
+
Value is big-endian, with leading zeros compressed
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
buf: Buffer to write to
|
|
253
|
+
type_val: TLV type identifier
|
|
254
|
+
x: Integer value (must be 0 <= x <= 2^64-1)
|
|
255
|
+
|
|
256
|
+
Raises:
|
|
257
|
+
ValidationError: If x is negative or too large
|
|
258
|
+
"""
|
|
259
|
+
if x < 0:
|
|
260
|
+
raise ValidationError(f"Cannot encode negative integer: {x}")
|
|
261
|
+
if x >= 2**64:
|
|
262
|
+
raise ValidationError(f"Integer too large for 64-bit encoding: {x}")
|
|
263
|
+
|
|
264
|
+
buf.append(type_val)
|
|
265
|
+
|
|
266
|
+
if x == 0:
|
|
267
|
+
# Compress zero to zero length
|
|
268
|
+
buf.append(0)
|
|
269
|
+
return 2
|
|
270
|
+
|
|
271
|
+
# Convert to bytes and remove leading zeros
|
|
272
|
+
x_bytes = x.to_bytes(8, byteorder='big')
|
|
273
|
+
# Find first non-zero byte
|
|
274
|
+
start = 0
|
|
275
|
+
while start < len(x_bytes) and x_bytes[start] == 0:
|
|
276
|
+
start += 1
|
|
277
|
+
|
|
278
|
+
value_bytes = x_bytes[start:]
|
|
279
|
+
length = len(value_bytes)
|
|
280
|
+
|
|
281
|
+
buf.append(length)
|
|
282
|
+
buf.extend(value_bytes)
|
|
283
|
+
|
|
284
|
+
return 2 + length
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def encode_int(buf: bytearray, type_val: int, x: int) -> int:
|
|
288
|
+
"""
|
|
289
|
+
Encode an integer in TLV format (alias for encode_int64)
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
buf: Buffer to write to
|
|
293
|
+
type_val: TLV type identifier
|
|
294
|
+
x: Integer value (must be 0 <= x <= 2^64-1)
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
Number of bytes written (2 + value length)
|
|
298
|
+
|
|
299
|
+
Raises:
|
|
300
|
+
ValidationError: If x is negative or too large
|
|
301
|
+
"""
|
|
302
|
+
return encode_int64(buf, type_val, x)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def encode_double(buf: bytearray, type_val: int, x: float) -> int:
|
|
306
|
+
"""
|
|
307
|
+
Encode a double-precision float (float64) in TLV format
|
|
308
|
+
|
|
309
|
+
Converts the float to IEEE 754 double-precision format, then encodes
|
|
310
|
+
it as a 64-bit integer in TLV format.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
buf: Buffer to write to
|
|
314
|
+
type_val: TLV type identifier
|
|
315
|
+
x: Float value to encode
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
Number of bytes written (2 + value length)
|
|
319
|
+
|
|
320
|
+
Example:
|
|
321
|
+
>>> buf = bytearray()
|
|
322
|
+
>>> encode_double(buf, StatusType.RADIO_FREQUENCY, 14.074e6)
|
|
323
|
+
10
|
|
324
|
+
"""
|
|
325
|
+
# Pack as double, unpack as uint64
|
|
326
|
+
packed = struct.pack('>d', x) # big-endian double
|
|
327
|
+
value = struct.unpack('>Q', packed)[0] # big-endian uint64
|
|
328
|
+
return encode_int64(buf, type_val, value)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def encode_float(buf: bytearray, type_val: int, x: float) -> int:
|
|
332
|
+
"""
|
|
333
|
+
Encode a single-precision float (float32) in TLV format
|
|
334
|
+
|
|
335
|
+
Converts the float to IEEE 754 single-precision format, then encodes
|
|
336
|
+
it as a 32-bit integer in TLV format.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
buf: Buffer to write to
|
|
340
|
+
type_val: TLV type identifier
|
|
341
|
+
x: Float value to encode
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
Number of bytes written (2 + value length)
|
|
345
|
+
|
|
346
|
+
Note:
|
|
347
|
+
Single-precision floats have less precision than doubles.
|
|
348
|
+
Use encode_double() for better precision when needed.
|
|
349
|
+
"""
|
|
350
|
+
# Pack as float, unpack as uint32
|
|
351
|
+
packed = struct.pack('>f', x) # big-endian float
|
|
352
|
+
value = struct.unpack('>I', packed)[0] # big-endian uint32
|
|
353
|
+
return encode_int64(buf, type_val, value)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def encode_string(buf: bytearray, type_val: int, s: str) -> int:
|
|
357
|
+
"""
|
|
358
|
+
Encode a UTF-8 string in TLV format
|
|
359
|
+
|
|
360
|
+
Strings are encoded with variable-length encoding:
|
|
361
|
+
- Length < 128: single-byte length
|
|
362
|
+
- Length >= 128: multi-byte length (0x80 | high_byte, low_byte)
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
buf: Buffer to write to
|
|
366
|
+
type_val: TLV type identifier
|
|
367
|
+
s: String to encode (will be converted to UTF-8)
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
Number of bytes written (2 + string length)
|
|
371
|
+
|
|
372
|
+
Raises:
|
|
373
|
+
ValueError: If string is longer than 65535 bytes
|
|
374
|
+
|
|
375
|
+
Example:
|
|
376
|
+
>>> buf = bytearray()
|
|
377
|
+
>>> encode_string(buf, StatusType.PRESET, "usb")
|
|
378
|
+
5
|
|
379
|
+
"""
|
|
380
|
+
buf.append(type_val)
|
|
381
|
+
|
|
382
|
+
s_bytes = s.encode('utf-8')
|
|
383
|
+
length = len(s_bytes)
|
|
384
|
+
|
|
385
|
+
if length < 128:
|
|
386
|
+
buf.append(length)
|
|
387
|
+
elif length < 65536:
|
|
388
|
+
# Multi-byte length encoding
|
|
389
|
+
buf.append(0x80 | (length >> 8))
|
|
390
|
+
buf.append(length & 0xff)
|
|
391
|
+
else:
|
|
392
|
+
raise ValueError(f"String too long: {length} bytes")
|
|
393
|
+
|
|
394
|
+
buf.extend(s_bytes)
|
|
395
|
+
return 2 + length
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def encode_socket(buf: bytearray, type_val: int, address: str, port: int = 5004) -> int:
|
|
399
|
+
"""
|
|
400
|
+
Encode a socket address (IPv4) in TLV format
|
|
401
|
+
|
|
402
|
+
Format matches radiod's decode_socket expectations:
|
|
403
|
+
- Address: 4 bytes (IPv4 address in network order)
|
|
404
|
+
- Port: 2 bytes (big-endian)
|
|
405
|
+
Total: 6 bytes for IPv4
|
|
406
|
+
|
|
407
|
+
Note: Family (AF_INET/AF_INET6) is inferred from length by radiod:
|
|
408
|
+
- length 6 = IPv4
|
|
409
|
+
- length 10 = IPv6 (not currently supported)
|
|
410
|
+
|
|
411
|
+
Args:
|
|
412
|
+
buf: Buffer to write to
|
|
413
|
+
type_val: TLV type identifier
|
|
414
|
+
address: IP address as string (e.g., "239.1.2.3")
|
|
415
|
+
port: Port number (default: 5004 for RTP)
|
|
416
|
+
|
|
417
|
+
Returns:
|
|
418
|
+
Number of bytes written (8 = type + length + 6 bytes data)
|
|
419
|
+
|
|
420
|
+
Raises:
|
|
421
|
+
ValidationError: If address format is invalid
|
|
422
|
+
|
|
423
|
+
Example:
|
|
424
|
+
>>> buf = bytearray()
|
|
425
|
+
>>> encode_socket(buf, StatusType.OUTPUT_DATA_DEST_SOCKET, "239.1.2.3", 5004)
|
|
426
|
+
8
|
|
427
|
+
"""
|
|
428
|
+
buf.append(type_val)
|
|
429
|
+
|
|
430
|
+
# Encode the socket address
|
|
431
|
+
try:
|
|
432
|
+
# Convert string IP to 4 bytes (already in network order)
|
|
433
|
+
addr_bytes = socket.inet_aton(address)
|
|
434
|
+
except OSError as e:
|
|
435
|
+
raise ValidationError(f"Invalid IP address '{address}': {e}")
|
|
436
|
+
|
|
437
|
+
# Validate port range
|
|
438
|
+
if not (0 <= port <= 65535):
|
|
439
|
+
raise ValidationError(f"Invalid port {port} (must be 0-65535)")
|
|
440
|
+
|
|
441
|
+
# Format: address(4 bytes) + port(2 bytes, network order)
|
|
442
|
+
# This matches radiod's decode_socket() expectations
|
|
443
|
+
buf.append(6) # length for IPv4
|
|
444
|
+
buf.extend(addr_bytes) # 4 bytes address (already in network order from inet_aton)
|
|
445
|
+
buf.extend(struct.pack('>H', port)) # 2 bytes port (big-endian)
|
|
446
|
+
|
|
447
|
+
return 2 + 6 # type + length + data
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def encode_eol(buf: bytearray) -> int:
|
|
451
|
+
"""
|
|
452
|
+
Encode end-of-list marker
|
|
453
|
+
|
|
454
|
+
Every TLV command must end with an EOL marker to signal
|
|
455
|
+
the end of the parameter list.
|
|
456
|
+
|
|
457
|
+
Args:
|
|
458
|
+
buf: Buffer to write to
|
|
459
|
+
|
|
460
|
+
Returns:
|
|
461
|
+
Number of bytes written (always 1)
|
|
462
|
+
"""
|
|
463
|
+
buf.append(StatusType.EOL)
|
|
464
|
+
return 1
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
# Decode functions for parsing TLV responses
|
|
468
|
+
|
|
469
|
+
def decode_int(data: bytes, length: int) -> int:
|
|
470
|
+
"""
|
|
471
|
+
Decode an integer from TLV response
|
|
472
|
+
|
|
473
|
+
Args:
|
|
474
|
+
data: Bytes to decode (variable length, big-endian)
|
|
475
|
+
length: Number of bytes
|
|
476
|
+
|
|
477
|
+
Returns:
|
|
478
|
+
Integer value
|
|
479
|
+
|
|
480
|
+
Raises:
|
|
481
|
+
ValidationError: If length is negative or data is insufficient
|
|
482
|
+
"""
|
|
483
|
+
# Validate length
|
|
484
|
+
if length < 0:
|
|
485
|
+
raise ValidationError(f"Negative length in decode_int: {length}")
|
|
486
|
+
if length == 0:
|
|
487
|
+
return 0
|
|
488
|
+
if length > 8:
|
|
489
|
+
logger.warning(f"Integer length {length} exceeds 8 bytes, truncating")
|
|
490
|
+
length = 8
|
|
491
|
+
if len(data) < length:
|
|
492
|
+
raise ValidationError(f"Insufficient data: need {length} bytes, have {len(data)}")
|
|
493
|
+
|
|
494
|
+
value = 0
|
|
495
|
+
for i in range(length):
|
|
496
|
+
value = (value << 8) | data[i]
|
|
497
|
+
return value
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def decode_int32(data: bytes, length: int) -> int:
|
|
501
|
+
"""
|
|
502
|
+
Decode a 32-bit integer from TLV response (alias for decode_int)
|
|
503
|
+
|
|
504
|
+
Args:
|
|
505
|
+
data: Bytes to decode (variable length, big-endian)
|
|
506
|
+
length: Number of bytes
|
|
507
|
+
|
|
508
|
+
Returns:
|
|
509
|
+
Integer value
|
|
510
|
+
"""
|
|
511
|
+
return decode_int(data, length)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def decode_int64(data: bytes, length: int) -> int:
|
|
515
|
+
"""
|
|
516
|
+
Decode a 64-bit integer from TLV response (alias for decode_int)
|
|
517
|
+
|
|
518
|
+
Args:
|
|
519
|
+
data: Bytes to decode (variable length, big-endian)
|
|
520
|
+
length: Number of bytes
|
|
521
|
+
|
|
522
|
+
Returns:
|
|
523
|
+
Integer value (up to 64-bit)
|
|
524
|
+
"""
|
|
525
|
+
return decode_int(data, length)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def decode_float(data: bytes, length: int) -> float:
|
|
529
|
+
"""
|
|
530
|
+
Decode a float (float32) from TLV response
|
|
531
|
+
|
|
532
|
+
Args:
|
|
533
|
+
data: Bytes to decode (big-endian IEEE 754)
|
|
534
|
+
length: Number of bytes (should be 4 or less with leading zeros stripped)
|
|
535
|
+
|
|
536
|
+
Returns:
|
|
537
|
+
Float value
|
|
538
|
+
|
|
539
|
+
Raises:
|
|
540
|
+
ValidationError: If length is negative or data is insufficient
|
|
541
|
+
"""
|
|
542
|
+
# Validate length
|
|
543
|
+
if length < 0:
|
|
544
|
+
raise ValidationError(f"Negative length in decode_float: {length}")
|
|
545
|
+
if length > 4:
|
|
546
|
+
logger.warning(f"Float length {length} exceeds 4 bytes, truncating")
|
|
547
|
+
length = 4
|
|
548
|
+
if len(data) < length:
|
|
549
|
+
raise ValidationError(f"Insufficient data: need {length} bytes, have {len(data)}")
|
|
550
|
+
|
|
551
|
+
# Reconstruct 4-byte big-endian representation
|
|
552
|
+
value_bytes = b'\x00' * (4 - length) + data[:length]
|
|
553
|
+
return struct.unpack('>f', value_bytes)[0]
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def decode_double(data: bytes, length: int) -> float:
|
|
557
|
+
"""
|
|
558
|
+
Decode a double (float64) from TLV response
|
|
559
|
+
|
|
560
|
+
Args:
|
|
561
|
+
data: Bytes to decode (big-endian IEEE 754)
|
|
562
|
+
length: Number of bytes (should be 8 or less with leading zeros stripped)
|
|
563
|
+
|
|
564
|
+
Returns:
|
|
565
|
+
Float value
|
|
566
|
+
|
|
567
|
+
Raises:
|
|
568
|
+
ValidationError: If length is negative or data is insufficient
|
|
569
|
+
"""
|
|
570
|
+
# Validate length
|
|
571
|
+
if length < 0:
|
|
572
|
+
raise ValidationError(f"Negative length in decode_double: {length}")
|
|
573
|
+
if length > 8:
|
|
574
|
+
logger.warning(f"Double length {length} exceeds 8 bytes, truncating")
|
|
575
|
+
length = 8
|
|
576
|
+
if len(data) < length:
|
|
577
|
+
raise ValidationError(f"Insufficient data: need {length} bytes, have {len(data)}")
|
|
578
|
+
|
|
579
|
+
# Reconstruct 8-byte big-endian representation
|
|
580
|
+
value_bytes = b'\x00' * (8 - length) + data[:length]
|
|
581
|
+
return struct.unpack('>d', value_bytes)[0]
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def decode_bool(data: bytes, length: int) -> bool:
|
|
585
|
+
"""
|
|
586
|
+
Decode a boolean value from TLV response
|
|
587
|
+
|
|
588
|
+
Args:
|
|
589
|
+
data: Bytes to decode
|
|
590
|
+
length: Number of bytes
|
|
591
|
+
|
|
592
|
+
Returns:
|
|
593
|
+
True if non-zero, False if zero
|
|
594
|
+
"""
|
|
595
|
+
return decode_int(data, length) != 0
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def decode_string(data: bytes, length: int) -> str:
|
|
599
|
+
"""
|
|
600
|
+
Decode a UTF-8 string from TLV response
|
|
601
|
+
|
|
602
|
+
Args:
|
|
603
|
+
data: Bytes to decode
|
|
604
|
+
length: String length in bytes
|
|
605
|
+
|
|
606
|
+
Returns:
|
|
607
|
+
Decoded string
|
|
608
|
+
|
|
609
|
+
Raises:
|
|
610
|
+
ValidationError: If length is negative or data is insufficient
|
|
611
|
+
"""
|
|
612
|
+
# Validate length
|
|
613
|
+
if length < 0:
|
|
614
|
+
raise ValidationError(f"Negative length in decode_string: {length}")
|
|
615
|
+
if length > 65535:
|
|
616
|
+
logger.warning(f"String length {length} exceeds maximum, truncating to 65535")
|
|
617
|
+
length = 65535
|
|
618
|
+
if len(data) < length:
|
|
619
|
+
logger.warning(f"String data truncated: expected {length} bytes, have {len(data)}")
|
|
620
|
+
length = len(data)
|
|
621
|
+
|
|
622
|
+
return data[:length].decode('utf-8', errors='replace')
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def decode_socket(data: bytes, length: int) -> dict:
|
|
626
|
+
"""
|
|
627
|
+
Decode a socket address from TLV response
|
|
628
|
+
|
|
629
|
+
Args:
|
|
630
|
+
data: Bytes containing socket address
|
|
631
|
+
length: Length of socket data
|
|
632
|
+
|
|
633
|
+
Returns:
|
|
634
|
+
Dictionary with 'family', 'address', and 'port' keys
|
|
635
|
+
|
|
636
|
+
Note:
|
|
637
|
+
Handles two formats:
|
|
638
|
+
- With family field: 2 bytes family + 2 bytes port + N bytes address (length 8 for IPv4, 18 for IPv6)
|
|
639
|
+
- Without family field: N bytes address + 2 bytes port (length 6 for IPv4, 10 for IPv6)
|
|
640
|
+
"""
|
|
641
|
+
if length == 8:
|
|
642
|
+
# Format WITH family field: family(2) + port(2) + address(4) for IPv4
|
|
643
|
+
family = struct.unpack('>H', data[0:2])[0]
|
|
644
|
+
port = struct.unpack('>H', data[2:4])[0]
|
|
645
|
+
# Check if it's actually IPv4 (AF_INET = 2)
|
|
646
|
+
if family == 2:
|
|
647
|
+
address = socket.inet_ntoa(data[4:8])
|
|
648
|
+
return {'family': 'IPv4', 'address': address, 'port': port}
|
|
649
|
+
else:
|
|
650
|
+
# Unknown family
|
|
651
|
+
return {'family': f'unknown (family={family})', 'address': '', 'port': port}
|
|
652
|
+
elif length == 6:
|
|
653
|
+
# Format WITHOUT family field: address(4) + port(2) for IPv4
|
|
654
|
+
address = socket.inet_ntoa(data[0:4])
|
|
655
|
+
port = struct.unpack('>H', data[4:6])[0]
|
|
656
|
+
return {'family': 'IPv4', 'address': address, 'port': port}
|
|
657
|
+
elif length == 10:
|
|
658
|
+
# Format WITHOUT family field: address(8) + port(2) for IPv6 (truncated)
|
|
659
|
+
# Note: This is a truncated IPv6 address (only 8 bytes instead of 16)
|
|
660
|
+
address_bytes = data[0:8]
|
|
661
|
+
port = struct.unpack('>H', data[8:10])[0]
|
|
662
|
+
# Format as hex string since it's truncated
|
|
663
|
+
address = ':'.join(f'{address_bytes[i]:02x}{address_bytes[i+1]:02x}'
|
|
664
|
+
for i in range(0, 8, 2))
|
|
665
|
+
return {'family': 'IPv6', 'address': address, 'port': port}
|
|
666
|
+
else:
|
|
667
|
+
return {'family': 'unknown', 'address': '', 'port': 0}
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
class RadiodControl:
|
|
671
|
+
"""
|
|
672
|
+
Control interface for radiod
|
|
673
|
+
|
|
674
|
+
Sends TLV-encoded commands to radiod's control socket to create
|
|
675
|
+
and configure channels.
|
|
676
|
+
"""
|
|
677
|
+
|
|
678
|
+
def __init__(self, status_address: str, max_commands_per_sec: int = 100,
|
|
679
|
+
interface: Optional[str] = None):
|
|
680
|
+
"""
|
|
681
|
+
Initialize radiod control
|
|
682
|
+
|
|
683
|
+
Args:
|
|
684
|
+
status_address: mDNS name or IP:port of radiod status stream
|
|
685
|
+
max_commands_per_sec: Maximum commands per second (rate limiting)
|
|
686
|
+
interface: IP address of network interface for multicast (e.g., '192.168.1.100').
|
|
687
|
+
Required on multi-homed systems. If None, uses INADDR_ANY (0.0.0.0).
|
|
688
|
+
"""
|
|
689
|
+
self.status_address = status_address
|
|
690
|
+
self.interface = interface
|
|
691
|
+
self.socket = None
|
|
692
|
+
self._status_sock = None # Cached status listener socket for tune()
|
|
693
|
+
self._status_sock_lock = None # Will be initialized when needed
|
|
694
|
+
self._socket_lock = threading.RLock() # Protect control socket operations
|
|
695
|
+
|
|
696
|
+
# Rate limiting
|
|
697
|
+
self.max_commands_per_sec = max_commands_per_sec
|
|
698
|
+
self._command_count = 0
|
|
699
|
+
self._command_window_start = time.time()
|
|
700
|
+
self._rate_limit_lock = threading.Lock()
|
|
701
|
+
|
|
702
|
+
# Metrics tracking
|
|
703
|
+
self.metrics = Metrics()
|
|
704
|
+
|
|
705
|
+
self._connect()
|
|
706
|
+
|
|
707
|
+
def __enter__(self):
|
|
708
|
+
"""Context manager entry"""
|
|
709
|
+
return self
|
|
710
|
+
|
|
711
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
712
|
+
"""Context manager exit - ensure cleanup"""
|
|
713
|
+
try:
|
|
714
|
+
self.close()
|
|
715
|
+
except Exception as e:
|
|
716
|
+
logger.warning(f"Error during cleanup in context manager: {e}")
|
|
717
|
+
return False # Don't suppress exceptions
|
|
718
|
+
|
|
719
|
+
def _connect(self):
|
|
720
|
+
"""Connect to radiod control socket"""
|
|
721
|
+
try:
|
|
722
|
+
# Resolve the status address using shared utility
|
|
723
|
+
mcast_addr = resolve_multicast_address(self.status_address, timeout=5.0)
|
|
724
|
+
|
|
725
|
+
# Create UDP socket
|
|
726
|
+
self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
727
|
+
|
|
728
|
+
# Allow multiple sockets to bind to the same port
|
|
729
|
+
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
730
|
+
|
|
731
|
+
# Set socket options for multicast
|
|
732
|
+
import struct
|
|
733
|
+
|
|
734
|
+
# Determine interface address for multicast operations
|
|
735
|
+
# Use specified interface for multi-homed systems, or INADDR_ANY otherwise
|
|
736
|
+
interface_addr = self.interface if self.interface else '0.0.0.0'
|
|
737
|
+
|
|
738
|
+
# Set multicast interface for sending
|
|
739
|
+
self.socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF,
|
|
740
|
+
socket.inet_aton(interface_addr))
|
|
741
|
+
logger.debug(f"Set IP_MULTICAST_IF to {interface_addr}")
|
|
742
|
+
|
|
743
|
+
# Join the multicast group on specified interface
|
|
744
|
+
mreq = struct.pack('=4s4s',
|
|
745
|
+
socket.inet_aton(mcast_addr), # multicast group address
|
|
746
|
+
socket.inet_aton(interface_addr)) # interface to use
|
|
747
|
+
try:
|
|
748
|
+
self.socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
|
|
749
|
+
logger.debug(f"Joined multicast group {mcast_addr} on interface {interface_addr}")
|
|
750
|
+
except OSError as e:
|
|
751
|
+
# EADDRINUSE is not fatal - group already joined
|
|
752
|
+
if e.errno != 48: # EADDRINUSE on macOS
|
|
753
|
+
logger.warning(f"Failed to join multicast group: {e}")
|
|
754
|
+
|
|
755
|
+
# Enable multicast loopback
|
|
756
|
+
self.socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, 1)
|
|
757
|
+
# Set TTL for multicast packets
|
|
758
|
+
self.socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
|
|
759
|
+
|
|
760
|
+
# Store both status and control addresses
|
|
761
|
+
# Status address is where we listen for status multicast
|
|
762
|
+
# Control address is where we send commands (same as status for now)
|
|
763
|
+
self.status_mcast_addr = mcast_addr
|
|
764
|
+
self.dest_addr = (mcast_addr, 5006) # Standard radiod control port
|
|
765
|
+
|
|
766
|
+
logger.info(f"Connected to radiod at {mcast_addr}:5006")
|
|
767
|
+
logger.debug(f"Status multicast: {self.status_mcast_addr}, Control: {self.dest_addr}")
|
|
768
|
+
logger.debug(f"Socket options: REUSEADDR=1, MULTICAST_IF=INADDR_ANY, MULTICAST_LOOP=1, MULTICAST_TTL=2")
|
|
769
|
+
|
|
770
|
+
except socket.error as e:
|
|
771
|
+
logger.error(f"Socket error connecting to radiod: {e}")
|
|
772
|
+
raise ConnectionError(f"Failed to create socket: {e}") from e
|
|
773
|
+
except Exception as e:
|
|
774
|
+
logger.error(f"Unexpected error connecting to radiod: {e}", exc_info=True)
|
|
775
|
+
raise ConnectionError(f"Failed to connect to radiod: {e}") from e
|
|
776
|
+
|
|
777
|
+
def _check_rate_limit(self):
|
|
778
|
+
"""
|
|
779
|
+
Check and enforce rate limiting (thread-safe)
|
|
780
|
+
|
|
781
|
+
Implements a sliding window rate limiter to prevent DoS attacks
|
|
782
|
+
and network flooding.
|
|
783
|
+
"""
|
|
784
|
+
with self._rate_limit_lock:
|
|
785
|
+
now = time.time()
|
|
786
|
+
|
|
787
|
+
# Reset window every second
|
|
788
|
+
if now - self._command_window_start >= 1.0:
|
|
789
|
+
self._command_count = 0
|
|
790
|
+
self._command_window_start = now
|
|
791
|
+
|
|
792
|
+
# Check limit
|
|
793
|
+
if self._command_count >= self.max_commands_per_sec:
|
|
794
|
+
sleep_time = 1.0 - (now - self._command_window_start)
|
|
795
|
+
if sleep_time > 0:
|
|
796
|
+
logger.warning(
|
|
797
|
+
f"Rate limit reached ({self.max_commands_per_sec}/sec), "
|
|
798
|
+
f"sleeping {sleep_time:.3f}s"
|
|
799
|
+
)
|
|
800
|
+
time.sleep(sleep_time)
|
|
801
|
+
# Reset after sleeping
|
|
802
|
+
self._command_count = 0
|
|
803
|
+
self._command_window_start = time.time()
|
|
804
|
+
|
|
805
|
+
self._command_count += 1
|
|
806
|
+
|
|
807
|
+
def send_command(self, cmdbuffer: bytearray, max_retries: int = 3, retry_delay: float = 0.1):
|
|
808
|
+
"""
|
|
809
|
+
Send a command packet to radiod with retry logic (thread-safe)
|
|
810
|
+
|
|
811
|
+
Args:
|
|
812
|
+
cmdbuffer: Command buffer to send
|
|
813
|
+
max_retries: Maximum number of retry attempts (default: 3)
|
|
814
|
+
retry_delay: Initial delay between retries in seconds (default: 0.1)
|
|
815
|
+
Uses exponential backoff: 0.1s, 0.2s, 0.4s, etc.
|
|
816
|
+
|
|
817
|
+
Raises:
|
|
818
|
+
RuntimeError: If not connected to radiod
|
|
819
|
+
CommandError: If sending fails after all retries
|
|
820
|
+
"""
|
|
821
|
+
import time
|
|
822
|
+
|
|
823
|
+
with self._socket_lock:
|
|
824
|
+
if not self.socket:
|
|
825
|
+
raise RuntimeError("Not connected to radiod")
|
|
826
|
+
|
|
827
|
+
# Apply rate limiting
|
|
828
|
+
self._check_rate_limit()
|
|
829
|
+
|
|
830
|
+
with self._socket_lock:
|
|
831
|
+
attempt = 0
|
|
832
|
+
last_error = None
|
|
833
|
+
for attempt in range(max_retries):
|
|
834
|
+
try:
|
|
835
|
+
# Log hex dump of the command
|
|
836
|
+
hex_dump = ' '.join(f'{b:02x}' for b in cmdbuffer)
|
|
837
|
+
logger.debug(f"Sending {len(cmdbuffer)} bytes to {self.dest_addr} (attempt {attempt+1}/{max_retries}): {hex_dump}")
|
|
838
|
+
|
|
839
|
+
sent = self.socket.sendto(bytes(cmdbuffer), self.dest_addr)
|
|
840
|
+
logger.debug(f"Command sent successfully (attempt {attempt + 1})")
|
|
841
|
+
self.metrics.commands_sent += 1
|
|
842
|
+
return sent
|
|
843
|
+
|
|
844
|
+
except socket.error as e:
|
|
845
|
+
last_error = e
|
|
846
|
+
if attempt < max_retries - 1:
|
|
847
|
+
# Exponential backoff
|
|
848
|
+
delay = retry_delay * (2 ** attempt)
|
|
849
|
+
logger.warning(f"Socket error on attempt {attempt+1}/{max_retries}: {e}. Retrying in {delay:.2f}s...")
|
|
850
|
+
time.sleep(delay)
|
|
851
|
+
else:
|
|
852
|
+
logger.error(f"Failed to send command after {max_retries} attempts: {last_error}")
|
|
853
|
+
self.metrics.commands_sent += 1
|
|
854
|
+
self.metrics.commands_failed += 1
|
|
855
|
+
self.metrics.last_error = str(last_error)
|
|
856
|
+
self.metrics.last_error_time = time.time()
|
|
857
|
+
error_type = type(last_error).__name__
|
|
858
|
+
self.metrics.errors_by_type[error_type] = self.metrics.errors_by_type.get(error_type, 0) + 1
|
|
859
|
+
raise CommandError(f"Command failed after {max_retries} attempts") from last_error
|
|
860
|
+
|
|
861
|
+
except Exception as e:
|
|
862
|
+
logger.error(f"Unexpected error sending command: {e}", exc_info=True)
|
|
863
|
+
raise CommandError(f"Failed to send command: {e}") from e
|
|
864
|
+
|
|
865
|
+
# Should not reach here, but just in case
|
|
866
|
+
if last_error:
|
|
867
|
+
raise CommandError(f"Failed to send command after {max_retries} attempts: {last_error}") from last_error
|
|
868
|
+
|
|
869
|
+
def set_frequency(self, ssrc: int, frequency_hz: float):
|
|
870
|
+
"""
|
|
871
|
+
Set the frequency of a channel
|
|
872
|
+
|
|
873
|
+
Args:
|
|
874
|
+
ssrc: SSRC of the channel
|
|
875
|
+
frequency_hz: Frequency in Hz
|
|
876
|
+
|
|
877
|
+
Raises:
|
|
878
|
+
ValidationError: If parameters are invalid
|
|
879
|
+
"""
|
|
880
|
+
_validate_ssrc(ssrc)
|
|
881
|
+
_validate_frequency(frequency_hz)
|
|
882
|
+
|
|
883
|
+
cmdbuffer = bytearray()
|
|
884
|
+
cmdbuffer.append(CMD) # Command packet type
|
|
885
|
+
|
|
886
|
+
encode_double(cmdbuffer, StatusType.RADIO_FREQUENCY, frequency_hz)
|
|
887
|
+
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
888
|
+
encode_int(cmdbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
889
|
+
encode_eol(cmdbuffer)
|
|
890
|
+
|
|
891
|
+
logger.info(f"Setting frequency for SSRC {ssrc} to {frequency_hz/1e6:.3f} MHz")
|
|
892
|
+
self.send_command(cmdbuffer)
|
|
893
|
+
|
|
894
|
+
def set_preset(self, ssrc: int, preset: str):
|
|
895
|
+
"""
|
|
896
|
+
Set the preset (mode) of a channel
|
|
897
|
+
|
|
898
|
+
Args:
|
|
899
|
+
ssrc: SSRC of the channel
|
|
900
|
+
preset: Preset name (e.g., "iq", "usb", "lsb")
|
|
901
|
+
|
|
902
|
+
Raises:
|
|
903
|
+
ValidationError: If preset name is invalid
|
|
904
|
+
"""
|
|
905
|
+
_validate_ssrc(ssrc)
|
|
906
|
+
_validate_preset(preset)
|
|
907
|
+
|
|
908
|
+
cmdbuffer = bytearray()
|
|
909
|
+
cmdbuffer.append(CMD)
|
|
910
|
+
|
|
911
|
+
encode_string(cmdbuffer, StatusType.PRESET, preset)
|
|
912
|
+
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
913
|
+
encode_int(cmdbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
914
|
+
encode_eol(cmdbuffer)
|
|
915
|
+
|
|
916
|
+
logger.info(f"Setting preset for SSRC {ssrc} to {preset}")
|
|
917
|
+
self.send_command(cmdbuffer)
|
|
918
|
+
|
|
919
|
+
def set_sample_rate(self, ssrc: int, sample_rate: int):
|
|
920
|
+
"""
|
|
921
|
+
Set the sample rate of a channel
|
|
922
|
+
|
|
923
|
+
Args:
|
|
924
|
+
ssrc: SSRC of the channel
|
|
925
|
+
sample_rate: Sample rate in Hz
|
|
926
|
+
|
|
927
|
+
Raises:
|
|
928
|
+
ValidationError: If parameters are invalid
|
|
929
|
+
"""
|
|
930
|
+
_validate_ssrc(ssrc)
|
|
931
|
+
_validate_sample_rate(sample_rate)
|
|
932
|
+
|
|
933
|
+
cmdbuffer = bytearray()
|
|
934
|
+
cmdbuffer.append(CMD)
|
|
935
|
+
|
|
936
|
+
encode_int(cmdbuffer, StatusType.OUTPUT_SAMPRATE, sample_rate)
|
|
937
|
+
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
938
|
+
encode_int(cmdbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
939
|
+
encode_eol(cmdbuffer)
|
|
940
|
+
|
|
941
|
+
logger.info(f"Setting sample rate for SSRC {ssrc} to {sample_rate} Hz")
|
|
942
|
+
self.send_command(cmdbuffer)
|
|
943
|
+
|
|
944
|
+
def set_agc(self, ssrc: int, enable: bool, hangtime: Optional[float] = None,
|
|
945
|
+
headroom: Optional[float] = None, recovery_rate: Optional[float] = None,
|
|
946
|
+
attack_rate: Optional[float] = None):
|
|
947
|
+
"""
|
|
948
|
+
Configure AGC (Automatic Gain Control) for a channel
|
|
949
|
+
|
|
950
|
+
Args:
|
|
951
|
+
ssrc: SSRC of the channel
|
|
952
|
+
enable: Enable/disable AGC (True=enabled, False=manual gain)
|
|
953
|
+
hangtime: AGC hang time in seconds (optional)
|
|
954
|
+
headroom: Target headroom in dB (optional)
|
|
955
|
+
recovery_rate: AGC recovery rate (optional)
|
|
956
|
+
attack_rate: AGC attack rate (optional)
|
|
957
|
+
"""
|
|
958
|
+
cmdbuffer = bytearray()
|
|
959
|
+
cmdbuffer.append(CMD)
|
|
960
|
+
|
|
961
|
+
encode_int(cmdbuffer, StatusType.AGC_ENABLE, 1 if enable else 0)
|
|
962
|
+
if hangtime is not None:
|
|
963
|
+
encode_float(cmdbuffer, StatusType.AGC_HANGTIME, hangtime)
|
|
964
|
+
if headroom is not None:
|
|
965
|
+
encode_float(cmdbuffer, StatusType.HEADROOM, headroom)
|
|
966
|
+
if recovery_rate is not None:
|
|
967
|
+
encode_float(cmdbuffer, StatusType.AGC_RECOVERY_RATE, recovery_rate)
|
|
968
|
+
if attack_rate is not None:
|
|
969
|
+
encode_float(cmdbuffer, StatusType.AGC_ATTACK_RATE, attack_rate)
|
|
970
|
+
|
|
971
|
+
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
972
|
+
encode_int(cmdbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
973
|
+
encode_eol(cmdbuffer)
|
|
974
|
+
|
|
975
|
+
logger.info(f"Setting AGC for SSRC {ssrc}: enable={enable}, hangtime={hangtime}, headroom={headroom}")
|
|
976
|
+
self.send_command(cmdbuffer)
|
|
977
|
+
|
|
978
|
+
def set_gain(self, ssrc: int, gain_db: float):
|
|
979
|
+
"""
|
|
980
|
+
Set manual gain for a channel (linear modes only)
|
|
981
|
+
|
|
982
|
+
Args:
|
|
983
|
+
ssrc: SSRC of the channel
|
|
984
|
+
gain_db: Gain in dB
|
|
985
|
+
|
|
986
|
+
Raises:
|
|
987
|
+
ValidationError: If parameters are invalid
|
|
988
|
+
"""
|
|
989
|
+
_validate_ssrc(ssrc)
|
|
990
|
+
_validate_gain(gain_db)
|
|
991
|
+
|
|
992
|
+
cmdbuffer = bytearray()
|
|
993
|
+
cmdbuffer.append(CMD)
|
|
994
|
+
|
|
995
|
+
encode_double(cmdbuffer, StatusType.GAIN, gain_db)
|
|
996
|
+
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
997
|
+
encode_int(cmdbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
998
|
+
encode_eol(cmdbuffer)
|
|
999
|
+
|
|
1000
|
+
logger.info(f"Setting gain for SSRC {ssrc} to {gain_db} dB")
|
|
1001
|
+
self.send_command(cmdbuffer)
|
|
1002
|
+
|
|
1003
|
+
def set_filter(self, ssrc: int, low_edge: Optional[float] = None,
|
|
1004
|
+
high_edge: Optional[float] = None, kaiser_beta: Optional[float] = None):
|
|
1005
|
+
"""
|
|
1006
|
+
Configure filter parameters for a channel
|
|
1007
|
+
|
|
1008
|
+
Args:
|
|
1009
|
+
ssrc: SSRC of the channel
|
|
1010
|
+
low_edge: Low frequency edge in Hz (optional)
|
|
1011
|
+
high_edge: High frequency edge in Hz (optional)
|
|
1012
|
+
kaiser_beta: Kaiser window beta parameter (optional)
|
|
1013
|
+
"""
|
|
1014
|
+
cmdbuffer = bytearray()
|
|
1015
|
+
cmdbuffer.append(CMD)
|
|
1016
|
+
|
|
1017
|
+
if low_edge is not None:
|
|
1018
|
+
encode_double(cmdbuffer, StatusType.LOW_EDGE, low_edge)
|
|
1019
|
+
if high_edge is not None:
|
|
1020
|
+
encode_double(cmdbuffer, StatusType.HIGH_EDGE, high_edge)
|
|
1021
|
+
if kaiser_beta is not None:
|
|
1022
|
+
encode_float(cmdbuffer, StatusType.KAISER_BETA, kaiser_beta)
|
|
1023
|
+
|
|
1024
|
+
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
1025
|
+
encode_int(cmdbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
1026
|
+
encode_eol(cmdbuffer)
|
|
1027
|
+
|
|
1028
|
+
logger.info(f"Setting filter for SSRC {ssrc}: low={low_edge}, high={high_edge}, beta={kaiser_beta}")
|
|
1029
|
+
self.send_command(cmdbuffer)
|
|
1030
|
+
|
|
1031
|
+
def set_shift_frequency(self, ssrc: int, shift_hz: float):
|
|
1032
|
+
"""
|
|
1033
|
+
Set post-detection frequency shift (for CW offset, etc.)
|
|
1034
|
+
|
|
1035
|
+
Args:
|
|
1036
|
+
ssrc: SSRC of the channel
|
|
1037
|
+
shift_hz: Frequency shift in Hz
|
|
1038
|
+
"""
|
|
1039
|
+
cmdbuffer = bytearray()
|
|
1040
|
+
cmdbuffer.append(CMD)
|
|
1041
|
+
|
|
1042
|
+
encode_double(cmdbuffer, StatusType.SHIFT_FREQUENCY, shift_hz)
|
|
1043
|
+
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
1044
|
+
encode_int(cmdbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
1045
|
+
encode_eol(cmdbuffer)
|
|
1046
|
+
|
|
1047
|
+
logger.info(f"Setting frequency shift for SSRC {ssrc} to {shift_hz} Hz")
|
|
1048
|
+
self.send_command(cmdbuffer)
|
|
1049
|
+
|
|
1050
|
+
def set_output_level(self, ssrc: int, level: float):
|
|
1051
|
+
"""
|
|
1052
|
+
Set output level for a channel
|
|
1053
|
+
|
|
1054
|
+
Args:
|
|
1055
|
+
ssrc: SSRC of the channel
|
|
1056
|
+
level: Output level (range depends on mode)
|
|
1057
|
+
"""
|
|
1058
|
+
cmdbuffer = bytearray()
|
|
1059
|
+
cmdbuffer.append(CMD)
|
|
1060
|
+
|
|
1061
|
+
encode_float(cmdbuffer, StatusType.OUTPUT_LEVEL, level)
|
|
1062
|
+
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
1063
|
+
encode_int(cmdbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
1064
|
+
encode_eol(cmdbuffer)
|
|
1065
|
+
|
|
1066
|
+
logger.info(f"Setting output level for SSRC {ssrc} to {level}")
|
|
1067
|
+
self.send_command(cmdbuffer)
|
|
1068
|
+
|
|
1069
|
+
def create_channel(self, frequency_hz: float,
|
|
1070
|
+
preset: str = "iq", sample_rate: Optional[int] = None,
|
|
1071
|
+
agc_enable: int = 0, gain: float = 0.0,
|
|
1072
|
+
destination: Optional[str] = None,
|
|
1073
|
+
ssrc: Optional[int] = None) -> int:
|
|
1074
|
+
"""
|
|
1075
|
+
Create a new channel with specified configuration
|
|
1076
|
+
|
|
1077
|
+
Creates and configures a radiod channel with a single command packet,
|
|
1078
|
+
ensuring all parameters are set together atomically.
|
|
1079
|
+
|
|
1080
|
+
SSRC can be auto-allocated by omitting the ssrc parameter. The allocation
|
|
1081
|
+
uses a deterministic hash of channel parameters, enabling stream sharing
|
|
1082
|
+
between applications using the same parameters.
|
|
1083
|
+
|
|
1084
|
+
Args:
|
|
1085
|
+
frequency_hz: Tuning frequency in Hz
|
|
1086
|
+
preset: Mode/preset name (default: "iq"). Common values:
|
|
1087
|
+
- "iq": Raw IQ output (no demodulation)
|
|
1088
|
+
- "usb": Upper sideband
|
|
1089
|
+
- "lsb": Lower sideband
|
|
1090
|
+
- "am": Amplitude modulation
|
|
1091
|
+
- "fm": Frequency modulation
|
|
1092
|
+
- "cw": Morse code
|
|
1093
|
+
sample_rate: Output sample rate in Hz (optional, uses radiod default if not set)
|
|
1094
|
+
agc_enable: Enable automatic gain control (0=off, 1=on, default: 0)
|
|
1095
|
+
gain: Manual gain in dB (default: 0.0). Only used when agc_enable=0
|
|
1096
|
+
destination: RTP destination multicast address (optional). Format: "address" or "address:port"
|
|
1097
|
+
Examples: "239.1.2.3", "wspr.local", "239.1.2.3:5004"
|
|
1098
|
+
If not specified, uses radiod's default from config file.
|
|
1099
|
+
ssrc: SSRC (channel identifier). If None, auto-allocated from parameters.
|
|
1100
|
+
Auto-allocation uses allocate_ssrc() for deterministic, shareable SSRCs.
|
|
1101
|
+
|
|
1102
|
+
Returns:
|
|
1103
|
+
The SSRC of the created channel (useful when auto-allocated)
|
|
1104
|
+
|
|
1105
|
+
Raises:
|
|
1106
|
+
CommandError: If command fails to send
|
|
1107
|
+
ValidationError: If parameters are invalid
|
|
1108
|
+
RuntimeError: If not connected to radiod
|
|
1109
|
+
|
|
1110
|
+
Example:
|
|
1111
|
+
>>> control = RadiodControl("radiod.local")
|
|
1112
|
+
>>> # SSRC-free API (recommended) - SSRC auto-allocated
|
|
1113
|
+
>>> ssrc = control.create_channel(
|
|
1114
|
+
... frequency_hz=14.074e6,
|
|
1115
|
+
... preset="usb",
|
|
1116
|
+
... sample_rate=12000
|
|
1117
|
+
... )
|
|
1118
|
+
>>> print(f"Channel created with SSRC: {ssrc}")
|
|
1119
|
+
|
|
1120
|
+
>>> # Explicit SSRC (backward compatible)
|
|
1121
|
+
>>> control.create_channel(
|
|
1122
|
+
... frequency_hz=10.0e6,
|
|
1123
|
+
... preset="iq",
|
|
1124
|
+
... ssrc=10000000
|
|
1125
|
+
... )
|
|
1126
|
+
"""
|
|
1127
|
+
# Auto-allocate SSRC if not provided
|
|
1128
|
+
if ssrc is None:
|
|
1129
|
+
ssrc = allocate_ssrc(
|
|
1130
|
+
frequency_hz=frequency_hz,
|
|
1131
|
+
preset=preset,
|
|
1132
|
+
sample_rate=sample_rate or 16000, # Default for allocation
|
|
1133
|
+
agc=bool(agc_enable),
|
|
1134
|
+
gain=gain
|
|
1135
|
+
)
|
|
1136
|
+
logger.info(f"Auto-allocated SSRC: {ssrc}")
|
|
1137
|
+
|
|
1138
|
+
# Validate inputs
|
|
1139
|
+
_validate_ssrc(ssrc)
|
|
1140
|
+
_validate_frequency(frequency_hz)
|
|
1141
|
+
if sample_rate is not None:
|
|
1142
|
+
_validate_sample_rate(sample_rate)
|
|
1143
|
+
_validate_gain(gain)
|
|
1144
|
+
|
|
1145
|
+
logger.info(f"Creating channel: SSRC={ssrc}, freq={frequency_hz/1e6:.3f} MHz, "
|
|
1146
|
+
f"demod={preset}, rate={sample_rate}Hz, agc={agc_enable}, gain={gain}dB")
|
|
1147
|
+
|
|
1148
|
+
# Build a single command packet with ALL parameters
|
|
1149
|
+
# This ensures radiod creates the channel with the correct settings
|
|
1150
|
+
cmdbuffer = bytearray()
|
|
1151
|
+
cmdbuffer.append(CMD)
|
|
1152
|
+
|
|
1153
|
+
# PRESET: Mode name (e.g., "iq", "usb", "lsb")
|
|
1154
|
+
# This MUST come first - radiod uses it to set up the channel
|
|
1155
|
+
_validate_preset(preset)
|
|
1156
|
+
encode_string(cmdbuffer, StatusType.PRESET, preset)
|
|
1157
|
+
logger.info(f"Setting preset for SSRC {ssrc} to {preset}")
|
|
1158
|
+
|
|
1159
|
+
# DEMOD_TYPE: 0=linear (IQ/USB/LSB/etc), 1=FM
|
|
1160
|
+
demod_type = 0 if preset.lower() in ['iq', 'usb', 'lsb', 'cw', 'am'] else 1
|
|
1161
|
+
encode_int(cmdbuffer, StatusType.DEMOD_TYPE, demod_type)
|
|
1162
|
+
logger.info(f"Setting DEMOD_TYPE for SSRC {ssrc} to {demod_type}")
|
|
1163
|
+
|
|
1164
|
+
# Frequency
|
|
1165
|
+
encode_double(cmdbuffer, StatusType.RADIO_FREQUENCY, frequency_hz)
|
|
1166
|
+
logger.info(f"Setting frequency for SSRC {ssrc} to {frequency_hz/1e6:.3f} MHz")
|
|
1167
|
+
|
|
1168
|
+
# Sample rate
|
|
1169
|
+
if sample_rate:
|
|
1170
|
+
encode_int(cmdbuffer, StatusType.OUTPUT_SAMPRATE, sample_rate)
|
|
1171
|
+
logger.info(f"Setting sample rate for SSRC {ssrc} to {sample_rate} Hz")
|
|
1172
|
+
|
|
1173
|
+
# AGC setting
|
|
1174
|
+
encode_int(cmdbuffer, StatusType.AGC_ENABLE, agc_enable)
|
|
1175
|
+
logger.info(f"Setting AGC_ENABLE for SSRC {ssrc} to {agc_enable}")
|
|
1176
|
+
|
|
1177
|
+
# Gain setting
|
|
1178
|
+
encode_double(cmdbuffer, StatusType.GAIN, gain)
|
|
1179
|
+
logger.info(f"Setting GAIN for SSRC {ssrc} to {gain} dB")
|
|
1180
|
+
|
|
1181
|
+
# Destination address (if specified)
|
|
1182
|
+
if destination is not None:
|
|
1183
|
+
_validate_multicast_address(destination)
|
|
1184
|
+
dest_addr = destination
|
|
1185
|
+
dest_port = 5004 # Default RTP port
|
|
1186
|
+
|
|
1187
|
+
# Check if port is specified in format "address:port"
|
|
1188
|
+
if ':' in destination:
|
|
1189
|
+
parts = destination.rsplit(':', 1)
|
|
1190
|
+
dest_addr = parts[0]
|
|
1191
|
+
try:
|
|
1192
|
+
dest_port = int(parts[1])
|
|
1193
|
+
except ValueError:
|
|
1194
|
+
raise ValidationError(f"Invalid port in destination '{destination}'")
|
|
1195
|
+
|
|
1196
|
+
encode_socket(cmdbuffer, StatusType.OUTPUT_DATA_DEST_SOCKET, dest_addr, dest_port)
|
|
1197
|
+
logger.info(f"Setting destination for SSRC {ssrc} to {dest_addr}:{dest_port}")
|
|
1198
|
+
|
|
1199
|
+
# SSRC and command tag
|
|
1200
|
+
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
1201
|
+
encode_int(cmdbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
1202
|
+
encode_eol(cmdbuffer)
|
|
1203
|
+
|
|
1204
|
+
# Send the single packet
|
|
1205
|
+
self.send_command(cmdbuffer)
|
|
1206
|
+
|
|
1207
|
+
logger.info(f"Channel {ssrc} created and configured")
|
|
1208
|
+
return ssrc
|
|
1209
|
+
|
|
1210
|
+
def verify_channel(self, ssrc: int, expected_freq: Optional[float] = None) -> bool:
|
|
1211
|
+
"""
|
|
1212
|
+
Verify that a channel exists and is configured correctly
|
|
1213
|
+
|
|
1214
|
+
Args:
|
|
1215
|
+
ssrc: SSRC to verify
|
|
1216
|
+
expected_freq: Expected frequency in Hz (optional)
|
|
1217
|
+
|
|
1218
|
+
Returns:
|
|
1219
|
+
True if channel exists and matches expectations
|
|
1220
|
+
"""
|
|
1221
|
+
# Discover current channels
|
|
1222
|
+
channels = discover_channels(self.status_address)
|
|
1223
|
+
|
|
1224
|
+
if ssrc not in channels:
|
|
1225
|
+
logger.warning(f"Channel {ssrc} not found")
|
|
1226
|
+
return False
|
|
1227
|
+
|
|
1228
|
+
channel = channels[ssrc]
|
|
1229
|
+
|
|
1230
|
+
if expected_freq and abs(channel.frequency - expected_freq) > 1: # 1 Hz tolerance
|
|
1231
|
+
logger.warning(
|
|
1232
|
+
f"Channel {ssrc} frequency mismatch: "
|
|
1233
|
+
f"expected {expected_freq/1e6:.3f} MHz, "
|
|
1234
|
+
f"got {channel.frequency/1e6:.3f} MHz"
|
|
1235
|
+
)
|
|
1236
|
+
return False
|
|
1237
|
+
|
|
1238
|
+
logger.info(f"Channel {ssrc} verified: {channel.frequency/1e6:.3f} MHz, {channel.preset}")
|
|
1239
|
+
return True
|
|
1240
|
+
|
|
1241
|
+
def remove_channel(self, ssrc: int):
|
|
1242
|
+
"""
|
|
1243
|
+
Remove a channel from radiod
|
|
1244
|
+
|
|
1245
|
+
In radiod, setting a channel's frequency to 0 marks it for removal.
|
|
1246
|
+
Radiod periodically polls for channels with frequency=0 and removes them,
|
|
1247
|
+
so the removal is not instantaneous but happens within the next polling cycle.
|
|
1248
|
+
|
|
1249
|
+
This is the proper way to clean up unused channels and prevent radiod
|
|
1250
|
+
from accumulating orphaned channel instances.
|
|
1251
|
+
|
|
1252
|
+
Args:
|
|
1253
|
+
ssrc: SSRC of the channel to remove
|
|
1254
|
+
|
|
1255
|
+
Raises:
|
|
1256
|
+
ValidationError: If SSRC is invalid
|
|
1257
|
+
|
|
1258
|
+
Example:
|
|
1259
|
+
>>> control = RadiodControl("radiod.local")
|
|
1260
|
+
>>> control.create_channel(ssrc=14074000, frequency_hz=14.074e6)
|
|
1261
|
+
>>> # ... use channel ...
|
|
1262
|
+
>>> control.remove_channel(ssrc=14074000) # Mark for removal
|
|
1263
|
+
>>> # Channel will be removed by radiod in next polling cycle
|
|
1264
|
+
|
|
1265
|
+
Note:
|
|
1266
|
+
- Removal is NOT instantaneous - radiod polls periodically for channels to remove
|
|
1267
|
+
- Always call this when your application is done with a channel
|
|
1268
|
+
- Especially important for long-running applications that create temporary channels
|
|
1269
|
+
- The channel may still appear in discovery for a brief time after calling this
|
|
1270
|
+
"""
|
|
1271
|
+
_validate_ssrc(ssrc)
|
|
1272
|
+
|
|
1273
|
+
cmdbuffer = bytearray()
|
|
1274
|
+
cmdbuffer.append(CMD)
|
|
1275
|
+
|
|
1276
|
+
# Setting frequency to 0 removes the channel in radiod
|
|
1277
|
+
encode_double(cmdbuffer, StatusType.RADIO_FREQUENCY, 0.0)
|
|
1278
|
+
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
1279
|
+
encode_int(cmdbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
1280
|
+
encode_eol(cmdbuffer)
|
|
1281
|
+
|
|
1282
|
+
logger.info(f"Removing channel SSRC {ssrc}")
|
|
1283
|
+
self.send_command(cmdbuffer)
|
|
1284
|
+
|
|
1285
|
+
def _setup_status_listener(self):
|
|
1286
|
+
"""Set up socket to listen for status responses"""
|
|
1287
|
+
# Create a separate socket for receiving status messages
|
|
1288
|
+
status_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
1289
|
+
status_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
1290
|
+
|
|
1291
|
+
# Set SO_REUSEPORT to allow multiple processes to bind (if available)
|
|
1292
|
+
if hasattr(socket, 'SO_REUSEPORT'):
|
|
1293
|
+
try:
|
|
1294
|
+
status_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
|
|
1295
|
+
logger.debug("SO_REUSEPORT enabled")
|
|
1296
|
+
except OSError as e:
|
|
1297
|
+
logger.warning(f"Could not set SO_REUSEPORT: {e}")
|
|
1298
|
+
|
|
1299
|
+
# CRITICAL: Must bind to the multicast port (5006) to receive multicast packets
|
|
1300
|
+
# Multicast packets are addressed to specific port, not just IP
|
|
1301
|
+
# Use 0.0.0.0 and SO_REUSEADDR to allow multiple processes
|
|
1302
|
+
try:
|
|
1303
|
+
status_sock.bind(('0.0.0.0', 5006)) # Bind to radiod status port on all interfaces
|
|
1304
|
+
bound_port = status_sock.getsockname()[1]
|
|
1305
|
+
logger.debug(f"Bound to port {bound_port} for multicast reception")
|
|
1306
|
+
except OSError as e:
|
|
1307
|
+
logger.error(f"Failed to bind socket: {e}")
|
|
1308
|
+
raise
|
|
1309
|
+
|
|
1310
|
+
# Join the multicast group on specified interface
|
|
1311
|
+
# Use the status multicast address (where status packets are sent)
|
|
1312
|
+
interface_addr = self.interface if self.interface else '0.0.0.0'
|
|
1313
|
+
mreq = struct.pack('=4s4s',
|
|
1314
|
+
socket.inet_aton(self.status_mcast_addr), # status multicast group
|
|
1315
|
+
socket.inet_aton(interface_addr)) # interface to use
|
|
1316
|
+
status_sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
|
|
1317
|
+
logger.debug(f"Joined status multicast group {self.status_mcast_addr} on interface {interface_addr}")
|
|
1318
|
+
|
|
1319
|
+
# Set timeout for polling
|
|
1320
|
+
status_sock.settimeout(0.1) # 100ms timeout
|
|
1321
|
+
|
|
1322
|
+
return status_sock
|
|
1323
|
+
|
|
1324
|
+
def _get_or_create_status_listener(self):
|
|
1325
|
+
"""
|
|
1326
|
+
Get cached status listener socket or create new one if needed.
|
|
1327
|
+
|
|
1328
|
+
This method implements socket reuse to avoid creating/destroying sockets
|
|
1329
|
+
on every tune() call, which saves 20-30ms per operation and prevents
|
|
1330
|
+
socket exhaustion.
|
|
1331
|
+
|
|
1332
|
+
Returns:
|
|
1333
|
+
Cached or newly created status listener socket
|
|
1334
|
+
"""
|
|
1335
|
+
import threading
|
|
1336
|
+
|
|
1337
|
+
# Lazy initialization of lock (avoid threading overhead if never used)
|
|
1338
|
+
if self._status_sock_lock is None:
|
|
1339
|
+
self._status_sock_lock = threading.Lock()
|
|
1340
|
+
|
|
1341
|
+
with self._status_sock_lock:
|
|
1342
|
+
if self._status_sock is None:
|
|
1343
|
+
logger.debug("Creating cached status listener socket")
|
|
1344
|
+
self._status_sock = self._setup_status_listener()
|
|
1345
|
+
else:
|
|
1346
|
+
logger.debug("Reusing cached status listener socket")
|
|
1347
|
+
return self._status_sock
|
|
1348
|
+
|
|
1349
|
+
def tune(self, ssrc: int, frequency_hz: Optional[float] = None,
|
|
1350
|
+
preset: Optional[str] = None, sample_rate: Optional[int] = None,
|
|
1351
|
+
low_edge: Optional[float] = None, high_edge: Optional[float] = None,
|
|
1352
|
+
gain: Optional[float] = None, agc_enable: Optional[bool] = None,
|
|
1353
|
+
rf_gain: Optional[float] = None, rf_atten: Optional[float] = None,
|
|
1354
|
+
encoding: Optional[int] = None, destination: Optional[str] = None,
|
|
1355
|
+
timeout: float = 5.0) -> dict:
|
|
1356
|
+
"""
|
|
1357
|
+
Tune a channel and retrieve its status (like tune.c in ka9q-radio)
|
|
1358
|
+
|
|
1359
|
+
This method sends tuning commands to radiod and waits for a status response,
|
|
1360
|
+
replicating the functionality of the tune utility in ka9q-radio.
|
|
1361
|
+
|
|
1362
|
+
Args:
|
|
1363
|
+
ssrc: SSRC of the channel to tune
|
|
1364
|
+
frequency_hz: Frequency in Hz (optional)
|
|
1365
|
+
preset: Preset/mode name (optional, e.g., "iq", "usb", "lsb")
|
|
1366
|
+
sample_rate: Sample rate in Hz (optional)
|
|
1367
|
+
low_edge: Low filter edge in Hz (optional)
|
|
1368
|
+
high_edge: High filter edge in Hz (optional)
|
|
1369
|
+
gain: Manual gain in dB (optional, disables AGC)
|
|
1370
|
+
agc_enable: Enable AGC (optional)
|
|
1371
|
+
rf_gain: RF front-end gain in dB (optional)
|
|
1372
|
+
rf_atten: RF front-end attenuation in dB (optional)
|
|
1373
|
+
encoding: Output encoding type (optional, use Encoding constants)
|
|
1374
|
+
destination: Destination multicast address (optional)
|
|
1375
|
+
timeout: Maximum time to wait for response in seconds (default: 5.0)
|
|
1376
|
+
|
|
1377
|
+
Returns:
|
|
1378
|
+
Dictionary containing channel status with keys:
|
|
1379
|
+
- ssrc: Channel SSRC
|
|
1380
|
+
- frequency: Radio frequency in Hz
|
|
1381
|
+
- preset: Mode/preset name
|
|
1382
|
+
- sample_rate: Sample rate in Hz
|
|
1383
|
+
- agc_enable: AGC enabled status
|
|
1384
|
+
- gain: Current gain in dB
|
|
1385
|
+
- rf_gain: RF gain in dB
|
|
1386
|
+
- rf_atten: RF attenuation in dB
|
|
1387
|
+
- rf_agc: RF AGC status
|
|
1388
|
+
- low_edge: Low filter edge in Hz
|
|
1389
|
+
- high_edge: High filter edge in Hz
|
|
1390
|
+
- noise_density: Noise density in dB/Hz
|
|
1391
|
+
- baseband_power: Baseband power in dB
|
|
1392
|
+
- encoding: Output encoding type
|
|
1393
|
+
- destination: Destination socket address
|
|
1394
|
+
- snr: Signal-to-noise ratio in dB (calculated)
|
|
1395
|
+
|
|
1396
|
+
Raises:
|
|
1397
|
+
TimeoutError: If no matching response received within timeout
|
|
1398
|
+
"""
|
|
1399
|
+
# Validate inputs
|
|
1400
|
+
_validate_ssrc(ssrc)
|
|
1401
|
+
if frequency_hz is not None:
|
|
1402
|
+
_validate_frequency(frequency_hz)
|
|
1403
|
+
if sample_rate is not None:
|
|
1404
|
+
_validate_sample_rate(sample_rate)
|
|
1405
|
+
if gain is not None:
|
|
1406
|
+
_validate_gain(gain)
|
|
1407
|
+
_validate_timeout(timeout)
|
|
1408
|
+
|
|
1409
|
+
import time
|
|
1410
|
+
import select
|
|
1411
|
+
|
|
1412
|
+
# Build command packet with all specified parameters
|
|
1413
|
+
cmdbuffer = bytearray()
|
|
1414
|
+
cmdbuffer.append(CMD)
|
|
1415
|
+
|
|
1416
|
+
# Generate command tag for matching response
|
|
1417
|
+
command_tag = secrets.randbits(31)
|
|
1418
|
+
encode_int(cmdbuffer, StatusType.COMMAND_TAG, command_tag)
|
|
1419
|
+
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
1420
|
+
|
|
1421
|
+
if preset is not None:
|
|
1422
|
+
_validate_preset(preset)
|
|
1423
|
+
encode_string(cmdbuffer, StatusType.PRESET, preset)
|
|
1424
|
+
|
|
1425
|
+
if sample_rate is not None:
|
|
1426
|
+
encode_int(cmdbuffer, StatusType.OUTPUT_SAMPRATE, sample_rate)
|
|
1427
|
+
|
|
1428
|
+
if low_edge is not None:
|
|
1429
|
+
encode_float(cmdbuffer, StatusType.LOW_EDGE, low_edge)
|
|
1430
|
+
|
|
1431
|
+
if high_edge is not None:
|
|
1432
|
+
encode_float(cmdbuffer, StatusType.HIGH_EDGE, high_edge)
|
|
1433
|
+
|
|
1434
|
+
if frequency_hz is not None:
|
|
1435
|
+
encode_double(cmdbuffer, StatusType.RADIO_FREQUENCY, frequency_hz)
|
|
1436
|
+
|
|
1437
|
+
if gain is not None:
|
|
1438
|
+
encode_float(cmdbuffer, StatusType.GAIN, gain)
|
|
1439
|
+
encode_int(cmdbuffer, StatusType.AGC_ENABLE, 0) # Turn off AGC for manual gain
|
|
1440
|
+
elif agc_enable is not None:
|
|
1441
|
+
encode_int(cmdbuffer, StatusType.AGC_ENABLE, 1 if agc_enable else 0)
|
|
1442
|
+
|
|
1443
|
+
if encoding is not None:
|
|
1444
|
+
encode_int(cmdbuffer, StatusType.OUTPUT_ENCODING, encoding)
|
|
1445
|
+
|
|
1446
|
+
if rf_gain is not None:
|
|
1447
|
+
encode_float(cmdbuffer, StatusType.RF_GAIN, rf_gain)
|
|
1448
|
+
|
|
1449
|
+
if rf_atten is not None:
|
|
1450
|
+
encode_float(cmdbuffer, StatusType.RF_ATTEN, rf_atten)
|
|
1451
|
+
|
|
1452
|
+
if destination is not None:
|
|
1453
|
+
_validate_multicast_address(destination)
|
|
1454
|
+
# Parse destination - could be IP address or hostname
|
|
1455
|
+
# If it's a hostname, try to resolve it; if not, use it as-is (radiod will handle it)
|
|
1456
|
+
dest_addr = destination
|
|
1457
|
+
dest_port = 5004 # Default RTP port
|
|
1458
|
+
|
|
1459
|
+
# Check if port is specified in format "address:port"
|
|
1460
|
+
if ':' in destination:
|
|
1461
|
+
parts = destination.rsplit(':', 1)
|
|
1462
|
+
dest_addr = parts[0]
|
|
1463
|
+
try:
|
|
1464
|
+
dest_port = int(parts[1])
|
|
1465
|
+
except ValueError:
|
|
1466
|
+
raise ValidationError(f"Invalid port in destination '{destination}'")
|
|
1467
|
+
|
|
1468
|
+
encode_socket(cmdbuffer, StatusType.OUTPUT_DATA_DEST_SOCKET, dest_addr, dest_port)
|
|
1469
|
+
logger.info(f"Setting destination for SSRC {ssrc} to {dest_addr}:{dest_port}")
|
|
1470
|
+
|
|
1471
|
+
encode_eol(cmdbuffer)
|
|
1472
|
+
|
|
1473
|
+
# Get cached status listener (or create if first use)
|
|
1474
|
+
# Socket is reused across tune() calls to avoid creation/destruction overhead
|
|
1475
|
+
status_sock = self._get_or_create_status_listener()
|
|
1476
|
+
|
|
1477
|
+
try:
|
|
1478
|
+
start_time = time.time()
|
|
1479
|
+
last_send_time = 0
|
|
1480
|
+
retry_interval = 0.1 # Start at 100ms
|
|
1481
|
+
max_retry_interval = 1.0 # Cap at 1 second
|
|
1482
|
+
attempts = 0
|
|
1483
|
+
|
|
1484
|
+
while time.time() - start_time < timeout:
|
|
1485
|
+
# Send command with exponential backoff
|
|
1486
|
+
current_time = time.time()
|
|
1487
|
+
if current_time - last_send_time >= retry_interval:
|
|
1488
|
+
self.send_command(cmdbuffer)
|
|
1489
|
+
last_send_time = current_time
|
|
1490
|
+
attempts += 1
|
|
1491
|
+
logger.debug(f"Sent tune command with tag {command_tag} (attempt {attempts})")
|
|
1492
|
+
|
|
1493
|
+
# Exponential backoff: 100ms, 200ms, 400ms, 800ms, 1000ms (capped)
|
|
1494
|
+
# This reduces network spam and CPU usage significantly
|
|
1495
|
+
retry_interval = min(retry_interval * 2, max_retry_interval)
|
|
1496
|
+
|
|
1497
|
+
# Check for incoming status messages with adaptive timeout
|
|
1498
|
+
try:
|
|
1499
|
+
# Use remaining time or retry interval, whichever is smaller
|
|
1500
|
+
remaining = timeout - (time.time() - start_time)
|
|
1501
|
+
select_timeout = min(retry_interval, remaining, 0.5)
|
|
1502
|
+
|
|
1503
|
+
ready = select.select([status_sock], [], [], select_timeout)
|
|
1504
|
+
if not ready[0]:
|
|
1505
|
+
logger.debug(f"select() timed out after {select_timeout:.3f}s, no packets received")
|
|
1506
|
+
continue
|
|
1507
|
+
|
|
1508
|
+
response_buffer, addr = status_sock.recvfrom(8192)
|
|
1509
|
+
logger.debug(f"Received {len(response_buffer)} bytes from {addr}")
|
|
1510
|
+
logger.debug(f"First 16 bytes: {' '.join(f'{b:02x}' for b in response_buffer[:16])}")
|
|
1511
|
+
|
|
1512
|
+
# Parse response
|
|
1513
|
+
if len(response_buffer) == 0 or response_buffer[0] != 0:
|
|
1514
|
+
continue # Not a status response
|
|
1515
|
+
|
|
1516
|
+
# Decode the response
|
|
1517
|
+
status = self._decode_status_response(response_buffer)
|
|
1518
|
+
|
|
1519
|
+
# Check if this response is for our command
|
|
1520
|
+
if status.get('ssrc') == ssrc and status.get('command_tag') == command_tag:
|
|
1521
|
+
logger.info(f"Received matching status response for SSRC {ssrc}")
|
|
1522
|
+
return status
|
|
1523
|
+
else:
|
|
1524
|
+
logger.debug(f"Response not for us: ssrc={status.get('ssrc')}, tag={status.get('command_tag')}")
|
|
1525
|
+
|
|
1526
|
+
except socket.timeout:
|
|
1527
|
+
continue
|
|
1528
|
+
|
|
1529
|
+
raise TimeoutError(f"No status response received for SSRC {ssrc} within {timeout}s")
|
|
1530
|
+
|
|
1531
|
+
finally:
|
|
1532
|
+
# NOTE: Do NOT close status_sock here - it's cached for reuse
|
|
1533
|
+
# Socket will be closed in close() method
|
|
1534
|
+
pass
|
|
1535
|
+
|
|
1536
|
+
def _decode_status_response(self, buffer: bytes) -> dict:
|
|
1537
|
+
"""
|
|
1538
|
+
Decode a status response packet from radiod
|
|
1539
|
+
|
|
1540
|
+
Args:
|
|
1541
|
+
buffer: Raw response bytes
|
|
1542
|
+
|
|
1543
|
+
Returns:
|
|
1544
|
+
Dictionary containing decoded status fields
|
|
1545
|
+
"""
|
|
1546
|
+
status = {}
|
|
1547
|
+
|
|
1548
|
+
if len(buffer) == 0 or buffer[0] != 0:
|
|
1549
|
+
return status # Not a status response
|
|
1550
|
+
|
|
1551
|
+
cp = 1 # Skip packet type byte
|
|
1552
|
+
|
|
1553
|
+
while cp < len(buffer):
|
|
1554
|
+
if cp >= len(buffer):
|
|
1555
|
+
break
|
|
1556
|
+
|
|
1557
|
+
type_val = buffer[cp]
|
|
1558
|
+
cp += 1
|
|
1559
|
+
|
|
1560
|
+
if type_val == StatusType.EOL:
|
|
1561
|
+
break
|
|
1562
|
+
|
|
1563
|
+
if cp >= len(buffer):
|
|
1564
|
+
break
|
|
1565
|
+
|
|
1566
|
+
optlen = buffer[cp]
|
|
1567
|
+
cp += 1
|
|
1568
|
+
|
|
1569
|
+
# Handle extended length encoding
|
|
1570
|
+
if optlen & 0x80:
|
|
1571
|
+
length_of_length = optlen & 0x7f
|
|
1572
|
+
optlen = 0
|
|
1573
|
+
for _ in range(length_of_length):
|
|
1574
|
+
if cp >= len(buffer):
|
|
1575
|
+
break
|
|
1576
|
+
optlen = (optlen << 8) | buffer[cp]
|
|
1577
|
+
cp += 1
|
|
1578
|
+
|
|
1579
|
+
if cp + optlen > len(buffer):
|
|
1580
|
+
break
|
|
1581
|
+
|
|
1582
|
+
data = buffer[cp:cp + optlen]
|
|
1583
|
+
|
|
1584
|
+
# Decode based on type
|
|
1585
|
+
if type_val == StatusType.COMMAND_TAG:
|
|
1586
|
+
status['command_tag'] = decode_int32(data, optlen)
|
|
1587
|
+
elif type_val == StatusType.GPS_TIME:
|
|
1588
|
+
status['gps_time'] = decode_int64(data, optlen)
|
|
1589
|
+
elif type_val == StatusType.RTP_TIMESNAP:
|
|
1590
|
+
status['rtp_timesnap'] = decode_int32(data, optlen)
|
|
1591
|
+
elif type_val == StatusType.RADIO_FREQUENCY:
|
|
1592
|
+
status['frequency'] = decode_double(data, optlen)
|
|
1593
|
+
elif type_val == StatusType.OUTPUT_SSRC:
|
|
1594
|
+
status['ssrc'] = decode_int32(data, optlen)
|
|
1595
|
+
elif type_val == StatusType.AGC_ENABLE:
|
|
1596
|
+
status['agc_enable'] = decode_bool(data, optlen)
|
|
1597
|
+
elif type_val == StatusType.GAIN:
|
|
1598
|
+
status['gain'] = decode_float(data, optlen)
|
|
1599
|
+
elif type_val == StatusType.RF_GAIN:
|
|
1600
|
+
status['rf_gain'] = decode_float(data, optlen)
|
|
1601
|
+
elif type_val == StatusType.RF_ATTEN:
|
|
1602
|
+
status['rf_atten'] = decode_float(data, optlen)
|
|
1603
|
+
elif type_val == StatusType.RF_AGC:
|
|
1604
|
+
status['rf_agc'] = decode_int(data, optlen)
|
|
1605
|
+
elif type_val == StatusType.PRESET:
|
|
1606
|
+
status['preset'] = decode_string(data, optlen)
|
|
1607
|
+
elif type_val == StatusType.LOW_EDGE:
|
|
1608
|
+
status['low_edge'] = decode_float(data, optlen)
|
|
1609
|
+
elif type_val == StatusType.HIGH_EDGE:
|
|
1610
|
+
status['high_edge'] = decode_float(data, optlen)
|
|
1611
|
+
elif type_val == StatusType.NOISE_DENSITY:
|
|
1612
|
+
status['noise_density'] = decode_float(data, optlen)
|
|
1613
|
+
elif type_val == StatusType.BASEBAND_POWER:
|
|
1614
|
+
status['baseband_power'] = decode_float(data, optlen)
|
|
1615
|
+
elif type_val == StatusType.OUTPUT_SAMPRATE:
|
|
1616
|
+
status['sample_rate'] = decode_int(data, optlen)
|
|
1617
|
+
elif type_val == StatusType.OUTPUT_ENCODING:
|
|
1618
|
+
status['encoding'] = decode_int(data, optlen)
|
|
1619
|
+
elif type_val == StatusType.OUTPUT_DATA_DEST_SOCKET:
|
|
1620
|
+
status['destination'] = decode_socket(data, optlen)
|
|
1621
|
+
|
|
1622
|
+
cp += optlen
|
|
1623
|
+
|
|
1624
|
+
# Calculate SNR if we have the necessary data
|
|
1625
|
+
if all(k in status for k in ['baseband_power', 'low_edge', 'high_edge', 'noise_density']):
|
|
1626
|
+
import math
|
|
1627
|
+
bandwidth = abs(status['high_edge'] - status['low_edge'])
|
|
1628
|
+
|
|
1629
|
+
# Guard against invalid bandwidth
|
|
1630
|
+
if bandwidth > 0:
|
|
1631
|
+
try:
|
|
1632
|
+
noise_power_db = status['noise_density'] + 10 * math.log10(bandwidth)
|
|
1633
|
+
signal_plus_noise_db = status['baseband_power']
|
|
1634
|
+
# Convert to linear, calculate SNR, convert back to dB
|
|
1635
|
+
noise_power = 10 ** (noise_power_db / 10)
|
|
1636
|
+
signal_plus_noise = 10 ** (signal_plus_noise_db / 10)
|
|
1637
|
+
|
|
1638
|
+
# Guard against division by zero
|
|
1639
|
+
if noise_power > 0:
|
|
1640
|
+
snr_linear = signal_plus_noise / noise_power - 1
|
|
1641
|
+
if snr_linear > 0:
|
|
1642
|
+
status['snr'] = 10 * math.log10(snr_linear)
|
|
1643
|
+
except (ValueError, ZeroDivisionError, OverflowError):
|
|
1644
|
+
# SNR calculation failed, skip it
|
|
1645
|
+
pass
|
|
1646
|
+
|
|
1647
|
+
# Track status received
|
|
1648
|
+
self.metrics.status_received += 1
|
|
1649
|
+
return status
|
|
1650
|
+
|
|
1651
|
+
def get_metrics(self) -> dict:
|
|
1652
|
+
"""
|
|
1653
|
+
Get current metrics as a dictionary
|
|
1654
|
+
|
|
1655
|
+
Returns:
|
|
1656
|
+
Dictionary containing performance and error metrics
|
|
1657
|
+
|
|
1658
|
+
Example:
|
|
1659
|
+
>>> control = RadiodControl("radiod.local")
|
|
1660
|
+
>>> control.create_channel(ssrc=12345, frequency_hz=14.074e6)
|
|
1661
|
+
>>> metrics = control.get_metrics()
|
|
1662
|
+
>>> print(f"Success rate: {metrics['success_rate']:.1%}")
|
|
1663
|
+
>>> print(f"Commands sent: {metrics['commands_sent']}")
|
|
1664
|
+
"""
|
|
1665
|
+
return self.metrics.to_dict()
|
|
1666
|
+
|
|
1667
|
+
def reset_metrics(self):
|
|
1668
|
+
"""Reset all metrics to zero"""
|
|
1669
|
+
self.metrics = Metrics()
|
|
1670
|
+
logger.info("Metrics reset")
|
|
1671
|
+
|
|
1672
|
+
def set_doppler(self, ssrc: int, doppler_hz: float = 0.0, doppler_rate_hz_per_sec: float = 0.0):
|
|
1673
|
+
"""
|
|
1674
|
+
Set Doppler frequency shift and rate for satellite tracking
|
|
1675
|
+
|
|
1676
|
+
Args:
|
|
1677
|
+
ssrc: SSRC of the channel
|
|
1678
|
+
doppler_hz: Doppler frequency shift in Hz (default: 0.0)
|
|
1679
|
+
doppler_rate_hz_per_sec: Doppler rate in Hz/sec (default: 0.0)
|
|
1680
|
+
|
|
1681
|
+
Example:
|
|
1682
|
+
>>> # Track satellite with 5 kHz Doppler shift, changing at 100 Hz/sec
|
|
1683
|
+
>>> control.set_doppler(ssrc=12345, doppler_hz=5000, doppler_rate_hz_per_sec=100)
|
|
1684
|
+
"""
|
|
1685
|
+
_validate_ssrc(ssrc)
|
|
1686
|
+
|
|
1687
|
+
cmdbuffer = bytearray()
|
|
1688
|
+
cmdbuffer.append(CMD)
|
|
1689
|
+
|
|
1690
|
+
encode_double(cmdbuffer, StatusType.DOPPLER_FREQUENCY, doppler_hz)
|
|
1691
|
+
encode_double(cmdbuffer, StatusType.DOPPLER_FREQUENCY_RATE, doppler_rate_hz_per_sec)
|
|
1692
|
+
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
1693
|
+
encode_int(cmdbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
1694
|
+
encode_eol(cmdbuffer)
|
|
1695
|
+
|
|
1696
|
+
logger.info(f"Setting Doppler for SSRC {ssrc}: freq={doppler_hz} Hz, rate={doppler_rate_hz_per_sec} Hz/s")
|
|
1697
|
+
self.send_command(cmdbuffer)
|
|
1698
|
+
|
|
1699
|
+
def set_pll(self, ssrc: int, enable: bool, bandwidth_hz: Optional[float] = None, square: bool = False):
|
|
1700
|
+
"""
|
|
1701
|
+
Configure PLL (Phase-Locked Loop) for carrier tracking in linear modes
|
|
1702
|
+
|
|
1703
|
+
Args:
|
|
1704
|
+
ssrc: SSRC of the channel
|
|
1705
|
+
enable: Enable PLL carrier tracking
|
|
1706
|
+
bandwidth_hz: PLL loop bandwidth in Hz (optional, default depends on mode)
|
|
1707
|
+
square: Enable squaring loop for suppressed carrier reception (default: False)
|
|
1708
|
+
|
|
1709
|
+
Example:
|
|
1710
|
+
>>> # Enable PLL for coherent AM reception
|
|
1711
|
+
>>> control.set_pll(ssrc=12345, enable=True, bandwidth_hz=50)
|
|
1712
|
+
>>> # Enable squaring PLL for DSB-SC (suppressed carrier)
|
|
1713
|
+
>>> control.set_pll(ssrc=12345, enable=True, square=True, bandwidth_hz=20)
|
|
1714
|
+
"""
|
|
1715
|
+
_validate_ssrc(ssrc)
|
|
1716
|
+
|
|
1717
|
+
cmdbuffer = bytearray()
|
|
1718
|
+
cmdbuffer.append(CMD)
|
|
1719
|
+
|
|
1720
|
+
encode_int(cmdbuffer, StatusType.PLL_ENABLE, 1 if enable else 0)
|
|
1721
|
+
if bandwidth_hz is not None:
|
|
1722
|
+
_validate_positive(bandwidth_hz, "PLL bandwidth")
|
|
1723
|
+
encode_float(cmdbuffer, StatusType.PLL_BW, bandwidth_hz)
|
|
1724
|
+
if square:
|
|
1725
|
+
encode_int(cmdbuffer, StatusType.PLL_SQUARE, 1)
|
|
1726
|
+
|
|
1727
|
+
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
1728
|
+
encode_int(cmdbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
1729
|
+
encode_eol(cmdbuffer)
|
|
1730
|
+
|
|
1731
|
+
logger.info(f"Setting PLL for SSRC {ssrc}: enable={enable}, bw={bandwidth_hz} Hz, square={square}")
|
|
1732
|
+
self.send_command(cmdbuffer)
|
|
1733
|
+
|
|
1734
|
+
def set_squelch(self, ssrc: int, enable: bool = True, open_snr_db: Optional[float] = None,
|
|
1735
|
+
close_snr_db: Optional[float] = None):
|
|
1736
|
+
"""
|
|
1737
|
+
Configure SNR-based squelch
|
|
1738
|
+
|
|
1739
|
+
Args:
|
|
1740
|
+
ssrc: SSRC of the channel
|
|
1741
|
+
enable: Enable SNR squelch (default: True)
|
|
1742
|
+
open_snr_db: SNR threshold in dB to open squelch (optional)
|
|
1743
|
+
close_snr_db: SNR threshold in dB to close squelch (optional, should be < open_snr_db)
|
|
1744
|
+
|
|
1745
|
+
Example:
|
|
1746
|
+
>>> # Open squelch at 10 dB SNR, close at 8 dB
|
|
1747
|
+
>>> control.set_squelch(ssrc=12345, enable=True, open_snr_db=10, close_snr_db=8)
|
|
1748
|
+
"""
|
|
1749
|
+
_validate_ssrc(ssrc)
|
|
1750
|
+
|
|
1751
|
+
cmdbuffer = bytearray()
|
|
1752
|
+
cmdbuffer.append(CMD)
|
|
1753
|
+
|
|
1754
|
+
encode_int(cmdbuffer, StatusType.SNR_SQUELCH, 1 if enable else 0)
|
|
1755
|
+
if open_snr_db is not None:
|
|
1756
|
+
encode_float(cmdbuffer, StatusType.SQUELCH_OPEN, open_snr_db)
|
|
1757
|
+
if close_snr_db is not None:
|
|
1758
|
+
encode_float(cmdbuffer, StatusType.SQUELCH_CLOSE, close_snr_db)
|
|
1759
|
+
|
|
1760
|
+
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
1761
|
+
encode_int(cmdbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
1762
|
+
encode_eol(cmdbuffer)
|
|
1763
|
+
|
|
1764
|
+
logger.info(f"Setting squelch for SSRC {ssrc}: enable={enable}, open={open_snr_db} dB, close={close_snr_db} dB")
|
|
1765
|
+
self.send_command(cmdbuffer)
|
|
1766
|
+
|
|
1767
|
+
def set_output_channels(self, ssrc: int, channels: int):
|
|
1768
|
+
"""
|
|
1769
|
+
Set output channel count (mono/stereo)
|
|
1770
|
+
|
|
1771
|
+
Args:
|
|
1772
|
+
ssrc: SSRC of the channel
|
|
1773
|
+
channels: 1 for mono, 2 for stereo
|
|
1774
|
+
For WFM mode: 2 enables FM stereo decoding, 1 disables it
|
|
1775
|
+
|
|
1776
|
+
Raises:
|
|
1777
|
+
ValidationError: If channels is not 1 or 2
|
|
1778
|
+
|
|
1779
|
+
Example:
|
|
1780
|
+
>>> control.set_output_channels(ssrc=12345, channels=2) # Stereo
|
|
1781
|
+
"""
|
|
1782
|
+
_validate_ssrc(ssrc)
|
|
1783
|
+
if channels not in [1, 2]:
|
|
1784
|
+
raise ValidationError(f"Invalid channel count: {channels} (must be 1 or 2)")
|
|
1785
|
+
|
|
1786
|
+
cmdbuffer = bytearray()
|
|
1787
|
+
cmdbuffer.append(CMD)
|
|
1788
|
+
|
|
1789
|
+
encode_int(cmdbuffer, StatusType.OUTPUT_CHANNELS, channels)
|
|
1790
|
+
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
1791
|
+
encode_int(cmdbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
1792
|
+
encode_eol(cmdbuffer)
|
|
1793
|
+
|
|
1794
|
+
logger.info(f"Setting output channels for SSRC {ssrc} to {channels}")
|
|
1795
|
+
self.send_command(cmdbuffer)
|
|
1796
|
+
|
|
1797
|
+
def set_envelope_detection(self, ssrc: int, enable: bool):
|
|
1798
|
+
"""
|
|
1799
|
+
Enable/disable envelope detection in linear modes (for AM)
|
|
1800
|
+
|
|
1801
|
+
Args:
|
|
1802
|
+
ssrc: SSRC of the channel
|
|
1803
|
+
enable: True for envelope detection (AM), False for synchronous detection
|
|
1804
|
+
|
|
1805
|
+
Example:
|
|
1806
|
+
>>> control.set_envelope_detection(ssrc=12345, enable=True) # AM mode
|
|
1807
|
+
"""
|
|
1808
|
+
_validate_ssrc(ssrc)
|
|
1809
|
+
|
|
1810
|
+
cmdbuffer = bytearray()
|
|
1811
|
+
cmdbuffer.append(CMD)
|
|
1812
|
+
|
|
1813
|
+
encode_int(cmdbuffer, StatusType.ENVELOPE, 1 if enable else 0)
|
|
1814
|
+
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
1815
|
+
encode_int(cmdbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
1816
|
+
encode_eol(cmdbuffer)
|
|
1817
|
+
|
|
1818
|
+
logger.info(f"Setting envelope detection for SSRC {ssrc}: {enable}")
|
|
1819
|
+
self.send_command(cmdbuffer)
|
|
1820
|
+
|
|
1821
|
+
def set_independent_sideband(self, ssrc: int, enable: bool):
|
|
1822
|
+
"""
|
|
1823
|
+
Enable/disable Independent Sideband (ISB) mode
|
|
1824
|
+
|
|
1825
|
+
In ISB mode, USB and LSB are demodulated separately and output to left/right channels.
|
|
1826
|
+
|
|
1827
|
+
Args:
|
|
1828
|
+
ssrc: SSRC of the channel
|
|
1829
|
+
enable: True to enable ISB mode, False for normal operation
|
|
1830
|
+
|
|
1831
|
+
Example:
|
|
1832
|
+
>>> control.set_independent_sideband(ssrc=12345, enable=True)
|
|
1833
|
+
"""
|
|
1834
|
+
_validate_ssrc(ssrc)
|
|
1835
|
+
|
|
1836
|
+
cmdbuffer = bytearray()
|
|
1837
|
+
cmdbuffer.append(CMD)
|
|
1838
|
+
|
|
1839
|
+
encode_int(cmdbuffer, StatusType.INDEPENDENT_SIDEBAND, 1 if enable else 0)
|
|
1840
|
+
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
1841
|
+
encode_int(cmdbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
1842
|
+
encode_eol(cmdbuffer)
|
|
1843
|
+
|
|
1844
|
+
logger.info(f"Setting ISB mode for SSRC {ssrc}: {enable}")
|
|
1845
|
+
self.send_command(cmdbuffer)
|
|
1846
|
+
|
|
1847
|
+
def set_fm_threshold_extension(self, ssrc: int, enable: bool):
|
|
1848
|
+
"""
|
|
1849
|
+
Enable/disable FM threshold extension (for weak signals)
|
|
1850
|
+
|
|
1851
|
+
Args:
|
|
1852
|
+
ssrc: SSRC of the channel
|
|
1853
|
+
enable: True to enable threshold extension
|
|
1854
|
+
|
|
1855
|
+
Example:
|
|
1856
|
+
>>> control.set_fm_threshold_extension(ssrc=12345, enable=True)
|
|
1857
|
+
"""
|
|
1858
|
+
_validate_ssrc(ssrc)
|
|
1859
|
+
|
|
1860
|
+
cmdbuffer = bytearray()
|
|
1861
|
+
cmdbuffer.append(CMD)
|
|
1862
|
+
|
|
1863
|
+
encode_int(cmdbuffer, StatusType.THRESH_EXTEND, 1 if enable else 0)
|
|
1864
|
+
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
1865
|
+
encode_int(cmdbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
1866
|
+
encode_eol(cmdbuffer)
|
|
1867
|
+
|
|
1868
|
+
logger.info(f"Setting FM threshold extension for SSRC {ssrc}: {enable}")
|
|
1869
|
+
self.send_command(cmdbuffer)
|
|
1870
|
+
|
|
1871
|
+
def set_agc_threshold(self, ssrc: int, threshold_db: float):
|
|
1872
|
+
"""
|
|
1873
|
+
Set AGC threshold (level above noise floor to activate AGC)
|
|
1874
|
+
|
|
1875
|
+
Args:
|
|
1876
|
+
ssrc: SSRC of the channel
|
|
1877
|
+
threshold_db: Threshold in dB relative to noise floor
|
|
1878
|
+
|
|
1879
|
+
Example:
|
|
1880
|
+
>>> control.set_agc_threshold(ssrc=12345, threshold_db=10)
|
|
1881
|
+
"""
|
|
1882
|
+
_validate_ssrc(ssrc)
|
|
1883
|
+
|
|
1884
|
+
cmdbuffer = bytearray()
|
|
1885
|
+
cmdbuffer.append(CMD)
|
|
1886
|
+
|
|
1887
|
+
encode_float(cmdbuffer, StatusType.AGC_THRESHOLD, threshold_db)
|
|
1888
|
+
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
1889
|
+
encode_int(cmdbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
1890
|
+
encode_eol(cmdbuffer)
|
|
1891
|
+
|
|
1892
|
+
logger.info(f"Setting AGC threshold for SSRC {ssrc}: {threshold_db} dB")
|
|
1893
|
+
self.send_command(cmdbuffer)
|
|
1894
|
+
|
|
1895
|
+
def set_opus_bitrate(self, ssrc: int, bitrate: int):
|
|
1896
|
+
"""
|
|
1897
|
+
Set Opus encoder bitrate
|
|
1898
|
+
|
|
1899
|
+
Args:
|
|
1900
|
+
ssrc: SSRC of the channel
|
|
1901
|
+
bitrate: Bitrate in bits/sec (0 for auto, typical: 32000-128000)
|
|
1902
|
+
|
|
1903
|
+
Example:
|
|
1904
|
+
>>> control.set_opus_bitrate(ssrc=12345, bitrate=64000) # 64 kbps
|
|
1905
|
+
>>> control.set_opus_bitrate(ssrc=12345, bitrate=0) # Auto
|
|
1906
|
+
"""
|
|
1907
|
+
_validate_ssrc(ssrc)
|
|
1908
|
+
if bitrate < 0:
|
|
1909
|
+
raise ValidationError(f"Bitrate must be non-negative, got {bitrate}")
|
|
1910
|
+
|
|
1911
|
+
cmdbuffer = bytearray()
|
|
1912
|
+
cmdbuffer.append(CMD)
|
|
1913
|
+
|
|
1914
|
+
encode_int(cmdbuffer, StatusType.OPUS_BIT_RATE, bitrate)
|
|
1915
|
+
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
1916
|
+
encode_int(cmdbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
1917
|
+
encode_eol(cmdbuffer)
|
|
1918
|
+
|
|
1919
|
+
logger.info(f"Setting Opus bitrate for SSRC {ssrc}: {bitrate} bps")
|
|
1920
|
+
self.send_command(cmdbuffer)
|
|
1921
|
+
|
|
1922
|
+
def set_packet_buffering(self, ssrc: int, min_blocks: int):
|
|
1923
|
+
"""
|
|
1924
|
+
Set minimum packet buffering (0-4 blocks)
|
|
1925
|
+
|
|
1926
|
+
Controls how many blocks to buffer before sending a packet.
|
|
1927
|
+
Higher values reduce packet rate but increase latency.
|
|
1928
|
+
|
|
1929
|
+
Args:
|
|
1930
|
+
ssrc: SSRC of the channel
|
|
1931
|
+
min_blocks: Minimum blocks (0-4). At 20ms/block: 0=no minimum, 4=80ms minimum
|
|
1932
|
+
|
|
1933
|
+
Raises:
|
|
1934
|
+
ValidationError: If min_blocks is not 0-4
|
|
1935
|
+
|
|
1936
|
+
Example:
|
|
1937
|
+
>>> control.set_packet_buffering(ssrc=12345, min_blocks=2) # 40ms minimum
|
|
1938
|
+
"""
|
|
1939
|
+
_validate_ssrc(ssrc)
|
|
1940
|
+
if not (0 <= min_blocks <= 4):
|
|
1941
|
+
raise ValidationError(f"min_blocks must be 0-4, got {min_blocks}")
|
|
1942
|
+
|
|
1943
|
+
cmdbuffer = bytearray()
|
|
1944
|
+
cmdbuffer.append(CMD)
|
|
1945
|
+
|
|
1946
|
+
encode_int(cmdbuffer, StatusType.MINPACKET, min_blocks)
|
|
1947
|
+
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
1948
|
+
encode_int(cmdbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
1949
|
+
encode_eol(cmdbuffer)
|
|
1950
|
+
|
|
1951
|
+
logger.info(f"Setting packet buffering for SSRC {ssrc}: {min_blocks} blocks")
|
|
1952
|
+
self.send_command(cmdbuffer)
|
|
1953
|
+
|
|
1954
|
+
def set_filter2(self, ssrc: int, blocksize: int, kaiser_beta: Optional[float] = None):
|
|
1955
|
+
"""
|
|
1956
|
+
Configure secondary filter (linear modes only)
|
|
1957
|
+
|
|
1958
|
+
The secondary filter provides additional selectivity after the main filter.
|
|
1959
|
+
|
|
1960
|
+
Args:
|
|
1961
|
+
ssrc: SSRC of the channel
|
|
1962
|
+
blocksize: Filter blocksize (0 to disable, 1-10 to enable)
|
|
1963
|
+
kaiser_beta: Kaiser window beta for filter2 (optional)
|
|
1964
|
+
|
|
1965
|
+
Example:
|
|
1966
|
+
>>> control.set_filter2(ssrc=12345, blocksize=5, kaiser_beta=3.0)
|
|
1967
|
+
>>> control.set_filter2(ssrc=12345, blocksize=0) # Disable
|
|
1968
|
+
"""
|
|
1969
|
+
_validate_ssrc(ssrc)
|
|
1970
|
+
if not (0 <= blocksize <= 10):
|
|
1971
|
+
raise ValidationError(f"blocksize must be 0-10, got {blocksize}")
|
|
1972
|
+
|
|
1973
|
+
cmdbuffer = bytearray()
|
|
1974
|
+
cmdbuffer.append(CMD)
|
|
1975
|
+
|
|
1976
|
+
encode_int(cmdbuffer, StatusType.FILTER2, blocksize)
|
|
1977
|
+
if kaiser_beta is not None:
|
|
1978
|
+
encode_float(cmdbuffer, StatusType.FILTER2_KAISER_BETA, kaiser_beta)
|
|
1979
|
+
|
|
1980
|
+
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
1981
|
+
encode_int(cmdbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
1982
|
+
encode_eol(cmdbuffer)
|
|
1983
|
+
|
|
1984
|
+
logger.info(f"Setting filter2 for SSRC {ssrc}: blocksize={blocksize}, beta={kaiser_beta}")
|
|
1985
|
+
self.send_command(cmdbuffer)
|
|
1986
|
+
|
|
1987
|
+
def set_spectrum(self, ssrc: int, bin_bw_hz: Optional[float] = None, bin_count: Optional[int] = None,
|
|
1988
|
+
crossover_hz: Optional[float] = None, kaiser_beta: Optional[float] = None):
|
|
1989
|
+
"""
|
|
1990
|
+
Configure spectrum analyzer mode parameters
|
|
1991
|
+
|
|
1992
|
+
Args:
|
|
1993
|
+
ssrc: SSRC of the channel
|
|
1994
|
+
bin_bw_hz: Bin bandwidth in Hz (optional)
|
|
1995
|
+
bin_count: Number of frequency bins (optional)
|
|
1996
|
+
crossover_hz: Crossover frequency between algorithms in Hz (optional)
|
|
1997
|
+
kaiser_beta: Kaiser window beta for spectrum analysis (optional)
|
|
1998
|
+
|
|
1999
|
+
Example:
|
|
2000
|
+
>>> control.set_spectrum(ssrc=12345, bin_bw_hz=100, bin_count=512)
|
|
2001
|
+
"""
|
|
2002
|
+
_validate_ssrc(ssrc)
|
|
2003
|
+
|
|
2004
|
+
cmdbuffer = bytearray()
|
|
2005
|
+
cmdbuffer.append(CMD)
|
|
2006
|
+
|
|
2007
|
+
if bin_bw_hz is not None:
|
|
2008
|
+
_validate_positive(bin_bw_hz, "Bin bandwidth")
|
|
2009
|
+
encode_float(cmdbuffer, StatusType.NONCOHERENT_BIN_BW, bin_bw_hz)
|
|
2010
|
+
if bin_count is not None:
|
|
2011
|
+
if bin_count <= 0:
|
|
2012
|
+
raise ValidationError(f"bin_count must be positive, got {bin_count}")
|
|
2013
|
+
encode_int(cmdbuffer, StatusType.BIN_COUNT, bin_count)
|
|
2014
|
+
if crossover_hz is not None:
|
|
2015
|
+
_validate_positive(crossover_hz, "Crossover frequency")
|
|
2016
|
+
encode_float(cmdbuffer, StatusType.CROSSOVER, crossover_hz)
|
|
2017
|
+
if kaiser_beta is not None:
|
|
2018
|
+
encode_float(cmdbuffer, StatusType.SPECTRUM_KAISER_BETA, kaiser_beta)
|
|
2019
|
+
|
|
2020
|
+
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
2021
|
+
encode_int(cmdbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
2022
|
+
encode_eol(cmdbuffer)
|
|
2023
|
+
|
|
2024
|
+
logger.info(f"Setting spectrum for SSRC {ssrc}: bw={bin_bw_hz} Hz, bins={bin_count}, crossover={crossover_hz} Hz")
|
|
2025
|
+
self.send_command(cmdbuffer)
|
|
2026
|
+
|
|
2027
|
+
def set_status_interval(self, ssrc: int, interval: int):
|
|
2028
|
+
"""
|
|
2029
|
+
Set automatic status reporting interval on data channel
|
|
2030
|
+
|
|
2031
|
+
Args:
|
|
2032
|
+
ssrc: SSRC of the channel
|
|
2033
|
+
interval: Status interval in frames (0 to disable automatic status)
|
|
2034
|
+
|
|
2035
|
+
Example:
|
|
2036
|
+
>>> control.set_status_interval(ssrc=12345, interval=50) # Every 50 frames
|
|
2037
|
+
>>> control.set_status_interval(ssrc=12345, interval=0) # Disable
|
|
2038
|
+
"""
|
|
2039
|
+
_validate_ssrc(ssrc)
|
|
2040
|
+
if interval < 0:
|
|
2041
|
+
raise ValidationError(f"interval must be non-negative, got {interval}")
|
|
2042
|
+
|
|
2043
|
+
cmdbuffer = bytearray()
|
|
2044
|
+
cmdbuffer.append(CMD)
|
|
2045
|
+
|
|
2046
|
+
encode_int(cmdbuffer, StatusType.STATUS_INTERVAL, interval)
|
|
2047
|
+
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
2048
|
+
encode_int(cmdbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
2049
|
+
encode_eol(cmdbuffer)
|
|
2050
|
+
|
|
2051
|
+
logger.info(f"Setting status interval for SSRC {ssrc}: {interval} frames")
|
|
2052
|
+
self.send_command(cmdbuffer)
|
|
2053
|
+
|
|
2054
|
+
def set_demod_type(self, ssrc: int, demod_type: int):
|
|
2055
|
+
"""
|
|
2056
|
+
Set demodulator type
|
|
2057
|
+
|
|
2058
|
+
Args:
|
|
2059
|
+
ssrc: SSRC of the channel
|
|
2060
|
+
demod_type: Demodulator type (0=LINEAR, 1=FM, 2=WFM, 3=SPECTRUM)
|
|
2061
|
+
|
|
2062
|
+
Raises:
|
|
2063
|
+
ValidationError: If demod_type is invalid
|
|
2064
|
+
|
|
2065
|
+
Example:
|
|
2066
|
+
>>> control.set_demod_type(ssrc=12345, demod_type=1) # FM
|
|
2067
|
+
"""
|
|
2068
|
+
_validate_ssrc(ssrc)
|
|
2069
|
+
if not (0 <= demod_type <= 3):
|
|
2070
|
+
raise ValidationError(f"Invalid demod_type: {demod_type} (must be 0-3)")
|
|
2071
|
+
|
|
2072
|
+
cmdbuffer = bytearray()
|
|
2073
|
+
cmdbuffer.append(CMD)
|
|
2074
|
+
|
|
2075
|
+
encode_int(cmdbuffer, StatusType.DEMOD_TYPE, demod_type)
|
|
2076
|
+
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
2077
|
+
encode_int(cmdbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
2078
|
+
encode_eol(cmdbuffer)
|
|
2079
|
+
|
|
2080
|
+
logger.info(f"Setting demod type for SSRC {ssrc}: {demod_type}")
|
|
2081
|
+
self.send_command(cmdbuffer)
|
|
2082
|
+
|
|
2083
|
+
def set_output_encoding(self, ssrc: int, encoding: int):
|
|
2084
|
+
"""
|
|
2085
|
+
Set output data encoding
|
|
2086
|
+
|
|
2087
|
+
Args:
|
|
2088
|
+
ssrc: SSRC of the channel
|
|
2089
|
+
encoding: Encoding type (use Encoding constants from types.py)
|
|
2090
|
+
0=NO_ENCODING, 1=S16BE, 2=S16LE, 3=F32, 4=F16, 5=OPUS
|
|
2091
|
+
|
|
2092
|
+
Example:
|
|
2093
|
+
>>> from ka9q.types import Encoding
|
|
2094
|
+
>>> control.set_output_encoding(ssrc=12345, encoding=Encoding.S16LE)
|
|
2095
|
+
>>> control.set_output_encoding(ssrc=12345, encoding=Encoding.OPUS)
|
|
2096
|
+
"""
|
|
2097
|
+
_validate_ssrc(ssrc)
|
|
2098
|
+
|
|
2099
|
+
cmdbuffer = bytearray()
|
|
2100
|
+
cmdbuffer.append(CMD)
|
|
2101
|
+
|
|
2102
|
+
encode_int(cmdbuffer, StatusType.OUTPUT_ENCODING, encoding)
|
|
2103
|
+
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
2104
|
+
encode_int(cmdbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
2105
|
+
encode_eol(cmdbuffer)
|
|
2106
|
+
|
|
2107
|
+
logger.info(f"Setting output encoding for SSRC {ssrc}: {encoding}")
|
|
2108
|
+
self.send_command(cmdbuffer)
|
|
2109
|
+
|
|
2110
|
+
def set_rf_gain(self, ssrc: int, gain_db: float):
|
|
2111
|
+
"""
|
|
2112
|
+
Set RF front-end gain
|
|
2113
|
+
|
|
2114
|
+
Args:
|
|
2115
|
+
ssrc: SSRC of the channel
|
|
2116
|
+
gain_db: RF gain in dB (hardware-dependent range)
|
|
2117
|
+
|
|
2118
|
+
Note:
|
|
2119
|
+
Only works with hardware that supports variable RF gain (e.g., RX888)
|
|
2120
|
+
|
|
2121
|
+
Example:
|
|
2122
|
+
>>> control.set_rf_gain(ssrc=12345, gain_db=20)
|
|
2123
|
+
"""
|
|
2124
|
+
_validate_ssrc(ssrc)
|
|
2125
|
+
|
|
2126
|
+
cmdbuffer = bytearray()
|
|
2127
|
+
cmdbuffer.append(CMD)
|
|
2128
|
+
|
|
2129
|
+
encode_float(cmdbuffer, StatusType.RF_GAIN, gain_db)
|
|
2130
|
+
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
2131
|
+
encode_int(cmdbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
2132
|
+
encode_eol(cmdbuffer)
|
|
2133
|
+
|
|
2134
|
+
logger.info(f"Setting RF gain for SSRC {ssrc}: {gain_db} dB")
|
|
2135
|
+
self.send_command(cmdbuffer)
|
|
2136
|
+
|
|
2137
|
+
def set_rf_attenuation(self, ssrc: int, atten_db: float):
|
|
2138
|
+
"""
|
|
2139
|
+
Set RF front-end attenuation
|
|
2140
|
+
|
|
2141
|
+
Args:
|
|
2142
|
+
ssrc: SSRC of the channel
|
|
2143
|
+
atten_db: RF attenuation in dB (hardware-dependent range)
|
|
2144
|
+
|
|
2145
|
+
Note:
|
|
2146
|
+
Only works with hardware that supports variable RF attenuation (e.g., RX888)
|
|
2147
|
+
|
|
2148
|
+
Example:
|
|
2149
|
+
>>> control.set_rf_attenuation(ssrc=12345, atten_db=10)
|
|
2150
|
+
"""
|
|
2151
|
+
_validate_ssrc(ssrc)
|
|
2152
|
+
|
|
2153
|
+
cmdbuffer = bytearray()
|
|
2154
|
+
cmdbuffer.append(CMD)
|
|
2155
|
+
|
|
2156
|
+
encode_float(cmdbuffer, StatusType.RF_ATTEN, atten_db)
|
|
2157
|
+
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
2158
|
+
encode_int(cmdbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
2159
|
+
encode_eol(cmdbuffer)
|
|
2160
|
+
|
|
2161
|
+
logger.info(f"Setting RF attenuation for SSRC {ssrc}: {atten_db} dB")
|
|
2162
|
+
self.send_command(cmdbuffer)
|
|
2163
|
+
|
|
2164
|
+
def set_destination(self, ssrc: int, address: str, port: int = 5004):
|
|
2165
|
+
"""
|
|
2166
|
+
Set RTP output destination (multicast address)
|
|
2167
|
+
|
|
2168
|
+
This sets both the data and status destination addresses.
|
|
2169
|
+
|
|
2170
|
+
Args:
|
|
2171
|
+
ssrc: SSRC of the channel
|
|
2172
|
+
address: Multicast IP address or mDNS name
|
|
2173
|
+
port: RTP port number (default: 5004)
|
|
2174
|
+
|
|
2175
|
+
Example:
|
|
2176
|
+
>>> control.set_destination(ssrc=12345, address="239.1.2.3", port=5004)
|
|
2177
|
+
>>> control.set_destination(ssrc=12345, address="wspr.local")
|
|
2178
|
+
"""
|
|
2179
|
+
_validate_ssrc(ssrc)
|
|
2180
|
+
_validate_multicast_address(address)
|
|
2181
|
+
|
|
2182
|
+
cmdbuffer = bytearray()
|
|
2183
|
+
cmdbuffer.append(CMD)
|
|
2184
|
+
|
|
2185
|
+
encode_socket(cmdbuffer, StatusType.OUTPUT_DATA_DEST_SOCKET, address, port)
|
|
2186
|
+
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
2187
|
+
encode_int(cmdbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
2188
|
+
encode_eol(cmdbuffer)
|
|
2189
|
+
|
|
2190
|
+
logger.info(f"Setting destination for SSRC {ssrc}: {address}:{port}")
|
|
2191
|
+
self.send_command(cmdbuffer)
|
|
2192
|
+
|
|
2193
|
+
def set_first_lo(self, ssrc: int, frequency_hz: float):
|
|
2194
|
+
"""
|
|
2195
|
+
Set first LO (front-end tuner) frequency
|
|
2196
|
+
|
|
2197
|
+
This tunes the SDR hardware itself. Use with caution as it affects all channels.
|
|
2198
|
+
|
|
2199
|
+
Args:
|
|
2200
|
+
ssrc: SSRC of the channel (used for command routing)
|
|
2201
|
+
frequency_hz: First LO frequency in Hz
|
|
2202
|
+
|
|
2203
|
+
Example:
|
|
2204
|
+
>>> control.set_first_lo(ssrc=12345, frequency_hz=14.1e6)
|
|
2205
|
+
"""
|
|
2206
|
+
_validate_ssrc(ssrc)
|
|
2207
|
+
_validate_frequency(frequency_hz)
|
|
2208
|
+
|
|
2209
|
+
cmdbuffer = bytearray()
|
|
2210
|
+
cmdbuffer.append(CMD)
|
|
2211
|
+
|
|
2212
|
+
encode_double(cmdbuffer, StatusType.FIRST_LO_FREQUENCY, frequency_hz)
|
|
2213
|
+
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
2214
|
+
encode_int(cmdbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
2215
|
+
encode_eol(cmdbuffer)
|
|
2216
|
+
|
|
2217
|
+
logger.info(f"Setting first LO frequency for SSRC {ssrc}: {frequency_hz/1e6:.3f} MHz")
|
|
2218
|
+
self.send_command(cmdbuffer)
|
|
2219
|
+
|
|
2220
|
+
def set_options(self, ssrc: int, set_bits: int = 0, clear_bits: int = 0):
|
|
2221
|
+
"""
|
|
2222
|
+
Set or clear option bits
|
|
2223
|
+
|
|
2224
|
+
Option bits are used for experimental features and debugging.
|
|
2225
|
+
|
|
2226
|
+
Args:
|
|
2227
|
+
ssrc: SSRC of the channel
|
|
2228
|
+
set_bits: Bit mask of options to set (OR operation)
|
|
2229
|
+
clear_bits: Bit mask of options to clear (AND NOT operation)
|
|
2230
|
+
|
|
2231
|
+
Example:
|
|
2232
|
+
>>> control.set_options(ssrc=12345, set_bits=0x01) # Set bit 0
|
|
2233
|
+
>>> control.set_options(ssrc=12345, clear_bits=0x02) # Clear bit 1
|
|
2234
|
+
"""
|
|
2235
|
+
_validate_ssrc(ssrc)
|
|
2236
|
+
|
|
2237
|
+
cmdbuffer = bytearray()
|
|
2238
|
+
cmdbuffer.append(CMD)
|
|
2239
|
+
|
|
2240
|
+
if set_bits:
|
|
2241
|
+
encode_int64(cmdbuffer, StatusType.SETOPTS, set_bits)
|
|
2242
|
+
if clear_bits:
|
|
2243
|
+
encode_int64(cmdbuffer, StatusType.CLEAROPTS, clear_bits)
|
|
2244
|
+
|
|
2245
|
+
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
2246
|
+
encode_int(cmdbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
2247
|
+
encode_eol(cmdbuffer)
|
|
2248
|
+
|
|
2249
|
+
logger.info(f"Setting options for SSRC {ssrc}: set=0x{set_bits:x}, clear=0x{clear_bits:x}")
|
|
2250
|
+
self.send_command(cmdbuffer)
|
|
2251
|
+
|
|
2252
|
+
def close(self):
|
|
2253
|
+
"""
|
|
2254
|
+
Close all sockets with proper error handling
|
|
2255
|
+
|
|
2256
|
+
This method is safe to call multiple times and handles errors gracefully.
|
|
2257
|
+
"""
|
|
2258
|
+
errors = []
|
|
2259
|
+
|
|
2260
|
+
# Close control socket
|
|
2261
|
+
if self.socket:
|
|
2262
|
+
try:
|
|
2263
|
+
self.socket.close()
|
|
2264
|
+
logger.debug("Control socket closed")
|
|
2265
|
+
except Exception as e:
|
|
2266
|
+
errors.append(f"control socket: {e}")
|
|
2267
|
+
finally:
|
|
2268
|
+
self.socket = None
|
|
2269
|
+
|
|
2270
|
+
# Close cached status listener socket
|
|
2271
|
+
if self._status_sock:
|
|
2272
|
+
try:
|
|
2273
|
+
logger.debug("Closing cached status listener socket")
|
|
2274
|
+
self._status_sock.close()
|
|
2275
|
+
except Exception as e:
|
|
2276
|
+
errors.append(f"status socket: {e}")
|
|
2277
|
+
finally:
|
|
2278
|
+
self._status_sock = None
|
|
2279
|
+
|
|
2280
|
+
if errors:
|
|
2281
|
+
error_msg = "; ".join(errors)
|
|
2282
|
+
logger.warning(f"Errors during socket cleanup: {error_msg}")
|
|
2283
|
+
|
|
2284
|
+
def __del__(self):
|
|
2285
|
+
"""
|
|
2286
|
+
Ensure resources are cleaned up on garbage collection
|
|
2287
|
+
|
|
2288
|
+
This provides a safety net for unclosed connections and helps
|
|
2289
|
+
detect resource leaks during development.
|
|
2290
|
+
"""
|
|
2291
|
+
try:
|
|
2292
|
+
self.close()
|
|
2293
|
+
except Exception:
|
|
2294
|
+
pass # Can't raise exceptions in __del__
|
|
2295
|
+
|