ka9q-python 3.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ka9q/__init__.py +98 -0
- ka9q/control.py +2295 -0
- ka9q/discovery.py +485 -0
- ka9q/exceptions.py +23 -0
- ka9q/resequencer.py +389 -0
- ka9q/rtp_recorder.py +457 -0
- ka9q/stream.py +393 -0
- ka9q/stream_quality.py +215 -0
- ka9q/types.py +161 -0
- ka9q/utils.py +202 -0
- ka9q_python-3.2.0.dist-info/METADATA +237 -0
- ka9q_python-3.2.0.dist-info/RECORD +15 -0
- ka9q_python-3.2.0.dist-info/WHEEL +5 -0
- ka9q_python-3.2.0.dist-info/licenses/LICENSE +21 -0
- ka9q_python-3.2.0.dist-info/top_level.txt +1 -0
ka9q/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
|