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/discovery.py ADDED
@@ -0,0 +1,485 @@
1
+ """
2
+ Stream discovery for ka9q-radio channels
3
+
4
+ This module provides functions to discover active channels by listening
5
+ to radiod's status multicast stream (native Python) or optionally using
6
+ the 'control' utility from ka9q-radio as a fallback.
7
+ """
8
+
9
+ import subprocess
10
+ import re
11
+ import logging
12
+ import time
13
+ import select
14
+ import socket
15
+ import struct
16
+ from typing import Dict, List, Tuple, Optional
17
+ from dataclasses import dataclass
18
+ from .utils import resolve_multicast_address
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ @dataclass
24
+ class ChannelInfo:
25
+ """Information about a ka9q-radio channel"""
26
+ ssrc: int
27
+ preset: str
28
+ sample_rate: int
29
+ frequency: float
30
+ snr: float
31
+ multicast_address: str
32
+ port: int
33
+ gps_time: Optional[int] = None # GPS nanoseconds when RTP_TIMESNAP was captured
34
+ rtp_timesnap: Optional[int] = None # RTP timestamp at GPS_TIME
35
+
36
+
37
+ def _create_status_listener_socket(multicast_addr: str, interface: Optional[str] = None) -> socket.socket:
38
+ """
39
+ Create a UDP socket configured to listen for radiod status multicast.
40
+
41
+ This is a standalone function that doesn't require RadiodControl,
42
+ making it lightweight for discovery operations.
43
+
44
+ Args:
45
+ multicast_addr: IP address of the multicast group
46
+ interface: IP address of the network interface to use (e.g., '192.168.1.100')
47
+ If None, uses INADDR_ANY (0.0.0.0) which works on single-homed systems
48
+
49
+ Returns:
50
+ Configured socket ready to receive status packets
51
+ """
52
+ # Create UDP socket
53
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
54
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
55
+
56
+ # Set SO_REUSEPORT if available (allows multiple processes)
57
+ if hasattr(socket, 'SO_REUSEPORT'):
58
+ try:
59
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
60
+ logger.debug("SO_REUSEPORT enabled")
61
+ except OSError as e:
62
+ logger.debug(f"Could not set SO_REUSEPORT: {e}")
63
+
64
+ # Bind to multicast port on all interfaces
65
+ try:
66
+ sock.bind(('0.0.0.0', 5006)) # radiod status port
67
+ logger.debug(f"Bound to port 5006 for multicast reception")
68
+ except OSError as e:
69
+ logger.error(f"Failed to bind socket to port 5006: {e}")
70
+ raise
71
+
72
+ # Join multicast group on specified interface (or any interface if not specified)
73
+ interface_addr = interface if interface else '0.0.0.0'
74
+ mreq = struct.pack('=4s4s',
75
+ socket.inet_aton(multicast_addr), # multicast group
76
+ socket.inet_aton(interface_addr)) # interface to use
77
+ sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
78
+ logger.debug(f"Joined multicast group {multicast_addr} on interface {interface_addr}")
79
+
80
+ # Set timeout for non-blocking reception
81
+ sock.settimeout(0.1)
82
+
83
+ return sock
84
+
85
+
86
+ def discover_channels_native(status_address: str, listen_duration: float = 2.0,
87
+ interface: Optional[str] = None) -> Dict[int, ChannelInfo]:
88
+ """
89
+ Discover channels by listening to radiod status multicast (pure Python)
90
+
91
+ This implementation listens directly to radiod's status multicast stream
92
+ and decodes the status packets without requiring external executables.
93
+
94
+ Args:
95
+ status_address: Status multicast address (e.g., "radiod.local" or IP)
96
+ listen_duration: How long to listen for status packets in seconds (default: 2.0)
97
+ interface: IP address of the network interface to use for multicast reception
98
+ (e.g., '192.168.1.100'). Required on multi-homed systems.
99
+ If None, uses INADDR_ANY which works on single-homed systems.
100
+
101
+ Returns:
102
+ Dictionary mapping SSRC to ChannelInfo
103
+ """
104
+ # Import decoder function without creating full RadiodControl instance
105
+ from .control import RadiodControl
106
+
107
+ logger.info(f"Discovering channels via native Python listener from {status_address}")
108
+ logger.info(f"Listening for {listen_duration} seconds...")
109
+
110
+ channels = {}
111
+ status_sock = None # Initialize outside try block
112
+ temp_control = None # Initialize outside try block
113
+
114
+ try:
115
+ # Resolve address and create lightweight socket (no RadiodControl overhead)
116
+ multicast_addr = resolve_multicast_address(status_address, timeout=2.0)
117
+ status_sock = _create_status_listener_socket(multicast_addr, interface)
118
+
119
+ # CRITICAL: Send a poll to radiod to trigger STATUS packet broadcasts
120
+ # We need to send this BEFORE creating RadiodControl to avoid socket conflicts
121
+ logger.debug("Sending poll to radiod to trigger STATUS broadcasts")
122
+ try:
123
+ import random
124
+ # Build poll command as control.c does:
125
+ # Type (1) + COMMAND_TAG (1, 4 bytes) + OUTPUT_SSRC (18, 4 bytes) + EOL (0, 0 bytes)
126
+ poll_cmd = bytearray([1]) # CMD packet type (STATUS=0, CMD=1)
127
+ # COMMAND_TAG (tag=1) with random value for tracking
128
+ poll_cmd.extend([1, 4]) # tag=1 (COMMAND_TAG), length=4
129
+ tag = random.randint(0, 0xffffffff)
130
+ poll_cmd.extend(tag.to_bytes(4, 'big'))
131
+ # OUTPUT_SSRC (tag=18) with value 0xffffffff (poll all channels)
132
+ poll_cmd.extend([18, 4]) # tag=18 (OUTPUT_SSRC), length=4
133
+ poll_cmd.extend([0xff, 0xff, 0xff, 0xff]) # SSRC=0xffffffff
134
+ # EOL marker
135
+ poll_cmd.extend([0, 0]) # tag=0 (EOL), length=0
136
+
137
+ # Send poll using status_sock directly
138
+ dest = (multicast_addr, 5006)
139
+ status_sock.sendto(poll_cmd, dest)
140
+ logger.debug(f"Poll sent successfully to {dest} (tag={tag})")
141
+ # Give radiod a moment to respond
142
+ time.sleep(0.1)
143
+ except Exception as e:
144
+ logger.warning(f"Could not send poll (continuing anyway): {e}")
145
+
146
+ start_time = time.time()
147
+ packet_count = 0
148
+
149
+ # We'll create temp_control lazily only when we need to decode a packet
150
+ # This avoids opening an extra socket that would compete for multicast packets
151
+ temp_control = None
152
+
153
+ while time.time() - start_time < listen_duration:
154
+ # Use remaining time or 0.5s, whichever is smaller (adaptive timeout)
155
+ remaining = listen_duration - (time.time() - start_time)
156
+ select_timeout = min(remaining, 0.5)
157
+
158
+ ready = select.select([status_sock], [], [], select_timeout)
159
+ if not ready[0]:
160
+ continue
161
+
162
+ # Receive status packet
163
+ try:
164
+ buffer, addr = status_sock.recvfrom(8192)
165
+ packet_count += 1
166
+ logger.debug(f"Received {len(buffer)} bytes from {addr}")
167
+ except Exception as e:
168
+ logger.debug(f"Error receiving packet: {e}")
169
+ continue
170
+
171
+ # Skip non-status packets (STATUS = 0, COMMAND = 1)
172
+ if len(buffer) == 0 or buffer[0] != 0: # STATUS packets have type byte == 0
173
+ logger.debug(f"Skipping non-STATUS packet (type={buffer[0] if buffer else 'empty'})")
174
+ continue
175
+
176
+ # Decode status packet using temporary control instance
177
+ # Create it lazily on first STATUS packet
178
+ if temp_control is None:
179
+ logger.debug("Creating RadiodControl for decoding")
180
+ temp_control = RadiodControl(status_address)
181
+
182
+ try:
183
+ status = temp_control._decode_status_response(buffer)
184
+ except Exception as e:
185
+ logger.debug(f"Error decoding status packet: {e}")
186
+ continue
187
+
188
+ # Extract SSRC - required field
189
+ ssrc = status.get('ssrc')
190
+ if not ssrc:
191
+ logger.debug("Status packet missing SSRC, skipping")
192
+ continue
193
+
194
+ # Build ChannelInfo from status
195
+ # Extract destination socket info
196
+ dest = status.get('destination', {})
197
+ mcast_addr = dest.get('address', '') if isinstance(dest, dict) else ''
198
+ port = dest.get('port', 0) if isinstance(dest, dict) else 0
199
+
200
+ channel = ChannelInfo(
201
+ ssrc=ssrc,
202
+ preset=status.get('preset', 'unknown'),
203
+ sample_rate=status.get('sample_rate', 0),
204
+ frequency=status.get('frequency', 0.0),
205
+ snr=status.get('snr', float('-inf')),
206
+ multicast_address=mcast_addr,
207
+ port=port,
208
+ gps_time=status.get('gps_time'),
209
+ rtp_timesnap=status.get('rtp_timesnap')
210
+ )
211
+
212
+ # Store or update channel info
213
+ if ssrc not in channels:
214
+ channels[ssrc] = channel
215
+ logger.debug(
216
+ f"Discovered channel: SSRC={ssrc}, freq={channel.frequency/1e6:.3f} MHz, "
217
+ f"rate={channel.sample_rate} Hz, preset={channel.preset}"
218
+ )
219
+ else:
220
+ # Update with latest info
221
+ channels[ssrc] = channel
222
+
223
+ logger.info(f"Discovered {len(channels)} channels from {packet_count} packets")
224
+
225
+ except Exception as e:
226
+ logger.error(f"Error during native channel discovery: {e}")
227
+ logger.debug(f"Exception details:", exc_info=True)
228
+
229
+ finally:
230
+ # Clean up socket with error handling
231
+ if status_sock:
232
+ try:
233
+ status_sock.close()
234
+ logger.debug("Discovery socket closed successfully")
235
+ except Exception as e:
236
+ logger.warning(f"Error closing discovery socket: {e}")
237
+
238
+ # Clean up temporary RadiodControl instance
239
+ if temp_control:
240
+ try:
241
+ temp_control.close()
242
+ logger.debug("Temporary control instance closed")
243
+ except Exception as e:
244
+ logger.debug(f"Error closing temporary control: {e}")
245
+
246
+ return channels
247
+
248
+
249
+ def discover_channels_via_control(status_address: str, timeout: float = 30.0) -> Dict[int, ChannelInfo]:
250
+ """
251
+ Discover channels using the 'control' utility from ka9q-radio
252
+
253
+ This is a fallback method that requires the 'control' executable
254
+ from ka9q-radio to be installed on the system.
255
+
256
+ Args:
257
+ status_address: Status multicast address (e.g., "radiod.local")
258
+ timeout: Timeout for control command (default: 30.0 seconds)
259
+
260
+ Returns:
261
+ Dictionary mapping SSRC to ChannelInfo
262
+ """
263
+ logger.info(f"Discovering channels via 'control' utility from {status_address}")
264
+
265
+ channels = {}
266
+
267
+ try:
268
+ # Run control utility with -v flag to get verbose channel listing
269
+ # Send empty input to make it list and exit
270
+ result = subprocess.run(
271
+ ['control', '-v', status_address],
272
+ input='\n',
273
+ capture_output=True,
274
+ text=True,
275
+ timeout=timeout
276
+ )
277
+
278
+ output = result.stdout
279
+
280
+ # Parse the output
281
+ # Format: SSRC preset samprate freq, Hz SNR output channel
282
+ # 60000 iq 16,000 60,000 9.5 239.41.204.101:5004
283
+
284
+ for line in output.split('\n'):
285
+ # Skip header and non-data lines
286
+ if 'SSRC' in line or 'channels' in line or not line.strip():
287
+ continue
288
+
289
+ # Parse channel line
290
+ # Pattern: whitespace-separated values
291
+ parts = line.split()
292
+ if len(parts) < 6:
293
+ continue
294
+
295
+ try:
296
+ ssrc = int(parts[0])
297
+ preset = parts[1]
298
+ sample_rate = int(parts[2].replace(',', ''))
299
+ frequency = float(parts[3].replace(',', ''))
300
+ snr_str = parts[4]
301
+ snr = float(snr_str) if snr_str != '-inf' else float('-inf')
302
+
303
+ # Parse multicast address:port
304
+ addr_port = parts[5]
305
+ if ':' in addr_port:
306
+ addr, port_str = addr_port.rsplit(':', 1)
307
+ port = int(port_str)
308
+ else:
309
+ addr = addr_port
310
+ port = 5004 # default
311
+
312
+ channel = ChannelInfo(
313
+ ssrc=ssrc,
314
+ preset=preset,
315
+ sample_rate=sample_rate,
316
+ frequency=frequency,
317
+ snr=snr,
318
+ multicast_address=addr,
319
+ port=port
320
+ )
321
+
322
+ channels[ssrc] = channel
323
+
324
+ logger.debug(
325
+ f"Found channel: SSRC={ssrc}, freq={frequency/1e6:.3f} MHz, "
326
+ f"rate={sample_rate} Hz, preset={preset}, addr={addr}:{port}"
327
+ )
328
+
329
+ except (ValueError, IndexError) as e:
330
+ logger.debug(f"Could not parse line: {line} - {e}")
331
+ continue
332
+
333
+ logger.info(f"Discovered {len(channels)} channels")
334
+
335
+ except subprocess.TimeoutExpired:
336
+ logger.error(f"Timeout running control utility")
337
+ except FileNotFoundError:
338
+ logger.error("control utility not found - is ka9q-radio installed?")
339
+ except Exception as e:
340
+ logger.error(f"Error running control utility: {e}")
341
+
342
+ return channels
343
+
344
+
345
+ def discover_channels(status_address: str,
346
+ listen_duration: float = 2.0,
347
+ use_native: bool = True,
348
+ interface: Optional[str] = None) -> Dict[int, ChannelInfo]:
349
+ """
350
+ Discover channels using the best available method
351
+
352
+ By default, uses native Python implementation. If use_native=False or
353
+ if native discovery fails, falls back to the 'control' utility.
354
+
355
+ Args:
356
+ status_address: Status multicast address (e.g., "radiod.local")
357
+ listen_duration: Duration to listen for native discovery (default: 2.0 seconds)
358
+ use_native: If True, use native Python listener; if False, use control utility
359
+ interface: IP address of network interface for multicast (e.g., '192.168.1.100').
360
+ Required on multi-homed systems. If None, uses INADDR_ANY (0.0.0.0).
361
+
362
+ Returns:
363
+ Dictionary mapping SSRC to ChannelInfo
364
+ """
365
+ if use_native:
366
+ try:
367
+ logger.debug("Attempting native channel discovery")
368
+ channels = discover_channels_native(status_address, listen_duration, interface)
369
+ if channels:
370
+ return channels
371
+ else:
372
+ logger.warning("Native discovery found no channels, trying control utility fallback")
373
+ except Exception as e:
374
+ logger.warning(f"Native discovery failed ({e}), trying control utility fallback")
375
+
376
+ # Fall back to control utility
377
+ logger.debug("Using control utility for channel discovery")
378
+ return discover_channels_via_control(status_address)
379
+
380
+
381
+ def find_channels_by_frequencies(
382
+ status_address: str,
383
+ frequencies: List[float],
384
+ tolerance: float = 1000.0
385
+ ) -> Dict[float, ChannelInfo]:
386
+ """
387
+ Find channels matching specific frequencies
388
+
389
+ Args:
390
+ status_address: Status multicast address
391
+ frequencies: List of frequencies to find (in Hz)
392
+ tolerance: Frequency tolerance in Hz (default 1000 Hz = 1 kHz)
393
+
394
+ Returns:
395
+ Dictionary mapping requested frequency to ChannelInfo
396
+ """
397
+ all_channels = discover_channels(status_address)
398
+
399
+ matched = {}
400
+
401
+ for target_freq in frequencies:
402
+ best_match = None
403
+ best_diff = float('inf')
404
+
405
+ for ssrc, channel in all_channels.items():
406
+ diff = abs(channel.frequency - target_freq)
407
+ if diff < tolerance and diff < best_diff:
408
+ best_match = channel
409
+ best_diff = diff
410
+
411
+ if best_match:
412
+ matched[target_freq] = best_match
413
+ logger.info(
414
+ f"Matched {target_freq/1e6:.3f} MHz → SSRC {best_match.ssrc} "
415
+ f"({best_match.frequency/1e6:.3f} MHz, diff={best_diff:.0f} Hz)"
416
+ )
417
+ else:
418
+ logger.warning(f"No channel found for {target_freq/1e6:.3f} MHz")
419
+
420
+ return matched
421
+
422
+
423
+
424
+ def _decode_escape_sequences(s: str) -> str:
425
+ """
426
+ Decode decimal escape sequences in a string from avahi-browse output
427
+
428
+ avahi-browse uses decimal ASCII escape sequences (e.g., \064 = ASCII 64 = '@')
429
+
430
+ Args:
431
+ s: String potentially containing escape sequences like \032 or \064
432
+
433
+ Returns:
434
+ Decoded string with escape sequences converted to actual characters
435
+ """
436
+ def replace_decimal(match):
437
+ """Replace decimal escape sequence with actual character"""
438
+ decimal_str = match.group(1)
439
+ char_code = int(decimal_str, 10) # Decimal, not octal!
440
+ # Replace control characters and non-printable chars with space
441
+ if char_code < 32 or char_code == 127:
442
+ return ' '
443
+ return chr(char_code)
444
+
445
+ # Replace decimal sequences like \032 (space) or \064 (@) with actual characters
446
+ s = re.sub(r'\\(\d{3})', replace_decimal, s)
447
+ # Replace other common escape sequences
448
+ s = s.replace(r'\n', '\n').replace(r'\t', '\t').replace(r'\\', '\\')
449
+ return s
450
+
451
+
452
+ def discover_radiod_services():
453
+ """
454
+ Discover all radiod services on the network via mDNS
455
+
456
+ Returns:
457
+ List of dicts with "name" and "address" keys
458
+ """
459
+ import subprocess
460
+
461
+ # Use dict to automatically deduplicate by address
462
+ services_dict = {}
463
+ try:
464
+ result = subprocess.run(
465
+ ["avahi-browse", "-t", "_ka9q-ctl._udp", "-p", "-r"],
466
+ capture_output=True,
467
+ text=True,
468
+ timeout=5
469
+ )
470
+
471
+ for line in result.stdout.split("\n"):
472
+ if line.startswith("="):
473
+ parts = line.split(";")
474
+ if len(parts) >= 8:
475
+ name = _decode_escape_sequences(parts[3])
476
+ address = parts[7]
477
+ # Use address as key to deduplicate
478
+ services_dict[address] = {"name": name, "address": address}
479
+ except Exception as e:
480
+ logger.warning(f"Failed to discover radiod services: {e}")
481
+
482
+ # Convert dict back to list, sorted by name for consistency
483
+ services = sorted(services_dict.values(), key=lambda x: x['name'])
484
+ return services
485
+
ka9q/exceptions.py ADDED
@@ -0,0 +1,23 @@
1
+ """
2
+ Exceptions for ka9q library
3
+ """
4
+
5
+ class Ka9qError(Exception):
6
+ """Base exception for all ka9q errors"""
7
+ pass
8
+
9
+ class ConnectionError(Ka9qError):
10
+ """Failed to connect to radiod"""
11
+ pass
12
+
13
+ class CommandError(Ka9qError):
14
+ """Failed to send command to radiod"""
15
+ pass
16
+
17
+ class DiscoveryError(Ka9qError):
18
+ """Failed to discover radiod services or channels"""
19
+ pass
20
+
21
+ class ValidationError(Ka9qError):
22
+ """Invalid parameter or configuration"""
23
+ pass