Files
intercept/routes/morse.py
Smittix 0bf8341b6c Fix Morse mode HF reception, stop button, and UX guidance
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>
2026-02-26 08:43:51 +00:00

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