mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
Enable direct sampling (-D 2) for RTL-SDR at HF frequencies below 24 MHz so rtl_fm can actually receive CW signals. Add startup health check to detect immediate rtl_fm failures. Push stopped status event from decoder thread on EOF so the frontend auto-resets. Add frequency placeholder and help text. Fix stop button silently swallowing errors. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
276 lines
9.5 KiB
Python
276 lines
9.5 KiB
Python
"""CW/Morse code decoder routes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import queue
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
from typing import Any
|
|
|
|
from flask import Blueprint, Response, jsonify, request
|
|
|
|
import app as app_module
|
|
from utils.event_pipeline import process_event
|
|
from utils.logging import sensor_logger as logger
|
|
from utils.morse import morse_decoder_thread
|
|
from utils.process import register_process, safe_terminate, unregister_process
|
|
from utils.sdr import SDRFactory, SDRType
|
|
from utils.sse import sse_stream_fanout
|
|
from utils.validation import (
|
|
validate_device_index,
|
|
validate_frequency,
|
|
validate_gain,
|
|
validate_ppm,
|
|
)
|
|
|
|
morse_bp = Blueprint('morse', __name__)
|
|
|
|
# Track which device is being used
|
|
morse_active_device: int | None = None
|
|
|
|
|
|
def _validate_tone_freq(value: Any) -> float:
|
|
"""Validate CW tone frequency (300-1200 Hz)."""
|
|
try:
|
|
freq = float(value)
|
|
if not 300 <= freq <= 1200:
|
|
raise ValueError("Tone frequency must be between 300 and 1200 Hz")
|
|
return freq
|
|
except (ValueError, TypeError) as e:
|
|
raise ValueError(f"Invalid tone frequency: {value}") from e
|
|
|
|
|
|
def _validate_wpm(value: Any) -> int:
|
|
"""Validate words per minute (5-50)."""
|
|
try:
|
|
wpm = int(value)
|
|
if not 5 <= wpm <= 50:
|
|
raise ValueError("WPM must be between 5 and 50")
|
|
return wpm
|
|
except (ValueError, TypeError) as e:
|
|
raise ValueError(f"Invalid WPM: {value}") from e
|
|
|
|
|
|
@morse_bp.route('/morse/start', methods=['POST'])
|
|
def start_morse() -> Response:
|
|
global morse_active_device
|
|
|
|
with app_module.morse_lock:
|
|
if app_module.morse_process:
|
|
return jsonify({'status': 'error', 'message': 'Morse decoder already running'}), 409
|
|
|
|
data = request.json or {}
|
|
|
|
# Validate standard SDR inputs
|
|
try:
|
|
freq = validate_frequency(data.get('frequency', '14.060'), min_mhz=0.5, max_mhz=30.0)
|
|
gain = validate_gain(data.get('gain', '0'))
|
|
ppm = validate_ppm(data.get('ppm', '0'))
|
|
device = validate_device_index(data.get('device', '0'))
|
|
except ValueError as e:
|
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
|
|
|
# Validate Morse-specific inputs
|
|
try:
|
|
tone_freq = _validate_tone_freq(data.get('tone_freq', '700'))
|
|
except ValueError as e:
|
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
|
|
|
try:
|
|
wpm = _validate_wpm(data.get('wpm', '15'))
|
|
except ValueError as e:
|
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
|
|
|
# Claim SDR device
|
|
device_int = int(device)
|
|
error = app_module.claim_sdr_device(device_int, 'morse')
|
|
if error:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'error_type': 'DEVICE_BUSY',
|
|
'message': error,
|
|
}), 409
|
|
morse_active_device = device_int
|
|
|
|
# Clear queue
|
|
while not app_module.morse_queue.empty():
|
|
try:
|
|
app_module.morse_queue.get_nowait()
|
|
except queue.Empty:
|
|
break
|
|
|
|
# Build rtl_fm USB demodulation command
|
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
|
try:
|
|
sdr_type = SDRType(sdr_type_str)
|
|
except ValueError:
|
|
sdr_type = SDRType.RTL_SDR
|
|
|
|
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
|
builder = SDRFactory.get_builder(sdr_device.sdr_type)
|
|
|
|
sample_rate = 8000
|
|
bias_t = data.get('bias_t', False)
|
|
|
|
# RTL-SDR needs direct sampling mode for HF frequencies below 24 MHz
|
|
direct_sampling = 2 if freq < 24.0 else None
|
|
|
|
rtl_cmd = builder.build_fm_demod_command(
|
|
device=sdr_device,
|
|
frequency_mhz=freq,
|
|
sample_rate=sample_rate,
|
|
gain=float(gain) if gain and gain != '0' else None,
|
|
ppm=int(ppm) if ppm and ppm != '0' else None,
|
|
modulation='usb',
|
|
bias_t=bias_t,
|
|
direct_sampling=direct_sampling,
|
|
)
|
|
|
|
full_cmd = ' '.join(rtl_cmd)
|
|
logger.info(f"Morse decoder running: {full_cmd}")
|
|
|
|
try:
|
|
rtl_process = subprocess.Popen(
|
|
rtl_cmd,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
)
|
|
register_process(rtl_process)
|
|
|
|
# Detect immediate startup failure (e.g. device busy, no device)
|
|
time.sleep(0.35)
|
|
if rtl_process.poll() is not None:
|
|
stderr_text = ''
|
|
try:
|
|
if rtl_process.stderr:
|
|
stderr_text = rtl_process.stderr.read().decode(
|
|
'utf-8', errors='replace'
|
|
).strip()
|
|
except Exception:
|
|
stderr_text = ''
|
|
msg = stderr_text or f'rtl_fm exited immediately (code {rtl_process.returncode})'
|
|
logger.error(f"Morse rtl_fm startup failed: {msg}")
|
|
unregister_process(rtl_process)
|
|
if morse_active_device is not None:
|
|
app_module.release_sdr_device(morse_active_device)
|
|
morse_active_device = None
|
|
return jsonify({'status': 'error', 'message': msg}), 500
|
|
|
|
# Monitor rtl_fm stderr
|
|
def monitor_stderr():
|
|
for line in rtl_process.stderr:
|
|
err_text = line.decode('utf-8', errors='replace').strip()
|
|
if err_text:
|
|
logger.debug(f"[rtl_fm/morse] {err_text}")
|
|
|
|
stderr_thread = threading.Thread(target=monitor_stderr)
|
|
stderr_thread.daemon = True
|
|
stderr_thread.start()
|
|
|
|
# Start Morse decoder thread
|
|
stop_event = threading.Event()
|
|
decoder_thread = threading.Thread(
|
|
target=morse_decoder_thread,
|
|
args=(
|
|
rtl_process.stdout,
|
|
app_module.morse_queue,
|
|
stop_event,
|
|
sample_rate,
|
|
tone_freq,
|
|
wpm,
|
|
),
|
|
)
|
|
decoder_thread.daemon = True
|
|
decoder_thread.start()
|
|
|
|
app_module.morse_process = rtl_process
|
|
app_module.morse_process._stop_decoder = stop_event
|
|
app_module.morse_process._decoder_thread = decoder_thread
|
|
|
|
app_module.morse_queue.put({'type': 'status', 'status': 'started'})
|
|
|
|
return jsonify({
|
|
'status': 'started',
|
|
'command': full_cmd,
|
|
'tone_freq': tone_freq,
|
|
'wpm': wpm,
|
|
})
|
|
|
|
except FileNotFoundError as e:
|
|
if morse_active_device is not None:
|
|
app_module.release_sdr_device(morse_active_device)
|
|
morse_active_device = None
|
|
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'}), 400
|
|
|
|
except Exception as e:
|
|
# Clean up rtl_fm if it was started
|
|
try:
|
|
rtl_process.terminate()
|
|
rtl_process.wait(timeout=2)
|
|
except Exception:
|
|
with contextlib.suppress(Exception):
|
|
rtl_process.kill()
|
|
unregister_process(rtl_process)
|
|
if morse_active_device is not None:
|
|
app_module.release_sdr_device(morse_active_device)
|
|
morse_active_device = None
|
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
|
|
|
|
|
@morse_bp.route('/morse/stop', methods=['POST'])
|
|
def stop_morse() -> Response:
|
|
global morse_active_device
|
|
|
|
with app_module.morse_lock:
|
|
if app_module.morse_process:
|
|
# Signal decoder thread to stop
|
|
stop_event = getattr(app_module.morse_process, '_stop_decoder', None)
|
|
if stop_event:
|
|
stop_event.set()
|
|
|
|
safe_terminate(app_module.morse_process)
|
|
unregister_process(app_module.morse_process)
|
|
app_module.morse_process = None
|
|
|
|
if morse_active_device is not None:
|
|
app_module.release_sdr_device(morse_active_device)
|
|
morse_active_device = None
|
|
|
|
app_module.morse_queue.put({'type': 'status', 'status': 'stopped'})
|
|
return jsonify({'status': 'stopped'})
|
|
|
|
return jsonify({'status': 'not_running'})
|
|
|
|
|
|
@morse_bp.route('/morse/status')
|
|
def morse_status() -> Response:
|
|
with app_module.morse_lock:
|
|
running = (
|
|
app_module.morse_process is not None
|
|
and app_module.morse_process.poll() is None
|
|
)
|
|
return jsonify({'running': running})
|
|
|
|
|
|
@morse_bp.route('/morse/stream')
|
|
def morse_stream() -> Response:
|
|
def _on_msg(msg: dict[str, Any]) -> None:
|
|
process_event('morse', msg, msg.get('type'))
|
|
|
|
response = Response(
|
|
sse_stream_fanout(
|
|
source_queue=app_module.morse_queue,
|
|
channel_key='morse',
|
|
timeout=1.0,
|
|
keepalive_interval=30.0,
|
|
on_message=_on_msg,
|
|
),
|
|
mimetype='text/event-stream',
|
|
)
|
|
response.headers['Cache-Control'] = 'no-cache'
|
|
response.headers['X-Accel-Buffering'] = 'no'
|
|
response.headers['Connection'] = 'keep-alive'
|
|
return response
|