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/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
+