Merge branch 'smittix:main' into main

This commit is contained in:
Device
2026-02-06 16:05:05 +01:00
committed by GitHub
2 changed files with 223 additions and 99 deletions

View File

@@ -13,7 +13,7 @@ import tempfile
import threading import threading
import time import time
from datetime import datetime from datetime import datetime
from subprocess import DEVNULL, PIPE, STDOUT from subprocess import PIPE, STDOUT
from typing import Generator, Optional from typing import Generator, Optional
from flask import Blueprint, jsonify, request, Response from flask import Blueprint, jsonify, request, Response
@@ -31,6 +31,9 @@ from utils.constants import (
aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs') aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
# Track which SDR device is being used
aprs_active_device: int | None = None
# APRS frequencies by region (MHz) # APRS frequencies by region (MHz)
APRS_FREQUENCIES = { APRS_FREQUENCIES = {
'north_america': '144.390', 'north_america': '144.390',
@@ -1301,7 +1304,7 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
This function reads from the decoder's stdout (text mode, line-buffered). This function reads from the decoder's stdout (text mode, line-buffered).
The decoder's stderr is merged into stdout (STDOUT) to avoid deadlocks. The decoder's stderr is merged into stdout (STDOUT) to avoid deadlocks.
rtl_fm's stderr is sent to DEVNULL for the same reason. rtl_fm's stderr is captured via PIPE with a monitor thread.
Outputs two types of messages to the queue: Outputs two types of messages to the queue:
- type='aprs': Decoded APRS packets - type='aprs': Decoded APRS packets
@@ -1383,6 +1386,7 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
logger.error(f"APRS stream error: {e}") logger.error(f"APRS stream error: {e}")
app_module.aprs_queue.put({'type': 'error', 'message': str(e)}) app_module.aprs_queue.put({'type': 'error', 'message': str(e)})
finally: finally:
global aprs_active_device
app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'}) app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'})
# Cleanup processes # Cleanup processes
for proc in [rtl_process, decoder_process]: for proc in [rtl_process, decoder_process]:
@@ -1394,6 +1398,10 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
proc.kill() proc.kill()
except Exception: except Exception:
pass pass
# Release SDR device
if aprs_active_device is not None:
app_module.release_sdr_device(aprs_active_device)
aprs_active_device = None
@aprs_bp.route('/tools') @aprs_bp.route('/tools')
@@ -1441,6 +1449,7 @@ def get_stations() -> Response:
def start_aprs() -> Response: def start_aprs() -> Response:
"""Start APRS decoder.""" """Start APRS decoder."""
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
global aprs_active_device
with app_module.aprs_lock: with app_module.aprs_lock:
if app_module.aprs_process and app_module.aprs_process.poll() is None: if app_module.aprs_process and app_module.aprs_process.poll() is None:
@@ -1477,6 +1486,16 @@ def start_aprs() -> Response:
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return jsonify({'status': 'error', 'message': str(e)}), 400
# Reserve SDR device to prevent conflicts with other modes
error = app_module.reserve_sdr_device(device, 'APRS')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
aprs_active_device = device
# Get frequency for region # Get frequency for region
region = data.get('region', 'north_america') region = data.get('region', 'north_america')
frequency = APRS_FREQUENCIES.get(region, '144.390') frequency = APRS_FREQUENCIES.get(region, '144.390')
@@ -1552,15 +1571,25 @@ def start_aprs() -> Response:
try: try:
# Start rtl_fm with stdout piped to decoder. # Start rtl_fm with stdout piped to decoder.
# stderr goes to DEVNULL to prevent blocking (rtl_fm logs to stderr). # stderr is captured via PIPE so errors are reported to the user.
# NOTE: RTL-SDR Blog V4 may show offset-tuned frequency in logs - this is normal. # NOTE: RTL-SDR Blog V4 may show offset-tuned frequency in logs - this is normal.
rtl_process = subprocess.Popen( rtl_process = subprocess.Popen(
rtl_cmd, rtl_cmd,
stdout=PIPE, stdout=PIPE,
stderr=DEVNULL, stderr=PIPE,
start_new_session=True start_new_session=True
) )
# Start a thread to monitor rtl_fm stderr for errors
def monitor_rtl_stderr():
for line in rtl_process.stderr:
err_text = line.decode('utf-8', errors='replace').strip()
if err_text:
logger.debug(f"[RTL_FM] {err_text}")
rtl_stderr_thread = threading.Thread(target=monitor_rtl_stderr, daemon=True)
rtl_stderr_thread.start()
# Start decoder with stdin wired to rtl_fm's stdout. # Start decoder with stdin wired to rtl_fm's stdout.
# Use text mode with line buffering for reliable line-by-line reading. # Use text mode with line buffering for reliable line-by-line reading.
# Merge stderr into stdout to avoid blocking on unbuffered stderr. # Merge stderr into stdout to avoid blocking on unbuffered stderr.
@@ -1582,13 +1611,25 @@ def start_aprs() -> Response:
time.sleep(PROCESS_START_WAIT) time.sleep(PROCESS_START_WAIT)
if rtl_process.poll() is not None: if rtl_process.poll() is not None:
# rtl_fm exited early - something went wrong # rtl_fm exited early - capture stderr for diagnostics
stderr_output = ''
try:
remaining = rtl_process.stderr.read()
if remaining:
stderr_output = remaining.decode('utf-8', errors='replace').strip()
except Exception:
pass
error_msg = f'rtl_fm failed to start (exit code {rtl_process.returncode})' error_msg = f'rtl_fm failed to start (exit code {rtl_process.returncode})'
if stderr_output:
error_msg += f': {stderr_output[:200]}'
logger.error(error_msg) logger.error(error_msg)
try: try:
decoder_process.kill() decoder_process.kill()
except Exception: except Exception:
pass pass
if aprs_active_device is not None:
app_module.release_sdr_device(aprs_active_device)
aprs_active_device = None
return jsonify({'status': 'error', 'message': error_msg}), 500 return jsonify({'status': 'error', 'message': error_msg}), 500
if decoder_process.poll() is not None: if decoder_process.poll() is not None:
@@ -1602,6 +1643,9 @@ def start_aprs() -> Response:
rtl_process.kill() rtl_process.kill()
except Exception: except Exception:
pass pass
if aprs_active_device is not None:
app_module.release_sdr_device(aprs_active_device)
aprs_active_device = None
return jsonify({'status': 'error', 'message': error_msg}), 500 return jsonify({'status': 'error', 'message': error_msg}), 500
# Store references for status checks and cleanup # Store references for status checks and cleanup
@@ -1626,12 +1670,17 @@ def start_aprs() -> Response:
except Exception as e: except Exception as e:
logger.error(f"Failed to start APRS decoder: {e}") logger.error(f"Failed to start APRS decoder: {e}")
if aprs_active_device is not None:
app_module.release_sdr_device(aprs_active_device)
aprs_active_device = None
return jsonify({'status': 'error', 'message': str(e)}), 500 return jsonify({'status': 'error', 'message': str(e)}), 500
@aprs_bp.route('/stop', methods=['POST']) @aprs_bp.route('/stop', methods=['POST'])
def stop_aprs() -> Response: def stop_aprs() -> Response:
"""Stop APRS decoder.""" """Stop APRS decoder."""
global aprs_active_device
with app_module.aprs_lock: with app_module.aprs_lock:
processes_to_stop = [] processes_to_stop = []
@@ -1660,6 +1709,11 @@ def stop_aprs() -> Response:
if hasattr(app_module, 'aprs_rtl_process'): if hasattr(app_module, 'aprs_rtl_process'):
app_module.aprs_rtl_process = None app_module.aprs_rtl_process = None
# Release SDR device
if aprs_active_device is not None:
app_module.release_sdr_device(aprs_active_device)
aprs_active_device = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})

