mirror of
https://github.com/smittix/intercept.git
synced 2026-07-27 01:58:10 -07:00
Prevent stale monitor start requests from retuning audio
This commit is contained in:
+205
-185
@@ -38,13 +38,15 @@ receiver_bp = Blueprint('receiver', __name__, url_prefix='/receiver')
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# Audio demodulation state
|
# Audio demodulation state
|
||||||
audio_process = None
|
audio_process = None
|
||||||
audio_rtl_process = None
|
audio_rtl_process = None
|
||||||
audio_lock = threading.Lock()
|
audio_lock = threading.Lock()
|
||||||
audio_running = False
|
audio_start_lock = threading.Lock()
|
||||||
audio_frequency = 0.0
|
audio_running = False
|
||||||
audio_modulation = 'fm'
|
audio_frequency = 0.0
|
||||||
audio_source = 'process'
|
audio_modulation = 'fm'
|
||||||
|
audio_source = 'process'
|
||||||
|
audio_start_token = 0
|
||||||
|
|
||||||
# Scanner state
|
# Scanner state
|
||||||
scanner_thread: Optional[threading.Thread] = None
|
scanner_thread: Optional[threading.Thread] = None
|
||||||
@@ -1269,184 +1271,202 @@ def get_presets() -> Response:
|
|||||||
# MANUAL AUDIO ENDPOINTS (for direct listening)
|
# MANUAL AUDIO ENDPOINTS (for direct listening)
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
@receiver_bp.route('/audio/start', methods=['POST'])
|
@receiver_bp.route('/audio/start', methods=['POST'])
|
||||||
def start_audio() -> Response:
|
def start_audio() -> Response:
|
||||||
"""Start audio at specific frequency (manual mode)."""
|
"""Start audio at specific frequency (manual mode)."""
|
||||||
global scanner_running, scanner_active_device, receiver_active_device, scanner_power_process, scanner_thread
|
global scanner_running, scanner_active_device, receiver_active_device, scanner_power_process, scanner_thread
|
||||||
global audio_running, audio_frequency, audio_modulation, audio_source
|
global audio_running, audio_frequency, audio_modulation, audio_source, audio_start_token
|
||||||
|
|
||||||
# Stop scanner if running
|
data = request.json or {}
|
||||||
if scanner_running:
|
|
||||||
scanner_running = False
|
try:
|
||||||
if scanner_active_device is not None:
|
frequency = float(data.get('frequency', 0))
|
||||||
app_module.release_sdr_device(scanner_active_device)
|
modulation = normalize_modulation(data.get('modulation', 'wfm'))
|
||||||
scanner_active_device = None
|
squelch = int(data.get('squelch', 0))
|
||||||
if scanner_thread and scanner_thread.is_alive():
|
gain = int(data.get('gain', 40))
|
||||||
try:
|
device = int(data.get('device', 0))
|
||||||
scanner_thread.join(timeout=2.0)
|
sdr_type = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||||
except Exception:
|
request_token_raw = data.get('request_token')
|
||||||
pass
|
request_token = int(request_token_raw) if request_token_raw is not None else None
|
||||||
if scanner_power_process and scanner_power_process.poll() is None:
|
bias_t_raw = data.get('bias_t', scanner_config.get('bias_t', False))
|
||||||
try:
|
if isinstance(bias_t_raw, str):
|
||||||
scanner_power_process.terminate()
|
bias_t = bias_t_raw.strip().lower() in {'1', 'true', 'yes', 'on'}
|
||||||
scanner_power_process.wait(timeout=1)
|
else:
|
||||||
except Exception:
|
bias_t = bool(bias_t_raw)
|
||||||
try:
|
except (ValueError, TypeError) as e:
|
||||||
scanner_power_process.kill()
|
return jsonify({
|
||||||
except Exception:
|
'status': 'error',
|
||||||
pass
|
'message': f'Invalid parameter: {e}'
|
||||||
scanner_power_process = None
|
}), 400
|
||||||
try:
|
|
||||||
subprocess.run(['pkill', '-9', 'rtl_power'], capture_output=True, timeout=0.5)
|
if frequency <= 0:
|
||||||
except Exception:
|
return jsonify({
|
||||||
pass
|
'status': 'error',
|
||||||
time.sleep(0.5)
|
'message': 'frequency is required'
|
||||||
|
}), 400
|
||||||
data = request.json or {}
|
|
||||||
|
valid_sdr_types = ['rtlsdr', 'hackrf', 'airspy', 'limesdr', 'sdrplay']
|
||||||
try:
|
if sdr_type not in valid_sdr_types:
|
||||||
frequency = float(data.get('frequency', 0))
|
return jsonify({
|
||||||
modulation = normalize_modulation(data.get('modulation', 'wfm'))
|
'status': 'error',
|
||||||
squelch = int(data.get('squelch', 0))
|
'message': f'Invalid sdr_type. Use: {", ".join(valid_sdr_types)}'
|
||||||
gain = int(data.get('gain', 40))
|
}), 400
|
||||||
device = int(data.get('device', 0))
|
|
||||||
sdr_type = str(data.get('sdr_type', 'rtlsdr')).lower()
|
with audio_start_lock:
|
||||||
bias_t_raw = data.get('bias_t', scanner_config.get('bias_t', False))
|
if request_token is not None:
|
||||||
if isinstance(bias_t_raw, str):
|
if request_token < audio_start_token:
|
||||||
bias_t = bias_t_raw.strip().lower() in {'1', 'true', 'yes', 'on'}
|
return jsonify({
|
||||||
else:
|
'status': 'stale',
|
||||||
bias_t = bool(bias_t_raw)
|
'message': 'Superseded audio start request',
|
||||||
except (ValueError, TypeError) as e:
|
'source': audio_source,
|
||||||
return jsonify({
|
'superseded': True,
|
||||||
'status': 'error',
|
}), 409
|
||||||
'message': f'Invalid parameter: {e}'
|
audio_start_token = request_token
|
||||||
}), 400
|
else:
|
||||||
|
audio_start_token += 1
|
||||||
if frequency <= 0:
|
request_token = audio_start_token
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
# Stop scanner if running
|
||||||
'message': 'frequency is required'
|
if scanner_running:
|
||||||
}), 400
|
scanner_running = False
|
||||||
|
if scanner_active_device is not None:
|
||||||
valid_sdr_types = ['rtlsdr', 'hackrf', 'airspy', 'limesdr', 'sdrplay']
|
app_module.release_sdr_device(scanner_active_device)
|
||||||
if sdr_type not in valid_sdr_types:
|
scanner_active_device = None
|
||||||
return jsonify({
|
if scanner_thread and scanner_thread.is_alive():
|
||||||
'status': 'error',
|
try:
|
||||||
'message': f'Invalid sdr_type. Use: {", ".join(valid_sdr_types)}'
|
scanner_thread.join(timeout=2.0)
|
||||||
}), 400
|
except Exception:
|
||||||
|
pass
|
||||||
# Update config for audio
|
if scanner_power_process and scanner_power_process.poll() is None:
|
||||||
scanner_config['squelch'] = squelch
|
try:
|
||||||
scanner_config['gain'] = gain
|
scanner_power_process.terminate()
|
||||||
scanner_config['device'] = device
|
scanner_power_process.wait(timeout=1)
|
||||||
scanner_config['sdr_type'] = sdr_type
|
except Exception:
|
||||||
scanner_config['bias_t'] = bias_t
|
try:
|
||||||
|
scanner_power_process.kill()
|
||||||
# Preferred path: when waterfall WebSocket is active on the same SDR,
|
except Exception:
|
||||||
# derive monitor audio from that IQ stream instead of spawning rtl_fm.
|
pass
|
||||||
try:
|
scanner_power_process = None
|
||||||
from routes.waterfall_websocket import (
|
try:
|
||||||
get_shared_capture_status,
|
subprocess.run(['pkill', '-9', 'rtl_power'], capture_output=True, timeout=0.5)
|
||||||
start_shared_monitor_from_capture,
|
except Exception:
|
||||||
)
|
pass
|
||||||
|
time.sleep(0.5)
|
||||||
shared = get_shared_capture_status()
|
|
||||||
if shared.get('running') and shared.get('device') == device:
|
# Update config for audio
|
||||||
_stop_audio_stream()
|
scanner_config['squelch'] = squelch
|
||||||
ok, msg = start_shared_monitor_from_capture(
|
scanner_config['gain'] = gain
|
||||||
device=device,
|
scanner_config['device'] = device
|
||||||
frequency_mhz=frequency,
|
scanner_config['sdr_type'] = sdr_type
|
||||||
modulation=modulation,
|
scanner_config['bias_t'] = bias_t
|
||||||
squelch=squelch,
|
|
||||||
)
|
# Preferred path: when waterfall WebSocket is active on the same SDR,
|
||||||
if ok:
|
# derive monitor audio from that IQ stream instead of spawning rtl_fm.
|
||||||
audio_running = True
|
try:
|
||||||
audio_frequency = frequency
|
from routes.waterfall_websocket import (
|
||||||
audio_modulation = modulation
|
get_shared_capture_status,
|
||||||
audio_source = 'waterfall'
|
start_shared_monitor_from_capture,
|
||||||
# Shared monitor uses the waterfall's existing SDR claim.
|
)
|
||||||
if receiver_active_device is not None:
|
|
||||||
app_module.release_sdr_device(receiver_active_device)
|
shared = get_shared_capture_status()
|
||||||
receiver_active_device = None
|
if shared.get('running') and shared.get('device') == device:
|
||||||
return jsonify({
|
_stop_audio_stream()
|
||||||
'status': 'started',
|
ok, msg = start_shared_monitor_from_capture(
|
||||||
'frequency': frequency,
|
device=device,
|
||||||
'modulation': modulation,
|
frequency_mhz=frequency,
|
||||||
'source': 'waterfall',
|
modulation=modulation,
|
||||||
})
|
squelch=squelch,
|
||||||
logger.warning(f"Shared waterfall monitor unavailable: {msg}")
|
)
|
||||||
except Exception as e:
|
if ok:
|
||||||
logger.debug(f"Shared waterfall monitor probe failed: {e}")
|
audio_running = True
|
||||||
|
audio_frequency = frequency
|
||||||
# Stop waterfall if it's using the same SDR (SSE path)
|
audio_modulation = modulation
|
||||||
if waterfall_running and waterfall_active_device == device:
|
audio_source = 'waterfall'
|
||||||
_stop_waterfall_internal()
|
# Shared monitor uses the waterfall's existing SDR claim.
|
||||||
time.sleep(0.2)
|
if receiver_active_device is not None:
|
||||||
|
app_module.release_sdr_device(receiver_active_device)
|
||||||
# Claim device for listening audio. The WebSocket waterfall handler
|
receiver_active_device = None
|
||||||
# may still be tearing down its IQ capture process (thread join +
|
return jsonify({
|
||||||
# safe_terminate can take several seconds), so we retry with back-off
|
'status': 'started',
|
||||||
# to give the USB device time to be fully released.
|
'frequency': frequency,
|
||||||
if receiver_active_device is None or receiver_active_device != device:
|
'modulation': modulation,
|
||||||
if receiver_active_device is not None:
|
'source': 'waterfall',
|
||||||
app_module.release_sdr_device(receiver_active_device)
|
'request_token': request_token,
|
||||||
receiver_active_device = None
|
})
|
||||||
|
logger.warning(f"Shared waterfall monitor unavailable: {msg}")
|
||||||
error = None
|
except Exception as e:
|
||||||
max_claim_attempts = 6
|
logger.debug(f"Shared waterfall monitor probe failed: {e}")
|
||||||
for attempt in range(max_claim_attempts):
|
|
||||||
error = app_module.claim_sdr_device(device, 'receiver')
|
# Stop waterfall if it's using the same SDR (SSE path)
|
||||||
if not error:
|
if waterfall_running and waterfall_active_device == device:
|
||||||
break
|
_stop_waterfall_internal()
|
||||||
if attempt < max_claim_attempts - 1:
|
time.sleep(0.2)
|
||||||
logger.debug(
|
|
||||||
f"Device claim attempt {attempt + 1}/{max_claim_attempts} "
|
# Claim device for listening audio. The WebSocket waterfall handler
|
||||||
f"failed, retrying in 0.5s: {error}"
|
# may still be tearing down its IQ capture process (thread join +
|
||||||
)
|
# safe_terminate can take several seconds), so we retry with back-off
|
||||||
time.sleep(0.5)
|
# to give the USB device time to be fully released.
|
||||||
|
if receiver_active_device is None or receiver_active_device != device:
|
||||||
if error:
|
if receiver_active_device is not None:
|
||||||
return jsonify({
|
app_module.release_sdr_device(receiver_active_device)
|
||||||
'status': 'error',
|
receiver_active_device = None
|
||||||
'error_type': 'DEVICE_BUSY',
|
|
||||||
'message': error
|
error = None
|
||||||
}), 409
|
max_claim_attempts = 6
|
||||||
receiver_active_device = device
|
for attempt in range(max_claim_attempts):
|
||||||
|
error = app_module.claim_sdr_device(device, 'receiver')
|
||||||
_start_audio_stream(frequency, modulation)
|
if not error:
|
||||||
|
break
|
||||||
if audio_running:
|
if attempt < max_claim_attempts - 1:
|
||||||
audio_source = 'process'
|
logger.debug(
|
||||||
return jsonify({
|
f"Device claim attempt {attempt + 1}/{max_claim_attempts} "
|
||||||
'status': 'started',
|
f"failed, retrying in 0.5s: {error}"
|
||||||
'frequency': frequency,
|
)
|
||||||
'modulation': modulation,
|
time.sleep(0.5)
|
||||||
'source': 'process',
|
|
||||||
})
|
if error:
|
||||||
else:
|
return jsonify({
|
||||||
# Avoid leaving a stale device claim after startup failure.
|
'status': 'error',
|
||||||
if receiver_active_device is not None:
|
'error_type': 'DEVICE_BUSY',
|
||||||
app_module.release_sdr_device(receiver_active_device)
|
'message': error
|
||||||
receiver_active_device = None
|
}), 409
|
||||||
|
receiver_active_device = device
|
||||||
start_error = ''
|
|
||||||
for log_path in ('/tmp/rtl_fm_stderr.log', '/tmp/ffmpeg_stderr.log'):
|
_start_audio_stream(frequency, modulation)
|
||||||
try:
|
|
||||||
with open(log_path, 'r') as handle:
|
if audio_running:
|
||||||
content = handle.read().strip()
|
audio_source = 'process'
|
||||||
if content:
|
return jsonify({
|
||||||
start_error = content.splitlines()[-1]
|
'status': 'started',
|
||||||
break
|
'frequency': audio_frequency,
|
||||||
except Exception:
|
'modulation': audio_modulation,
|
||||||
continue
|
'source': 'process',
|
||||||
|
'request_token': request_token,
|
||||||
message = 'Failed to start audio. Check SDR device.'
|
})
|
||||||
if start_error:
|
|
||||||
message = f'Failed to start audio: {start_error}'
|
# Avoid leaving a stale device claim after startup failure.
|
||||||
return jsonify({
|
if receiver_active_device is not None:
|
||||||
'status': 'error',
|
app_module.release_sdr_device(receiver_active_device)
|
||||||
'message': message
|
receiver_active_device = None
|
||||||
}), 500
|
|
||||||
|
start_error = ''
|
||||||
|
for log_path in ('/tmp/rtl_fm_stderr.log', '/tmp/ffmpeg_stderr.log'):
|
||||||
|
try:
|
||||||
|
with open(log_path, 'r') as handle:
|
||||||
|
content = handle.read().strip()
|
||||||
|
if content:
|
||||||
|
start_error = content.splitlines()[-1]
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
message = 'Failed to start audio. Check SDR device.'
|
||||||
|
if start_error:
|
||||||
|
message = f'Failed to start audio: {start_error}'
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': message
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
@receiver_bp.route('/audio/stop', methods=['POST'])
|
@receiver_bp.route('/audio/stop', methods=['POST'])
|
||||||
|
|||||||
@@ -2704,6 +2704,7 @@ const Waterfall = (function () {
|
|||||||
gain,
|
gain,
|
||||||
device,
|
device,
|
||||||
biasT,
|
biasT,
|
||||||
|
requestToken,
|
||||||
}) {
|
}) {
|
||||||
const response = await fetch('/receiver/audio/start', {
|
const response = await fetch('/receiver/audio/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -2716,6 +2717,7 @@ const Waterfall = (function () {
|
|||||||
device: device.deviceIndex,
|
device: device.deviceIndex,
|
||||||
sdr_type: device.sdrType,
|
sdr_type: device.sdrType,
|
||||||
bias_t: biasT,
|
bias_t: biasT,
|
||||||
|
request_token: requestToken,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2812,10 +2814,13 @@ const Waterfall = (function () {
|
|||||||
gain,
|
gain,
|
||||||
device: monitorDevice,
|
device: monitorDevice,
|
||||||
biasT,
|
biasT,
|
||||||
|
requestToken: nonce,
|
||||||
});
|
});
|
||||||
if (nonce !== _audioConnectNonce) return;
|
if (nonce !== _audioConnectNonce) return;
|
||||||
|
|
||||||
const busy = payload?.error_type === 'DEVICE_BUSY' || response.status === 409;
|
const staleStart = payload?.superseded === true || payload?.status === 'stale';
|
||||||
|
if (staleStart) return;
|
||||||
|
const busy = payload?.error_type === 'DEVICE_BUSY' || (response.status === 409 && !staleStart);
|
||||||
if (
|
if (
|
||||||
busy
|
busy
|
||||||
&& _running
|
&& _running
|
||||||
@@ -2834,8 +2839,10 @@ const Waterfall = (function () {
|
|||||||
gain,
|
gain,
|
||||||
device: monitorDevice,
|
device: monitorDevice,
|
||||||
biasT,
|
biasT,
|
||||||
|
requestToken: nonce,
|
||||||
}));
|
}));
|
||||||
if (nonce !== _audioConnectNonce) return;
|
if (nonce !== _audioConnectNonce) return;
|
||||||
|
if (payload?.superseded === true || payload?.status === 'stale') return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok || payload.status !== 'started') {
|
if (!response.ok || payload.status !== 'started') {
|
||||||
|
|||||||
Reference in New Issue
Block a user