mirror of
https://github.com/smittix/intercept.git
synced 2026-05-28 10:44:45 -07:00
Merge branch 'smittix:main' into main
This commit is contained in:
@@ -13,7 +13,7 @@ import tempfile
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from subprocess import DEVNULL, PIPE, STDOUT
|
||||
from subprocess import PIPE, STDOUT
|
||||
from typing import Generator, Optional
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
@@ -31,6 +31,9 @@ from utils.constants import (
|
||||
|
||||
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 = {
|
||||
'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).
|
||||
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:
|
||||
- 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}")
|
||||
app_module.aprs_queue.put({'type': 'error', 'message': str(e)})
|
||||
finally:
|
||||
global aprs_active_device
|
||||
app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'})
|
||||
# Cleanup processes
|
||||
for proc in [rtl_process, decoder_process]:
|
||||
@@ -1394,6 +1398,10 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
||||
proc.kill()
|
||||
except Exception:
|
||||
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')
|
||||
@@ -1441,6 +1449,7 @@ def get_stations() -> Response:
|
||||
def start_aprs() -> Response:
|
||||
"""Start APRS decoder."""
|
||||
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
|
||||
global aprs_active_device
|
||||
|
||||
with app_module.aprs_lock:
|
||||
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:
|
||||
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
|
||||
region = data.get('region', 'north_america')
|
||||
frequency = APRS_FREQUENCIES.get(region, '144.390')
|
||||
@@ -1552,15 +1571,25 @@ def start_aprs() -> Response:
|
||||
|
||||
try:
|
||||
# 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.
|
||||
rtl_process = subprocess.Popen(
|
||||
rtl_cmd,
|
||||
stdout=PIPE,
|
||||
stderr=DEVNULL,
|
||||
stderr=PIPE,
|
||||
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.
|
||||
# Use text mode with line buffering for reliable line-by-line reading.
|
||||
# Merge stderr into stdout to avoid blocking on unbuffered stderr.
|
||||
@@ -1582,13 +1611,25 @@ def start_aprs() -> Response:
|
||||
time.sleep(PROCESS_START_WAIT)
|
||||
|
||||
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})'
|
||||
if stderr_output:
|
||||
error_msg += f': {stderr_output[:200]}'
|
||||
logger.error(error_msg)
|
||||
try:
|
||||
decoder_process.kill()
|
||||
except Exception:
|
||||
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
|
||||
|
||||
if decoder_process.poll() is not None:
|
||||
@@ -1602,6 +1643,9 @@ def start_aprs() -> Response:
|
||||
rtl_process.kill()
|
||||
except Exception:
|
||||
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
|
||||
|
||||
# Store references for status checks and cleanup
|
||||
@@ -1626,12 +1670,17 @@ def start_aprs() -> Response:
|
||||
|
||||
except Exception as 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
|
||||
|
||||
|
||||
@aprs_bp.route('/stop', methods=['POST'])
|
||||
def stop_aprs() -> Response:
|
||||
"""Stop APRS decoder."""
|
||||
global aprs_active_device
|
||||
|
||||
with app_module.aprs_lock:
|
||||
processes_to_stop = []
|
||||
|
||||
@@ -1660,6 +1709,11 @@ def stop_aprs() -> Response:
|
||||
if hasattr(app_module, 'aprs_rtl_process'):
|
||||
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'})
|
||||
|
||||
|
||||
|
||||
@@ -96,16 +96,27 @@ def find_rx_fm() -> str | None:
|
||||
return shutil.which('rx_fm')
|
||||
|
||||
|
||||
def find_ffmpeg() -> str | None:
|
||||
"""Find ffmpeg for audio encoding."""
|
||||
return shutil.which('ffmpeg')
|
||||
|
||||
|
||||
|
||||
|
||||
def add_activity_log(event_type: str, frequency: float, details: str = ''):
|
||||
"""Add entry to activity log."""
|
||||
with activity_log_lock:
|
||||
def find_ffmpeg() -> str | None:
|
||||
"""Find ffmpeg for audio encoding."""
|
||||
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 = ''):
|
||||
"""Add entry to activity log."""
|
||||
with activity_log_lock:
|
||||
entry = {
|
||||
'timestamp': datetime.utcnow().isoformat() + 'Z',
|
||||
'type': event_type,
|
||||
@@ -723,56 +734,106 @@ def _start_audio_stream(frequency: float, modulation: str):
|
||||
'pipe:1'
|
||||
]
|
||||
|
||||
try:
|
||||
# Use shell pipe for reliable streaming
|
||||
# Log stderr to temp files for error diagnosis
|
||||
rtl_stderr_log = '/tmp/rtl_fm_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']}")
|
||||
|
||||
# Retry loop for USB device contention (device may not be
|
||||
# released immediately after a previous process exits)
|
||||
max_attempts = 3
|
||||
for attempt in range(max_attempts):
|
||||
audio_rtl_process = None # Not used in shell mode
|
||||
audio_process = subprocess.Popen(
|
||||
shell_cmd,
|
||||
shell=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
bufsize=0,
|
||||
start_new_session=True # Create new process group for clean shutdown
|
||||
)
|
||||
|
||||
# Brief delay to check if process started successfully
|
||||
time.sleep(0.3)
|
||||
|
||||
if audio_process.poll() is not None:
|
||||
# Read stderr from temp files
|
||||
rtl_stderr = ''
|
||||
ffmpeg_stderr = ''
|
||||
try:
|
||||
with open(rtl_stderr_log, 'r') as f:
|
||||
rtl_stderr = f.read().strip()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
with open(ffmpeg_stderr_log, 'r') as f:
|
||||
ffmpeg_stderr = f.read().strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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...")
|
||||
time.sleep(1.0)
|
||||
continue
|
||||
|
||||
logger.error(f"Audio pipeline exited immediately. rtl_fm stderr: {rtl_stderr}, ffmpeg stderr: {ffmpeg_stderr}")
|
||||
return
|
||||
|
||||
# Pipeline started successfully
|
||||
break
|
||||
try:
|
||||
# Use subprocess piping for reliable streaming.
|
||||
# Log stderr to temp files for error diagnosis.
|
||||
rtl_stderr_log = '/tmp/rtl_fm_stderr.log'
|
||||
ffmpeg_stderr_log = '/tmp/ffmpeg_stderr.log'
|
||||
logger.info(f"Starting audio: {frequency} MHz, mod={modulation}, device={scanner_config['device']}")
|
||||
|
||||
# Retry loop for USB device contention (device may not be
|
||||
# released immediately after a previous process exits)
|
||||
max_attempts = 3
|
||||
for attempt in range(max_attempts):
|
||||
audio_rtl_process = None
|
||||
audio_process = None
|
||||
rtl_err_handle = None
|
||||
ffmpeg_err_handle = None
|
||||
try:
|
||||
rtl_err_handle = open(rtl_stderr_log, 'w')
|
||||
ffmpeg_err_handle = open(ffmpeg_stderr_log, 'w')
|
||||
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
|
||||
time.sleep(0.3)
|
||||
|
||||
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
|
||||
rtl_stderr = ''
|
||||
ffmpeg_stderr = ''
|
||||
try:
|
||||
with open(rtl_stderr_log, 'r') as f:
|
||||
rtl_stderr = f.read().strip()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
with open(ffmpeg_stderr_log, 'r') as f:
|
||||
ffmpeg_stderr = f.read().strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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...")
|
||||
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)
|
||||
continue
|
||||
|
||||
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
|
||||
|
||||
# Pipeline started successfully
|
||||
break
|
||||
|
||||
# Validate that audio is producing data quickly
|
||||
try:
|
||||
@@ -797,28 +858,38 @@ def _stop_audio_stream():
|
||||
_stop_audio_stream_internal()
|
||||
|
||||
|
||||
def _stop_audio_stream_internal():
|
||||
"""Internal stop (must hold lock)."""
|
||||
global audio_process, audio_rtl_process, audio_running, audio_frequency
|
||||
|
||||
# Set flag first to stop any streaming
|
||||
audio_running = False
|
||||
audio_frequency = 0.0
|
||||
|
||||
# Kill the shell process and its children
|
||||
if audio_process:
|
||||
try:
|
||||
# Kill entire process group (rtl_fm, ffmpeg, shell)
|
||||
try:
|
||||
os.killpg(os.getpgid(audio_process.pid), signal.SIGKILL)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
audio_process.kill()
|
||||
audio_process.wait(timeout=0.5)
|
||||
except:
|
||||
pass
|
||||
|
||||
audio_process = None
|
||||
audio_rtl_process = None
|
||||
def _stop_audio_stream_internal():
|
||||
"""Internal stop (must hold lock)."""
|
||||
global audio_process, audio_rtl_process, audio_running, audio_frequency
|
||||
|
||||
# Set flag first to stop any streaming
|
||||
audio_running = False
|
||||
audio_frequency = 0.0
|
||||
|
||||
# Kill the pipeline processes and their groups
|
||||
if audio_process:
|
||||
try:
|
||||
# Kill entire process group (SDR demod + ffmpeg)
|
||||
try:
|
||||
os.killpg(os.getpgid(audio_process.pid), signal.SIGKILL)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
audio_process.kill()
|
||||
audio_process.wait(timeout=0.5)
|
||||
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
|
||||
|
||||
audio_process = None
|
||||
audio_rtl_process = None
|
||||
|
||||
# Kill any orphaned rtl_fm, rtl_power, and ffmpeg processes
|
||||
for proc_pattern in ['rtl_fm', 'rtl_power']:
|
||||
@@ -891,7 +962,7 @@ def start_scanner() -> Response:
|
||||
scanner_config['start_freq'] = float(data.get('start_freq', 88.0))
|
||||
scanner_config['end_freq'] = float(data.get('end_freq', 108.0))
|
||||
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['dwell_time'] = float(data.get('dwell_time', 3.0))
|
||||
scanner_config['scan_delay'] = float(data.get('scan_delay', 0.5))
|
||||
@@ -1073,9 +1144,15 @@ def update_scanner_config() -> Response:
|
||||
scanner_config['dwell_time'] = int(data['dwell_time'])
|
||||
updated.append(f"dwell={data['dwell_time']}s")
|
||||
|
||||
if 'modulation' in data:
|
||||
scanner_config['modulation'] = str(data['modulation']).lower()
|
||||
updated.append(f"mod={data['modulation']}")
|
||||
if 'modulation' in data:
|
||||
try:
|
||||
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:
|
||||
logger.info(f"Scanner config updated: {', '.join(updated)}")
|
||||
@@ -1197,7 +1274,7 @@ def start_audio() -> Response:
|
||||
|
||||
try:
|
||||
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))
|
||||
gain = int(data.get('gain', 40))
|
||||
device = int(data.get('device', 0))
|
||||
@@ -1214,13 +1291,6 @@ def start_audio() -> Response:
|
||||
'message': 'frequency is required'
|
||||
}), 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']
|
||||
if sdr_type not in valid_sdr_types:
|
||||
return jsonify({
|
||||
|
||||
Reference in New Issue
Block a user