View File

@@ -101,6 +101,17 @@ def find_ffmpeg() -> str | None:
return shutil.which('ffmpeg') return shutil.which('ffmpeg')
VALID_MODULATIONS = ['fm', 'wfm', 'am', 'usb', 'lsb']
def normalize_modulation(value: str) -> str:
"""Normalize and validate modulation string."""
mod = str(value or '').lower().strip()
if mod not in VALID_MODULATIONS:
raise ValueError(f'Invalid modulation. Use: {", ".join(VALID_MODULATIONS)}')
return mod
def add_activity_log(event_type: str, frequency: float, details: str = ''): def add_activity_log(event_type: str, frequency: float, details: str = ''):
@@ -724,31 +735,52 @@ def _start_audio_stream(frequency: float, modulation: str):
] ]
try: try:
# Use shell pipe for reliable streaming # Use subprocess piping for reliable streaming.
# Log stderr to temp files for error diagnosis # Log stderr to temp files for error diagnosis.
rtl_stderr_log = '/tmp/rtl_fm_stderr.log' rtl_stderr_log = '/tmp/rtl_fm_stderr.log'
ffmpeg_stderr_log = '/tmp/ffmpeg_stderr.log' ffmpeg_stderr_log = '/tmp/ffmpeg_stderr.log'
shell_cmd = f"{' '.join(sdr_cmd)} 2>{rtl_stderr_log} | {' '.join(encoder_cmd)} 2>{ffmpeg_stderr_log}"
logger.info(f"Starting audio: {frequency} MHz, mod={modulation}, device={scanner_config['device']}") logger.info(f"Starting audio: {frequency} MHz, mod={modulation}, device={scanner_config['device']}")
# Retry loop for USB device contention (device may not be # Retry loop for USB device contention (device may not be
# released immediately after a previous process exits) # released immediately after a previous process exits)
max_attempts = 3 max_attempts = 3
for attempt in range(max_attempts): for attempt in range(max_attempts):
audio_rtl_process = None # Not used in shell mode audio_rtl_process = None
audio_process = subprocess.Popen( audio_process = None
shell_cmd, rtl_err_handle = None
shell=True, ffmpeg_err_handle = None
stdout=subprocess.PIPE, try:
stderr=subprocess.PIPE, rtl_err_handle = open(rtl_stderr_log, 'w')
bufsize=0, ffmpeg_err_handle = open(ffmpeg_stderr_log, 'w')
start_new_session=True # Create new process group for clean shutdown audio_rtl_process = subprocess.Popen(
) sdr_cmd,
stdout=subprocess.PIPE,
stderr=rtl_err_handle,
bufsize=0,
start_new_session=True # Create new process group for clean shutdown
)
audio_process = subprocess.Popen(
encoder_cmd,
stdin=audio_rtl_process.stdout,
stdout=subprocess.PIPE,
stderr=ffmpeg_err_handle,
bufsize=0,
start_new_session=True # Create new process group for clean shutdown
)
if audio_rtl_process.stdout:
audio_rtl_process.stdout.close()
finally:
if rtl_err_handle:
rtl_err_handle.close()
if ffmpeg_err_handle:
ffmpeg_err_handle.close()
# Brief delay to check if process started successfully # Brief delay to check if process started successfully
time.sleep(0.3) time.sleep(0.3)
if audio_process.poll() is not None: if (audio_rtl_process and audio_rtl_process.poll() is not None) or (
audio_process and audio_process.poll() is not None
):
# Read stderr from temp files # Read stderr from temp files
rtl_stderr = '' rtl_stderr = ''
ffmpeg_stderr = '' ffmpeg_stderr = ''
@@ -765,10 +797,39 @@ def _start_audio_stream(frequency: float, modulation: str):
if 'usb_claim_interface' in rtl_stderr and attempt < max_attempts - 1: if 'usb_claim_interface' in rtl_stderr and attempt < max_attempts - 1:
logger.warning(f"USB device busy (attempt {attempt + 1}/{max_attempts}), waiting for release...") logger.warning(f"USB device busy (attempt {attempt + 1}/{max_attempts}), waiting for release...")
if audio_process:
try:
audio_process.terminate()
audio_process.wait(timeout=0.5)
except Exception:
pass
if audio_rtl_process:
try:
audio_rtl_process.terminate()
audio_rtl_process.wait(timeout=0.5)
except Exception:
pass
time.sleep(1.0) time.sleep(1.0)
continue continue
logger.error(f"Audio pipeline exited immediately. rtl_fm stderr: {rtl_stderr}, ffmpeg stderr: {ffmpeg_stderr}") if audio_process and audio_process.poll() is None:
try:
audio_process.terminate()
audio_process.wait(timeout=0.5)
except Exception:
pass
if audio_rtl_process and audio_rtl_process.poll() is None:
try:
audio_rtl_process.terminate()
audio_rtl_process.wait(timeout=0.5)
except Exception:
pass
audio_process = None
audio_rtl_process = None
logger.error(
f"Audio pipeline exited immediately. rtl_fm stderr: {rtl_stderr}, ffmpeg stderr: {ffmpeg_stderr}"
)
return return
# Pipeline started successfully # Pipeline started successfully
@@ -805,16 +866,26 @@ def _stop_audio_stream_internal():
audio_running = False audio_running = False
audio_frequency = 0.0 audio_frequency = 0.0
# Kill the shell process and its children # Kill the pipeline processes and their groups
if audio_process: if audio_process:
try: try:
# Kill entire process group (rtl_fm, ffmpeg, shell) # Kill entire process group (SDR demod + ffmpeg)
try: try:
os.killpg(os.getpgid(audio_process.pid), signal.SIGKILL) os.killpg(os.getpgid(audio_process.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError): except (ProcessLookupError, PermissionError):
audio_process.kill() audio_process.kill()
audio_process.wait(timeout=0.5) audio_process.wait(timeout=0.5)
except: except Exception:
pass
if audio_rtl_process:
try:
try:
os.killpg(os.getpgid(audio_rtl_process.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError):
audio_rtl_process.kill()
audio_rtl_process.wait(timeout=0.5)
except Exception:
pass pass
audio_process = None audio_process = None
@@ -891,7 +962,7 @@ def start_scanner() -> Response:
scanner_config['start_freq'] = float(data.get('start_freq', 88.0)) scanner_config['start_freq'] = float(data.get('start_freq', 88.0))
scanner_config['end_freq'] = float(data.get('end_freq', 108.0)) scanner_config['end_freq'] = float(data.get('end_freq', 108.0))
scanner_config['step'] = float(data.get('step', 0.1)) scanner_config['step'] = float(data.get('step', 0.1))
scanner_config['modulation'] = str(data.get('modulation', 'wfm')).lower() scanner_config['modulation'] = normalize_modulation(data.get('modulation', 'wfm'))
scanner_config['squelch'] = int(data.get('squelch', 0)) scanner_config['squelch'] = int(data.get('squelch', 0))
scanner_config['dwell_time'] = float(data.get('dwell_time', 3.0)) scanner_config['dwell_time'] = float(data.get('dwell_time', 3.0))
scanner_config['scan_delay'] = float(data.get('scan_delay', 0.5)) scanner_config['scan_delay'] = float(data.get('scan_delay', 0.5))
@@ -1074,8 +1145,14 @@ def update_scanner_config() -> Response:
updated.append(f"dwell={data['dwell_time']}s") updated.append(f"dwell={data['dwell_time']}s")
if 'modulation' in data: if 'modulation' in data:
scanner_config['modulation'] = str(data['modulation']).lower() try:
updated.append(f"mod={data['modulation']}") scanner_config['modulation'] = normalize_modulation(data['modulation'])
updated.append(f"mod={data['modulation']}")
except (ValueError, TypeError) as e:
return jsonify({
'status': 'error',
'message': str(e)
}), 400
if updated: if updated:
logger.info(f"Scanner config updated: {', '.join(updated)}") logger.info(f"Scanner config updated: {', '.join(updated)}")
@@ -1197,7 +1274,7 @@ def start_audio() -> Response:
try: try:
frequency = float(data.get('frequency', 0)) frequency = float(data.get('frequency', 0))
modulation = str(data.get('modulation', 'wfm')).lower() modulation = normalize_modulation(data.get('modulation', 'wfm'))
squelch = int(data.get('squelch', 0)) squelch = int(data.get('squelch', 0))
gain = int(data.get('gain', 40)) gain = int(data.get('gain', 40))
device = int(data.get('device', 0)) device = int(data.get('device', 0))
@@ -1214,13 +1291,6 @@ def start_audio() -> Response:
'message': 'frequency is required' 'message': 'frequency is required'
}), 400 }), 400
valid_mods = ['fm', 'wfm', 'am', 'usb', 'lsb']
if modulation not in valid_mods:
return jsonify({
'status': 'error',
'message': f'Invalid modulation. Use: {", ".join(valid_mods)}'
}), 400
valid_sdr_types = ['rtlsdr', 'hackrf', 'airspy', 'limesdr', 'sdrplay'] valid_sdr_types = ['rtlsdr', 'hackrf', 'airspy', 'limesdr', 'sdrplay']
if sdr_type not in valid_sdr_types: if sdr_type not in valid_sdr_types:
return jsonify({ return jsonify({