mirror of
https://github.com/smittix/intercept.git
synced 2026-07-25 01:08:11 -07:00
Add DMR digital voice, WebSDR, and listening post enhancements
- DMR/P25 digital voice decoder mode with DSD-FME integration - WebSDR mode with KiwiSDR audio proxy and websocket-client support - Listening post waterfall/spectrogram visualization and audio streaming - Dockerfile updates for mbelib and DSD-FME build dependencies - New tests for DMR, WebSDR, KiwiSDR, waterfall, and signal guess API - Chart.js date adapter for time-scale axes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+352
@@ -0,0 +1,352 @@
|
||||
"""DMR / P25 / Digital Voice decoding routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Generator, Optional
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import format_sse
|
||||
from utils.constants import (
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
QUEUE_MAX_SIZE,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.dmr')
|
||||
|
||||
dmr_bp = Blueprint('dmr', __name__, url_prefix='/dmr')
|
||||
|
||||
# ============================================
|
||||
# GLOBAL STATE
|
||||
# ============================================
|
||||
|
||||
dmr_rtl_process: Optional[subprocess.Popen] = None
|
||||
dmr_dsd_process: Optional[subprocess.Popen] = None
|
||||
dmr_thread: Optional[threading.Thread] = None
|
||||
dmr_running = False
|
||||
dmr_lock = threading.Lock()
|
||||
dmr_queue: queue.Queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
dmr_active_device: Optional[int] = None
|
||||
|
||||
VALID_PROTOCOLS = ['auto', 'dmr', 'p25', 'nxdn', 'dstar', 'provoice']
|
||||
PROTOCOL_FLAGS = {
|
||||
'auto': [],
|
||||
'dmr': ['-fd'],
|
||||
'p25': ['-fp'],
|
||||
'nxdn': ['-fn'],
|
||||
'dstar': ['-fi'],
|
||||
'provoice': ['-fv'],
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# HELPERS
|
||||
# ============================================
|
||||
|
||||
|
||||
def find_dsd() -> str | None:
|
||||
"""Find DSD (Digital Speech Decoder) binary."""
|
||||
return shutil.which('dsd')
|
||||
|
||||
|
||||
def find_rtl_fm() -> str | None:
|
||||
"""Find rtl_fm binary."""
|
||||
return shutil.which('rtl_fm')
|
||||
|
||||
|
||||
def parse_dsd_output(line: str) -> dict | None:
|
||||
"""Parse a line of DSD stderr output into a structured event."""
|
||||
line = line.strip()
|
||||
if not line:
|
||||
return None
|
||||
|
||||
# Sync detection: "Sync: +DMR (data)" or "Sync: +P25 Phase 1"
|
||||
sync_match = re.match(r'Sync:\s*\+?(\S+.*)', line)
|
||||
if sync_match:
|
||||
return {
|
||||
'type': 'sync',
|
||||
'protocol': sync_match.group(1).strip(),
|
||||
'timestamp': datetime.now().strftime('%H:%M:%S'),
|
||||
}
|
||||
|
||||
# Talkgroup and Source: "TG: 12345 Src: 67890"
|
||||
tg_match = re.match(r'.*TG:\s*(\d+)\s+Src:\s*(\d+)', line)
|
||||
if tg_match:
|
||||
return {
|
||||
'type': 'call',
|
||||
'talkgroup': int(tg_match.group(1)),
|
||||
'source_id': int(tg_match.group(2)),
|
||||
'timestamp': datetime.now().strftime('%H:%M:%S'),
|
||||
}
|
||||
|
||||
# Slot info: "Slot 1" or "Slot 2"
|
||||
slot_match = re.match(r'.*Slot\s*(\d+)', line)
|
||||
if slot_match:
|
||||
return {
|
||||
'type': 'slot',
|
||||
'slot': int(slot_match.group(1)),
|
||||
'timestamp': datetime.now().strftime('%H:%M:%S'),
|
||||
}
|
||||
|
||||
# DMR voice frame
|
||||
if 'Voice' in line or 'voice' in line:
|
||||
return {
|
||||
'type': 'voice',
|
||||
'detail': line,
|
||||
'timestamp': datetime.now().strftime('%H:%M:%S'),
|
||||
}
|
||||
|
||||
# P25 NAC (Network Access Code)
|
||||
nac_match = re.match(r'.*NAC:\s*(\w+)', line)
|
||||
if nac_match:
|
||||
return {
|
||||
'type': 'nac',
|
||||
'nac': nac_match.group(1),
|
||||
'timestamp': datetime.now().strftime('%H:%M:%S'),
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Popen):
|
||||
"""Read DSD stderr output and push parsed events to the queue."""
|
||||
global dmr_running
|
||||
|
||||
try:
|
||||
dmr_queue.put_nowait({'type': 'status', 'text': 'started'})
|
||||
|
||||
while dmr_running:
|
||||
if dsd_process.poll() is not None:
|
||||
break
|
||||
|
||||
line = dsd_process.stderr.readline()
|
||||
if not line:
|
||||
if dsd_process.poll() is not None:
|
||||
break
|
||||
continue
|
||||
|
||||
text = line.decode('utf-8', errors='replace').strip()
|
||||
if not text:
|
||||
continue
|
||||
|
||||
parsed = parse_dsd_output(text)
|
||||
if parsed:
|
||||
try:
|
||||
dmr_queue.put_nowait(parsed)
|
||||
except queue.Full:
|
||||
try:
|
||||
dmr_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
try:
|
||||
dmr_queue.put_nowait(parsed)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"DSD stream error: {e}")
|
||||
finally:
|
||||
dmr_running = False
|
||||
try:
|
||||
dmr_queue.put_nowait({'type': 'status', 'text': 'stopped'})
|
||||
except queue.Full:
|
||||
pass
|
||||
logger.info("DSD stream thread stopped")
|
||||
|
||||
|
||||
# ============================================
|
||||
# API ENDPOINTS
|
||||
# ============================================
|
||||
|
||||
@dmr_bp.route('/tools')
|
||||
def check_tools() -> Response:
|
||||
"""Check for required tools."""
|
||||
dsd = find_dsd()
|
||||
rtl_fm = find_rtl_fm()
|
||||
return jsonify({
|
||||
'dsd': dsd is not None,
|
||||
'rtl_fm': rtl_fm is not None,
|
||||
'available': dsd is not None and rtl_fm is not None,
|
||||
'protocols': VALID_PROTOCOLS,
|
||||
})
|
||||
|
||||
|
||||
@dmr_bp.route('/start', methods=['POST'])
|
||||
def start_dmr() -> Response:
|
||||
"""Start digital voice decoding."""
|
||||
global dmr_rtl_process, dmr_dsd_process, dmr_thread, dmr_running, dmr_active_device
|
||||
|
||||
with dmr_lock:
|
||||
if dmr_running:
|
||||
return jsonify({'status': 'error', 'message': 'Already running'}), 409
|
||||
|
||||
dsd_path = find_dsd()
|
||||
if not dsd_path:
|
||||
return jsonify({'status': 'error', 'message': 'dsd not found. Install Digital Speech Decoder.'}), 503
|
||||
|
||||
rtl_fm_path = find_rtl_fm()
|
||||
if not rtl_fm_path:
|
||||
return jsonify({'status': 'error', 'message': 'rtl_fm not found. Install rtl-sdr tools.'}), 503
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
try:
|
||||
frequency = float(data.get('frequency', 462.5625))
|
||||
gain = int(data.get('gain', 40))
|
||||
device = int(data.get('device', 0))
|
||||
protocol = str(data.get('protocol', 'auto')).lower()
|
||||
except (ValueError, TypeError) as e:
|
||||
return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400
|
||||
|
||||
if frequency <= 0:
|
||||
return jsonify({'status': 'error', 'message': 'Frequency must be positive'}), 400
|
||||
|
||||
if protocol not in VALID_PROTOCOLS:
|
||||
return jsonify({'status': 'error', 'message': f'Invalid protocol. Use: {", ".join(VALID_PROTOCOLS)}'}), 400
|
||||
|
||||
# Clear stale queue
|
||||
try:
|
||||
while True:
|
||||
dmr_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
# Claim SDR device
|
||||
error = app_module.claim_sdr_device(device, 'dmr')
|
||||
if error:
|
||||
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
|
||||
|
||||
dmr_active_device = device
|
||||
|
||||
freq_hz = int(frequency * 1e6)
|
||||
|
||||
# Build rtl_fm command (48kHz sample rate for DSD)
|
||||
rtl_cmd = [
|
||||
rtl_fm_path,
|
||||
'-M', 'fm',
|
||||
'-f', str(freq_hz),
|
||||
'-s', '48000',
|
||||
'-g', str(gain),
|
||||
'-d', str(device),
|
||||
'-l', '1', # squelch level
|
||||
]
|
||||
|
||||
# Build DSD command
|
||||
dsd_cmd = [dsd_path, '-i', '-']
|
||||
dsd_cmd.extend(PROTOCOL_FLAGS.get(protocol, []))
|
||||
|
||||
try:
|
||||
dmr_rtl_process = subprocess.Popen(
|
||||
rtl_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
dmr_dsd_process = subprocess.Popen(
|
||||
dsd_cmd,
|
||||
stdin=dmr_rtl_process.stdout,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
|
||||
# Allow rtl_fm to send directly to dsd
|
||||
dmr_rtl_process.stdout.close()
|
||||
|
||||
time.sleep(0.3)
|
||||
|
||||
if dmr_rtl_process.poll() is not None or dmr_dsd_process.poll() is not None:
|
||||
# Process died
|
||||
if dmr_active_device is not None:
|
||||
app_module.release_sdr_device(dmr_active_device)
|
||||
dmr_active_device = None
|
||||
return jsonify({'status': 'error', 'message': 'Failed to start DSD pipeline'}), 500
|
||||
|
||||
dmr_running = True
|
||||
dmr_thread = threading.Thread(
|
||||
target=stream_dsd_output,
|
||||
args=(dmr_rtl_process, dmr_dsd_process),
|
||||
daemon=True,
|
||||
)
|
||||
dmr_thread.start()
|
||||
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
'protocol': protocol,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start DMR: {e}")
|
||||
if dmr_active_device is not None:
|
||||
app_module.release_sdr_device(dmr_active_device)
|
||||
dmr_active_device = None
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@dmr_bp.route('/stop', methods=['POST'])
|
||||
def stop_dmr() -> Response:
|
||||
"""Stop digital voice decoding."""
|
||||
global dmr_rtl_process, dmr_dsd_process, dmr_running, dmr_active_device
|
||||
|
||||
dmr_running = False
|
||||
|
||||
for proc in [dmr_dsd_process, dmr_rtl_process]:
|
||||
if proc and proc.poll() is None:
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
dmr_rtl_process = None
|
||||
dmr_dsd_process = None
|
||||
|
||||
if dmr_active_device is not None:
|
||||
app_module.release_sdr_device(dmr_active_device)
|
||||
dmr_active_device = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@dmr_bp.route('/status')
|
||||
def dmr_status() -> Response:
|
||||
"""Get DMR decoder status."""
|
||||
return jsonify({
|
||||
'running': dmr_running,
|
||||
'device': dmr_active_device,
|
||||
})
|
||||
|
||||
|
||||
@dmr_bp.route('/stream')
|
||||
def stream_dmr() -> Response:
|
||||
"""SSE stream for DMR decoder events."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
while True:
|
||||
try:
|
||||
msg = dmr_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
return response
|
||||
+433
-164
@@ -96,27 +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')
|
||||
|
||||
|
||||
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:
|
||||
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,
|
||||
@@ -734,106 +734,106 @@ def _start_audio_stream(frequency: float, modulation: str):
|
||||
'pipe:1'
|
||||
]
|
||||
|
||||
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
|
||||
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:
|
||||
@@ -858,38 +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 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
|
||||
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']:
|
||||
@@ -962,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'] = normalize_modulation(data.get('modulation', 'wfm'))
|
||||
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))
|
||||
@@ -1144,15 +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:
|
||||
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 '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)}")
|
||||
@@ -1274,7 +1274,7 @@ def start_audio() -> Response:
|
||||
|
||||
try:
|
||||
frequency = float(data.get('frequency', 0))
|
||||
modulation = normalize_modulation(data.get('modulation', 'wfm'))
|
||||
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))
|
||||
@@ -1467,3 +1467,272 @@ def stream_audio() -> Response:
|
||||
'Transfer-Encoding': 'chunked',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ============================================
|
||||
# SIGNAL IDENTIFICATION ENDPOINT
|
||||
# ============================================
|
||||
|
||||
@listening_post_bp.route('/signal/guess', methods=['POST'])
|
||||
def guess_signal() -> Response:
|
||||
"""Identify a signal based on frequency, modulation, and other parameters."""
|
||||
data = request.json or {}
|
||||
|
||||
freq_mhz = data.get('frequency_mhz')
|
||||
if freq_mhz is None:
|
||||
return jsonify({'status': 'error', 'message': 'frequency_mhz is required'}), 400
|
||||
|
||||
try:
|
||||
freq_mhz = float(freq_mhz)
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid frequency_mhz'}), 400
|
||||
|
||||
if freq_mhz <= 0:
|
||||
return jsonify({'status': 'error', 'message': 'frequency_mhz must be positive'}), 400
|
||||
|
||||
frequency_hz = int(freq_mhz * 1e6)
|
||||
|
||||
modulation = data.get('modulation')
|
||||
bandwidth_hz = data.get('bandwidth_hz')
|
||||
if bandwidth_hz is not None:
|
||||
try:
|
||||
bandwidth_hz = int(bandwidth_hz)
|
||||
except (ValueError, TypeError):
|
||||
bandwidth_hz = None
|
||||
|
||||
region = data.get('region', 'UK/EU')
|
||||
|
||||
try:
|
||||
from utils.signal_guess import guess_signal_type_dict
|
||||
result = guess_signal_type_dict(
|
||||
frequency_hz=frequency_hz,
|
||||
modulation=modulation,
|
||||
bandwidth_hz=bandwidth_hz,
|
||||
region=region,
|
||||
)
|
||||
return jsonify({'status': 'ok', **result})
|
||||
except Exception as e:
|
||||
logger.error(f"Signal guess error: {e}")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
# ============================================
|
||||
# WATERFALL / SPECTROGRAM ENDPOINTS
|
||||
# ============================================
|
||||
|
||||
waterfall_process: Optional[subprocess.Popen] = None
|
||||
waterfall_thread: Optional[threading.Thread] = None
|
||||
waterfall_running = False
|
||||
waterfall_lock = threading.Lock()
|
||||
waterfall_queue: queue.Queue = queue.Queue(maxsize=200)
|
||||
waterfall_active_device: Optional[int] = None
|
||||
waterfall_config = {
|
||||
'start_freq': 88.0,
|
||||
'end_freq': 108.0,
|
||||
'bin_size': 10000,
|
||||
'gain': 40,
|
||||
'device': 0,
|
||||
}
|
||||
|
||||
|
||||
def _waterfall_loop():
|
||||
"""Continuous rtl_power sweep loop emitting waterfall data."""
|
||||
global waterfall_running, waterfall_process
|
||||
|
||||
rtl_power_path = find_rtl_power()
|
||||
if not rtl_power_path:
|
||||
logger.error("rtl_power not found for waterfall")
|
||||
waterfall_running = False
|
||||
return
|
||||
|
||||
try:
|
||||
while waterfall_running:
|
||||
start_hz = int(waterfall_config['start_freq'] * 1e6)
|
||||
end_hz = int(waterfall_config['end_freq'] * 1e6)
|
||||
bin_hz = int(waterfall_config['bin_size'])
|
||||
gain = waterfall_config['gain']
|
||||
device = waterfall_config['device']
|
||||
|
||||
cmd = [
|
||||
rtl_power_path,
|
||||
'-f', f'{start_hz}:{end_hz}:{bin_hz}',
|
||||
'-i', '0.5',
|
||||
'-1',
|
||||
'-g', str(gain),
|
||||
'-d', str(device),
|
||||
]
|
||||
|
||||
try:
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
||||
waterfall_process = proc
|
||||
stdout, _ = proc.communicate(timeout=15)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
stdout = b''
|
||||
finally:
|
||||
waterfall_process = None
|
||||
|
||||
if not waterfall_running:
|
||||
break
|
||||
|
||||
if not stdout:
|
||||
time.sleep(0.2)
|
||||
continue
|
||||
|
||||
# Parse rtl_power CSV output
|
||||
all_bins = []
|
||||
sweep_start_hz = start_hz
|
||||
sweep_end_hz = end_hz
|
||||
|
||||
for line in stdout.decode(errors='ignore').splitlines():
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
parts = [p.strip() for p in line.split(',')]
|
||||
start_idx = None
|
||||
for i, tok in enumerate(parts):
|
||||
try:
|
||||
val = float(tok)
|
||||
except ValueError:
|
||||
continue
|
||||
if val > 1e5:
|
||||
start_idx = i
|
||||
break
|
||||
if start_idx is None or len(parts) < start_idx + 4:
|
||||
continue
|
||||
try:
|
||||
seg_start = float(parts[start_idx])
|
||||
seg_end = float(parts[start_idx + 1])
|
||||
seg_bin = float(parts[start_idx + 2])
|
||||
raw_values = []
|
||||
for v in parts[start_idx + 3:]:
|
||||
try:
|
||||
raw_values.append(float(v))
|
||||
except ValueError:
|
||||
continue
|
||||
if raw_values and raw_values[0] >= 0 and any(val < 0 for val in raw_values[1:]):
|
||||
raw_values = raw_values[1:]
|
||||
all_bins.extend(raw_values)
|
||||
sweep_start_hz = min(sweep_start_hz, seg_start)
|
||||
sweep_end_hz = max(sweep_end_hz, seg_end)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if all_bins:
|
||||
msg = {
|
||||
'type': 'waterfall_sweep',
|
||||
'start_freq': sweep_start_hz / 1e6,
|
||||
'end_freq': sweep_end_hz / 1e6,
|
||||
'bins': all_bins,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
}
|
||||
try:
|
||||
waterfall_queue.put_nowait(msg)
|
||||
except queue.Full:
|
||||
try:
|
||||
waterfall_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
try:
|
||||
waterfall_queue.put_nowait(msg)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Waterfall loop error: {e}")
|
||||
finally:
|
||||
waterfall_running = False
|
||||
logger.info("Waterfall loop stopped")
|
||||
|
||||
|
||||
@listening_post_bp.route('/waterfall/start', methods=['POST'])
|
||||
def start_waterfall() -> Response:
|
||||
"""Start the waterfall/spectrogram display."""
|
||||
global waterfall_thread, waterfall_running, waterfall_config, waterfall_active_device
|
||||
|
||||
with waterfall_lock:
|
||||
if waterfall_running:
|
||||
return jsonify({'status': 'error', 'message': 'Waterfall already running'}), 409
|
||||
|
||||
if not find_rtl_power():
|
||||
return jsonify({'status': 'error', 'message': 'rtl_power not found'}), 503
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
try:
|
||||
waterfall_config['start_freq'] = float(data.get('start_freq', 88.0))
|
||||
waterfall_config['end_freq'] = float(data.get('end_freq', 108.0))
|
||||
waterfall_config['bin_size'] = int(data.get('bin_size', 10000))
|
||||
waterfall_config['gain'] = int(data.get('gain', 40))
|
||||
waterfall_config['device'] = int(data.get('device', 0))
|
||||
except (ValueError, TypeError) as e:
|
||||
return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400
|
||||
|
||||
if waterfall_config['start_freq'] >= waterfall_config['end_freq']:
|
||||
return jsonify({'status': 'error', 'message': 'start_freq must be less than end_freq'}), 400
|
||||
|
||||
# Clear stale queue
|
||||
try:
|
||||
while True:
|
||||
waterfall_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
# Claim SDR device
|
||||
error = app_module.claim_sdr_device(waterfall_config['device'], 'waterfall')
|
||||
if error:
|
||||
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
|
||||
|
||||
waterfall_active_device = waterfall_config['device']
|
||||
waterfall_running = True
|
||||
waterfall_thread = threading.Thread(target=_waterfall_loop, daemon=True)
|
||||
waterfall_thread.start()
|
||||
|
||||
return jsonify({'status': 'started', 'config': waterfall_config})
|
||||
|
||||
|
||||
@listening_post_bp.route('/waterfall/stop', methods=['POST'])
|
||||
def stop_waterfall() -> Response:
|
||||
"""Stop the waterfall display."""
|
||||
global waterfall_running, waterfall_process, waterfall_active_device
|
||||
|
||||
waterfall_running = False
|
||||
if waterfall_process and waterfall_process.poll() is None:
|
||||
try:
|
||||
waterfall_process.terminate()
|
||||
waterfall_process.wait(timeout=1)
|
||||
except Exception:
|
||||
try:
|
||||
waterfall_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
waterfall_process = None
|
||||
|
||||
if waterfall_active_device is not None:
|
||||
app_module.release_sdr_device(waterfall_active_device)
|
||||
waterfall_active_device = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@listening_post_bp.route('/waterfall/stream')
|
||||
def stream_waterfall() -> Response:
|
||||
"""SSE stream for waterfall data."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
while True:
|
||||
try:
|
||||
msg = waterfall_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
return response
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
"""HF/Shortwave WebSDR Integration - KiwiSDR network access."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import queue
|
||||
import re
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from flask import Blueprint, Flask, jsonify, request, Response
|
||||
|
||||
try:
|
||||
from flask_sock import Sock
|
||||
WEBSOCKET_AVAILABLE = True
|
||||
except ImportError:
|
||||
WEBSOCKET_AVAILABLE = False
|
||||
|
||||
from utils.kiwisdr import KiwiSDRClient, KIWI_SAMPLE_RATE, VALID_MODES, parse_host_port
|
||||
from utils.logging import get_logger
|
||||
|
||||
logger = get_logger('intercept.websdr')
|
||||
|
||||
websdr_bp = Blueprint('websdr', __name__, url_prefix='/websdr')
|
||||
|
||||
# ============================================
|
||||
# RECEIVER CACHE
|
||||
# ============================================
|
||||
|
||||
_receiver_cache: list[dict] = []
|
||||
_cache_lock = threading.Lock()
|
||||
_cache_timestamp: float = 0
|
||||
CACHE_TTL = 3600 # 1 hour
|
||||
|
||||
|
||||
def _parse_gps_coord(coord_str: str) -> Optional[float]:
|
||||
"""Parse a GPS coordinate string like '51.5074' or '(-33.87)' into a float."""
|
||||
if not coord_str:
|
||||
return None
|
||||
# Remove parentheses and whitespace
|
||||
cleaned = coord_str.strip().strip('()').strip()
|
||||
try:
|
||||
return float(cleaned)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Calculate distance in km between two GPS coordinates."""
|
||||
R = 6371 # Earth radius in km
|
||||
dlat = math.radians(lat2 - lat1)
|
||||
dlon = math.radians(lon2 - lon1)
|
||||
a = (math.sin(dlat / 2) ** 2 +
|
||||
math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) *
|
||||
math.sin(dlon / 2) ** 2)
|
||||
c = 2 * math.asin(math.sqrt(a))
|
||||
return R * c
|
||||
|
||||
|
||||
KIWI_DATA_URLS = [
|
||||
'https://rx.skywavelinux.com/kiwisdr_com.js',
|
||||
'http://rx.linkfanel.net/kiwisdr_com.js',
|
||||
]
|
||||
|
||||
|
||||
def _fetch_kiwi_receivers() -> list[dict]:
|
||||
"""Fetch the KiwiSDR receiver list from the public directory."""
|
||||
import urllib.request
|
||||
import json
|
||||
|
||||
receivers = []
|
||||
raw = None
|
||||
|
||||
# Try each data source until one works
|
||||
for data_url in KIWI_DATA_URLS:
|
||||
try:
|
||||
req = urllib.request.Request(data_url, headers={
|
||||
'User-Agent': 'INTERCEPT-SIGINT/1.0',
|
||||
})
|
||||
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||
raw = resp.read().decode('utf-8', errors='replace')
|
||||
if raw and len(raw) > 100:
|
||||
logger.info(f"Fetched KiwiSDR data from {data_url}")
|
||||
break
|
||||
raw = None
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch from {data_url}: {e}")
|
||||
continue
|
||||
|
||||
if not raw:
|
||||
logger.error("All KiwiSDR data sources failed")
|
||||
return receivers
|
||||
|
||||
# The JS file contains: var kiwisdr_com = [ {...}, {...}, ... ];
|
||||
# Extract the JSON array
|
||||
match = re.search(r'var\s+kiwisdr_com\s*=\s*(\[.*\])\s*;?', raw, re.DOTALL)
|
||||
if not match:
|
||||
# Try bare array
|
||||
match = re.search(r'(\[\s*\{.*\}\s*\])', raw, re.DOTALL)
|
||||
if not match:
|
||||
logger.warning("Could not find receiver array in KiwiSDR data")
|
||||
return receivers
|
||||
|
||||
arr_str = match.group(1)
|
||||
|
||||
# Parse JSON
|
||||
try:
|
||||
raw_list = json.loads(arr_str)
|
||||
except json.JSONDecodeError:
|
||||
# Fix common JS → JSON issues (trailing commas)
|
||||
fixed = re.sub(r',\s*}', '}', arr_str)
|
||||
fixed = re.sub(r',\s*]', ']', fixed)
|
||||
try:
|
||||
raw_list = json.loads(fixed)
|
||||
except json.JSONDecodeError:
|
||||
logger.error("Failed to parse KiwiSDR JSON")
|
||||
return receivers
|
||||
|
||||
for entry in raw_list:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
|
||||
# Skip offline receivers
|
||||
if entry.get('offline') == 'yes' or entry.get('status') != 'active':
|
||||
continue
|
||||
|
||||
name = entry.get('name', 'Unknown')
|
||||
url = entry.get('url', '')
|
||||
gps = entry.get('gps', '')
|
||||
antenna = entry.get('antenna', '')
|
||||
location = entry.get('loc', '')
|
||||
|
||||
# Parse users (strings in actual data)
|
||||
try:
|
||||
users = int(entry.get('users', 0))
|
||||
except (ValueError, TypeError):
|
||||
users = 0
|
||||
try:
|
||||
users_max = int(entry.get('users_max', 4))
|
||||
except (ValueError, TypeError):
|
||||
users_max = 4
|
||||
|
||||
# Parse bands field: "0-30000000" (Hz) → freq_lo/freq_hi in kHz
|
||||
bands_str = entry.get('bands', '0-30000000')
|
||||
freq_lo = 0
|
||||
freq_hi = 30000
|
||||
if bands_str and '-' in str(bands_str):
|
||||
try:
|
||||
parts = str(bands_str).split('-')
|
||||
freq_lo = int(parts[0]) / 1000 # Hz to kHz
|
||||
freq_hi = int(parts[1]) / 1000 # Hz to kHz
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
# Parse GPS: "(51.317266, -2.950479)" format
|
||||
lat, lon = None, None
|
||||
if gps:
|
||||
parts = str(gps).replace('(', '').replace(')', '').split(',')
|
||||
if len(parts) >= 2:
|
||||
lat = _parse_gps_coord(parts[0])
|
||||
lon = _parse_gps_coord(parts[1])
|
||||
|
||||
if not url:
|
||||
continue
|
||||
|
||||
# Ensure URL has protocol
|
||||
if not url.startswith('http'):
|
||||
url = 'http://' + url
|
||||
|
||||
receivers.append({
|
||||
'name': name,
|
||||
'url': url.rstrip('/'),
|
||||
'lat': lat,
|
||||
'lon': lon,
|
||||
'location': location,
|
||||
'users': users,
|
||||
'users_max': users_max,
|
||||
'antenna': antenna,
|
||||
'bands': bands_str,
|
||||
'freq_lo': freq_lo,
|
||||
'freq_hi': freq_hi,
|
||||
'available': users < users_max,
|
||||
})
|
||||
|
||||
return receivers
|
||||
|
||||
|
||||
def get_receivers(force_refresh: bool = False) -> list[dict]:
|
||||
"""Get cached receiver list, refreshing if stale."""
|
||||
global _receiver_cache, _cache_timestamp
|
||||
|
||||
with _cache_lock:
|
||||
now = time.time()
|
||||
if force_refresh or not _receiver_cache or (now - _cache_timestamp) > CACHE_TTL:
|
||||
logger.info("Refreshing KiwiSDR receiver list...")
|
||||
_receiver_cache = _fetch_kiwi_receivers()
|
||||
_cache_timestamp = now
|
||||
logger.info(f"Loaded {len(_receiver_cache)} KiwiSDR receivers")
|
||||
|
||||
return _receiver_cache
|
||||
|
||||
|
||||
# ============================================
|
||||
# API ENDPOINTS
|
||||
# ============================================
|
||||
|
||||
@websdr_bp.route('/receivers')
|
||||
def list_receivers() -> Response:
|
||||
"""List KiwiSDR receivers, with optional filters."""
|
||||
freq_khz = request.args.get('freq_khz', type=float)
|
||||
available = request.args.get('available', type=str)
|
||||
refresh = request.args.get('refresh', type=str)
|
||||
|
||||
receivers = get_receivers(force_refresh=(refresh == 'true'))
|
||||
|
||||
filtered = receivers
|
||||
if available == 'true':
|
||||
filtered = [r for r in filtered if r.get('available', True)]
|
||||
|
||||
if freq_khz is not None:
|
||||
filtered = [
|
||||
r for r in filtered
|
||||
if r.get('freq_lo', 0) <= freq_khz <= r.get('freq_hi', 30000)
|
||||
]
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'receivers': filtered[:100],
|
||||
'total': len(filtered),
|
||||
'cached_total': len(receivers),
|
||||
})
|
||||
|
||||
|
||||
@websdr_bp.route('/receivers/nearest')
|
||||
def nearest_receivers() -> Response:
|
||||
"""Find receivers nearest to a given location."""
|
||||
lat = request.args.get('lat', type=float)
|
||||
lon = request.args.get('lon', type=float)
|
||||
freq_khz = request.args.get('freq_khz', type=float)
|
||||
|
||||
if lat is None or lon is None:
|
||||
return jsonify({'status': 'error', 'message': 'lat and lon are required'}), 400
|
||||
|
||||
receivers = get_receivers()
|
||||
|
||||
# Filter by frequency if specified
|
||||
if freq_khz is not None:
|
||||
receivers = [
|
||||
r for r in receivers
|
||||
if r.get('freq_lo', 0) <= freq_khz <= r.get('freq_hi', 30000)
|
||||
]
|
||||
|
||||
# Calculate distances and sort
|
||||
with_distance = []
|
||||
for r in receivers:
|
||||
if r.get('lat') is not None and r.get('lon') is not None:
|
||||
dist = _haversine(lat, lon, r['lat'], r['lon'])
|
||||
entry = dict(r)
|
||||
entry['distance_km'] = round(dist, 1)
|
||||
with_distance.append(entry)
|
||||
|
||||
with_distance.sort(key=lambda x: x['distance_km'])
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'receivers': with_distance[:10],
|
||||
})
|
||||
|
||||
|
||||
@websdr_bp.route('/spy-station/<station_id>/receivers')
|
||||
def spy_station_receivers(station_id: str) -> Response:
|
||||
"""Find receivers that can tune to a spy station's frequency."""
|
||||
try:
|
||||
from routes.spy_stations import STATIONS
|
||||
except ImportError:
|
||||
return jsonify({'status': 'error', 'message': 'Spy stations module not available'}), 503
|
||||
|
||||
# Find the station
|
||||
station = None
|
||||
for s in STATIONS:
|
||||
if s.get('id') == station_id:
|
||||
station = s
|
||||
break
|
||||
|
||||
if not station:
|
||||
return jsonify({'status': 'error', 'message': 'Station not found'}), 404
|
||||
|
||||
# Get primary frequency
|
||||
freq_khz = None
|
||||
for f in station.get('frequencies', []):
|
||||
if f.get('primary'):
|
||||
freq_khz = f.get('freq_khz')
|
||||
break
|
||||
if freq_khz is None and station.get('frequencies'):
|
||||
freq_khz = station['frequencies'][0].get('freq_khz')
|
||||
|
||||
if freq_khz is None:
|
||||
return jsonify({'status': 'error', 'message': 'No frequency found for station'}), 404
|
||||
|
||||
receivers = get_receivers()
|
||||
|
||||
# Filter receivers that cover this frequency and are available
|
||||
matching = [
|
||||
r for r in receivers
|
||||
if r.get('freq_lo', 0) <= freq_khz <= r.get('freq_hi', 30000) and r.get('available', True)
|
||||
]
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'station': {
|
||||
'id': station['id'],
|
||||
'name': station.get('name', ''),
|
||||
'nickname': station.get('nickname', ''),
|
||||
'freq_khz': freq_khz,
|
||||
'mode': station.get('mode', 'USB'),
|
||||
},
|
||||
'receivers': matching[:20],
|
||||
'total': len(matching),
|
||||
})
|
||||
|
||||
|
||||
@websdr_bp.route('/status')
|
||||
def websdr_status() -> Response:
|
||||
"""Get WebSDR connection and cache status."""
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'cached_receivers': len(_receiver_cache),
|
||||
'cache_age_seconds': round(time.time() - _cache_timestamp, 0) if _cache_timestamp > 0 else None,
|
||||
'cache_ttl': CACHE_TTL,
|
||||
'audio_connected': _kiwi_client is not None and _kiwi_client.connected if _kiwi_client else False,
|
||||
})
|
||||
|
||||
|
||||
# ============================================
|
||||
# KIWISDR AUDIO PROXY
|
||||
# ============================================
|
||||
|
||||
_kiwi_client: Optional[KiwiSDRClient] = None
|
||||
_kiwi_lock = threading.Lock()
|
||||
_kiwi_audio_queue: queue.Queue = queue.Queue(maxsize=200)
|
||||
|
||||
|
||||
def _disconnect_kiwi() -> None:
|
||||
"""Disconnect active KiwiSDR client."""
|
||||
global _kiwi_client
|
||||
with _kiwi_lock:
|
||||
if _kiwi_client:
|
||||
_kiwi_client.disconnect()
|
||||
_kiwi_client = None
|
||||
# Drain audio queue
|
||||
while not _kiwi_audio_queue.empty():
|
||||
try:
|
||||
_kiwi_audio_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
|
||||
def _handle_kiwi_command(ws, cmd: str, data: dict) -> None:
|
||||
"""Handle a command from the browser client."""
|
||||
global _kiwi_client
|
||||
|
||||
if cmd == 'connect':
|
||||
receiver_url = data.get('url', '')
|
||||
host = data.get('host', '')
|
||||
port = int(data.get('port', 8073))
|
||||
freq_khz = float(data.get('freq_khz', 7000))
|
||||
mode = data.get('mode', 'am').lower()
|
||||
password = data.get('password', '')
|
||||
|
||||
# Parse host/port from URL if provided
|
||||
if receiver_url and not host:
|
||||
host, port = parse_host_port(receiver_url)
|
||||
|
||||
if mode not in VALID_MODES:
|
||||
ws.send(json.dumps({'type': 'error', 'message': f'Invalid mode: {mode}'}))
|
||||
return
|
||||
|
||||
if not host or ';' in host or '&' in host or '|' in host:
|
||||
ws.send(json.dumps({'type': 'error', 'message': 'Invalid host'}))
|
||||
return
|
||||
|
||||
_disconnect_kiwi()
|
||||
|
||||
def on_audio(pcm_bytes, smeter):
|
||||
# Package: 2 bytes smeter (big-endian int16) + PCM data
|
||||
header = struct.pack('>h', smeter)
|
||||
try:
|
||||
_kiwi_audio_queue.put_nowait(header + pcm_bytes)
|
||||
except queue.Full:
|
||||
try:
|
||||
_kiwi_audio_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
try:
|
||||
_kiwi_audio_queue.put_nowait(header + pcm_bytes)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
def on_error(msg):
|
||||
try:
|
||||
ws.send(json.dumps({'type': 'error', 'message': msg}))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def on_disconnect():
|
||||
try:
|
||||
ws.send(json.dumps({'type': 'disconnected'}))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
with _kiwi_lock:
|
||||
_kiwi_client = KiwiSDRClient(
|
||||
host=host, port=port,
|
||||
on_audio=on_audio,
|
||||
on_error=on_error,
|
||||
on_disconnect=on_disconnect,
|
||||
password=password,
|
||||
)
|
||||
success = _kiwi_client.connect(freq_khz, mode)
|
||||
|
||||
if success:
|
||||
ws.send(json.dumps({
|
||||
'type': 'connected',
|
||||
'host': host,
|
||||
'port': port,
|
||||
'freq_khz': freq_khz,
|
||||
'mode': mode,
|
||||
'sample_rate': KIWI_SAMPLE_RATE,
|
||||
}))
|
||||
else:
|
||||
ws.send(json.dumps({'type': 'error', 'message': 'Connection to KiwiSDR failed'}))
|
||||
_disconnect_kiwi()
|
||||
|
||||
elif cmd == 'tune':
|
||||
freq_khz = float(data.get('freq_khz', 0))
|
||||
mode = data.get('mode', '').lower() or None
|
||||
|
||||
with _kiwi_lock:
|
||||
if _kiwi_client and _kiwi_client.connected:
|
||||
success = _kiwi_client.tune(
|
||||
freq_khz,
|
||||
mode or _kiwi_client.mode
|
||||
)
|
||||
if success:
|
||||
ws.send(json.dumps({
|
||||
'type': 'tuned',
|
||||
'freq_khz': freq_khz,
|
||||
'mode': mode or _kiwi_client.mode,
|
||||
}))
|
||||
else:
|
||||
ws.send(json.dumps({'type': 'error', 'message': 'Retune failed'}))
|
||||
else:
|
||||
ws.send(json.dumps({'type': 'error', 'message': 'Not connected'}))
|
||||
|
||||
elif cmd == 'disconnect':
|
||||
_disconnect_kiwi()
|
||||
ws.send(json.dumps({'type': 'disconnected'}))
|
||||
|
||||
|
||||
def init_websdr_audio(app: Flask) -> None:
|
||||
"""Initialize WebSocket audio proxy for KiwiSDR. Called from app.py."""
|
||||
if not WEBSOCKET_AVAILABLE:
|
||||
logger.warning("flask-sock not installed, KiwiSDR audio proxy disabled")
|
||||
return
|
||||
|
||||
sock = Sock(app)
|
||||
|
||||
@sock.route('/ws/kiwi-audio')
|
||||
def kiwi_audio_stream(ws):
|
||||
"""WebSocket endpoint: proxy audio between browser and KiwiSDR."""
|
||||
logger.info("KiwiSDR audio client connected")
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Check for commands from browser
|
||||
try:
|
||||
msg = ws.receive(timeout=0.005)
|
||||
if msg:
|
||||
data = json.loads(msg)
|
||||
cmd = data.get('cmd', '')
|
||||
_handle_kiwi_command(ws, cmd, data)
|
||||
except TimeoutError:
|
||||
pass
|
||||
except Exception as e:
|
||||
if 'closed' in str(e).lower():
|
||||
break
|
||||
if 'timed out' not in str(e).lower():
|
||||
logger.error(f"KiwiSDR WS receive error: {e}")
|
||||
|
||||
# Forward audio from KiwiSDR to browser
|
||||
try:
|
||||
audio_data = _kiwi_audio_queue.get_nowait()
|
||||
ws.send(audio_data)
|
||||
except queue.Empty:
|
||||
time.sleep(0.005)
|
||||
|
||||
except Exception as e:
|
||||
logger.info(f"KiwiSDR WS closed: {e}")
|
||||
finally:
|
||||
_disconnect_kiwi()
|
||||
logger.info("KiwiSDR audio client disconnected")
|
||||
Reference in New Issue
Block a user