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:
Smittix
2026-02-06 15:38:08 +00:00
parent acfe90a5a0
commit b32734b7f2
22 changed files with 4065 additions and 193 deletions
+352
View File
@@ -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
View File
@@ -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
+504
View File
@@ -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")