mirror of
https://github.com/smittix/intercept.git
synced 2026-07-19 23:08:10 -07:00
chore: commit all changes and remove large IQ captures from tracking
Add .gitignore entry for data/subghz/captures/ to prevent large IQ recording files from being committed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,7 @@ def register_blueprints(app):
|
||||
from .websdr import websdr_bp
|
||||
from .alerts import alerts_bp
|
||||
from .recordings import recordings_bp
|
||||
from .subghz import subghz_bp
|
||||
|
||||
app.register_blueprint(pager_bp)
|
||||
app.register_blueprint(sensor_bp)
|
||||
@@ -63,6 +64,7 @@ def register_blueprints(app):
|
||||
app.register_blueprint(websdr_bp) # HF/Shortwave WebSDR
|
||||
app.register_blueprint(alerts_bp) # Cross-mode alerts
|
||||
app.register_blueprint(recordings_bp) # Session recordings
|
||||
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
|
||||
|
||||
# Initialize TSCM state with queue and lock from app
|
||||
import app as app_module
|
||||
|
||||
+101
-79
@@ -19,15 +19,16 @@ from typing import Generator, Optional
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.constants import (
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
PROCESS_START_WAIT,
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.constants import (
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
PROCESS_START_WAIT,
|
||||
)
|
||||
|
||||
aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
|
||||
@@ -72,14 +73,19 @@ def find_multimon_ng() -> Optional[str]:
|
||||
return shutil.which('multimon-ng')
|
||||
|
||||
|
||||
def find_rtl_fm() -> Optional[str]:
|
||||
"""Find rtl_fm binary."""
|
||||
return shutil.which('rtl_fm')
|
||||
|
||||
|
||||
def find_rtl_power() -> Optional[str]:
|
||||
"""Find rtl_power binary for spectrum scanning."""
|
||||
return shutil.which('rtl_power')
|
||||
def find_rtl_fm() -> Optional[str]:
|
||||
"""Find rtl_fm binary."""
|
||||
return shutil.which('rtl_fm')
|
||||
|
||||
|
||||
def find_rx_fm() -> Optional[str]:
|
||||
"""Find SoapySDR rx_fm binary."""
|
||||
return shutil.which('rx_fm')
|
||||
|
||||
|
||||
def find_rtl_power() -> Optional[str]:
|
||||
"""Find rtl_power binary for spectrum scanning."""
|
||||
return shutil.which('rtl_power')
|
||||
|
||||
|
||||
# Path to direwolf config file
|
||||
@@ -1414,19 +1420,22 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
||||
|
||||
|
||||
@aprs_bp.route('/tools')
|
||||
def check_aprs_tools() -> Response:
|
||||
"""Check for APRS decoding tools."""
|
||||
has_rtl_fm = find_rtl_fm() is not None
|
||||
has_direwolf = find_direwolf() is not None
|
||||
has_multimon = find_multimon_ng() is not None
|
||||
|
||||
return jsonify({
|
||||
'rtl_fm': has_rtl_fm,
|
||||
'direwolf': has_direwolf,
|
||||
'multimon_ng': has_multimon,
|
||||
'ready': has_rtl_fm and (has_direwolf or has_multimon),
|
||||
'decoder': 'direwolf' if has_direwolf else ('multimon-ng' if has_multimon else None)
|
||||
})
|
||||
def check_aprs_tools() -> Response:
|
||||
"""Check for APRS decoding tools."""
|
||||
has_rtl_fm = find_rtl_fm() is not None
|
||||
has_rx_fm = find_rx_fm() is not None
|
||||
has_direwolf = find_direwolf() is not None
|
||||
has_multimon = find_multimon_ng() is not None
|
||||
has_fm_demod = has_rtl_fm or has_rx_fm
|
||||
|
||||
return jsonify({
|
||||
'rtl_fm': has_rtl_fm,
|
||||
'rx_fm': has_rx_fm,
|
||||
'direwolf': has_direwolf,
|
||||
'multimon_ng': has_multimon,
|
||||
'ready': has_fm_demod and (has_direwolf or has_multimon),
|
||||
'decoder': 'direwolf' if has_direwolf else ('multimon-ng' if has_multimon else None)
|
||||
})
|
||||
|
||||
|
||||
@aprs_bp.route('/status')
|
||||
@@ -1467,20 +1476,12 @@ def start_aprs() -> Response:
|
||||
'message': 'APRS decoder already running'
|
||||
}), 409
|
||||
|
||||
# Check for required tools
|
||||
rtl_fm_path = find_rtl_fm()
|
||||
if not rtl_fm_path:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr'
|
||||
}), 400
|
||||
|
||||
# Check for decoder (prefer direwolf, fallback to multimon-ng)
|
||||
direwolf_path = find_direwolf()
|
||||
multimon_path = find_multimon_ng()
|
||||
|
||||
if not direwolf_path and not multimon_path:
|
||||
return jsonify({
|
||||
# Check for decoder (prefer direwolf, fallback to multimon-ng)
|
||||
direwolf_path = find_direwolf()
|
||||
multimon_path = find_multimon_ng()
|
||||
|
||||
if not direwolf_path and not multimon_path:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'No APRS decoder found. Install direwolf or multimon-ng'
|
||||
}), 400
|
||||
@@ -1488,12 +1489,31 @@ def start_aprs() -> Response:
|
||||
data = request.json or {}
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
gain = validate_gain(data.get('gain', '40'))
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
try:
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
gain = validate_gain(data.get('gain', '40'))
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
if sdr_type == SDRType.RTL_SDR:
|
||||
if find_rtl_fm() is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr'
|
||||
}), 400
|
||||
else:
|
||||
if find_rx_fm() is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
|
||||
}), 400
|
||||
|
||||
# Reserve SDR device to prevent conflicts with other modes
|
||||
error = app_module.claim_sdr_device(device, 'aprs')
|
||||
@@ -1525,28 +1545,29 @@ def start_aprs() -> Response:
|
||||
aprs_last_packet_time = None
|
||||
aprs_stations = {}
|
||||
|
||||
# Build rtl_fm command for APRS (narrowband FM at 22050 Hz for AFSK1200)
|
||||
freq_hz = f"{float(frequency)}M"
|
||||
rtl_cmd = [
|
||||
rtl_fm_path,
|
||||
'-f', freq_hz,
|
||||
'-M', 'nfm', # Narrowband FM for APRS
|
||||
'-s', '22050', # Sample rate matching direwolf -r 22050
|
||||
'-E', 'dc', # Enable DC blocking filter for cleaner audio
|
||||
'-A', 'fast', # Fast AGC for packet bursts
|
||||
'-d', str(device),
|
||||
]
|
||||
|
||||
# Gain: 0 means auto, otherwise set specific gain
|
||||
if gain and str(gain) != '0':
|
||||
rtl_cmd.extend(['-g', str(gain)])
|
||||
|
||||
# PPM frequency correction
|
||||
if ppm and str(ppm) != '0':
|
||||
rtl_cmd.extend(['-p', str(ppm)])
|
||||
|
||||
# Output raw audio to stdout
|
||||
rtl_cmd.append('-')
|
||||
# Build FM demod command for APRS (AFSK1200 @ 22050 Hz) via SDR abstraction.
|
||||
try:
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
rtl_cmd = builder.build_fm_demod_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=float(frequency),
|
||||
sample_rate=22050,
|
||||
gain=float(gain) if gain and str(gain) != '0' else None,
|
||||
ppm=int(ppm) if ppm and str(ppm) != '0' else None,
|
||||
modulation='nfm' if sdr_type == SDRType.RTL_SDR else 'fm',
|
||||
squelch=None,
|
||||
bias_t=bool(data.get('bias_t', False)),
|
||||
)
|
||||
|
||||
if sdr_type == SDRType.RTL_SDR and rtl_cmd and rtl_cmd[-1] == '-':
|
||||
# APRS benefits from DC blocking + fast AGC on rtl_fm.
|
||||
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-']
|
||||
except Exception as e:
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
aprs_active_device = None
|
||||
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
|
||||
|
||||
# Build decoder command
|
||||
if direwolf_path:
|
||||
@@ -1669,13 +1690,14 @@ def start_aprs() -> Response:
|
||||
)
|
||||
thread.start()
|
||||
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
'region': region,
|
||||
'device': device,
|
||||
'decoder': decoder_name
|
||||
})
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
'region': region,
|
||||
'device': device,
|
||||
'sdr_type': sdr_type.value,
|
||||
'decoder': decoder_name
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start APRS decoder: {e}")
|
||||
|
||||
+19
-13
@@ -37,11 +37,17 @@ def find_rtl_fm():
|
||||
return shutil.which('rtl_fm')
|
||||
|
||||
|
||||
def find_ffmpeg():
|
||||
return shutil.which('ffmpeg')
|
||||
|
||||
|
||||
def kill_audio_processes():
|
||||
def find_ffmpeg():
|
||||
return shutil.which('ffmpeg')
|
||||
|
||||
|
||||
def _rtl_fm_demod_mode(modulation):
|
||||
"""Map UI modulation names to rtl_fm demod tokens."""
|
||||
mod = str(modulation or '').lower().strip()
|
||||
return 'wbfm' if mod == 'wfm' else mod
|
||||
|
||||
|
||||
def kill_audio_processes():
|
||||
"""Kill any running audio processes."""
|
||||
global audio_process, rtl_process
|
||||
|
||||
@@ -104,14 +110,14 @@ def start_audio_stream(config):
|
||||
|
||||
freq_hz = int(freq * 1e6)
|
||||
|
||||
rtl_cmd = [
|
||||
rtl_fm,
|
||||
'-M', mod,
|
||||
'-f', str(freq_hz),
|
||||
'-s', str(sample_rate),
|
||||
'-r', str(resample_rate),
|
||||
'-g', str(gain),
|
||||
'-d', str(device),
|
||||
rtl_cmd = [
|
||||
rtl_fm,
|
||||
'-M', _rtl_fm_demod_mode(mod),
|
||||
'-f', str(freq_hz),
|
||||
'-s', str(sample_rate),
|
||||
'-r', str(resample_rate),
|
||||
'-g', str(gain),
|
||||
'-d', str(device),
|
||||
'-l', str(squelch),
|
||||
]
|
||||
|
||||
|
||||
+137
-107
@@ -21,6 +21,7 @@ from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.process import register_process, unregister_process
|
||||
from utils.validation import validate_frequency, validate_gain, validate_device_index, validate_ppm
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.constants import (
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
@@ -44,11 +45,12 @@ dmr_lock = threading.Lock()
|
||||
dmr_queue: queue.Queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
dmr_active_device: Optional[int] = None
|
||||
|
||||
# Audio mux: the sole reader of dsd-fme stdout. Writes to an ffmpeg
|
||||
# stdin when a streaming client is connected, discards otherwise.
|
||||
# Audio mux: the sole reader of dsd-fme stdout. Fans out bytes to all
|
||||
# active ffmpeg stdin sinks when streaming clients are connected.
|
||||
# This prevents dsd-fme from blocking on stdout (which would also
|
||||
# freeze stderr / text data output).
|
||||
_active_ffmpeg_stdin: Optional[object] = None # set by stream endpoint
|
||||
_ffmpeg_sinks: set[object] = set()
|
||||
_ffmpeg_sinks_lock = threading.Lock()
|
||||
|
||||
VALID_PROTOCOLS = ['auto', 'dmr', 'p25', 'nxdn', 'dstar', 'provoice']
|
||||
|
||||
@@ -68,9 +70,9 @@ _DSD_PROTOCOL_FLAGS = {
|
||||
# -fi = NXDN48 (NOT D-Star), -f1 = P25 Phase 1,
|
||||
# -ft = XDMA multi-protocol decoder
|
||||
_DSD_FME_PROTOCOL_FLAGS = {
|
||||
'auto': ['-ft'], # XDMA: auto-detect DMR/P25/YSF
|
||||
'auto': ['-fa'], # Broad auto: P25 (P1/P2), DMR, D-STAR, YSF, X2-TDMA
|
||||
'dmr': ['-fs'], # DMR Simplex (-fd is D-STAR in dsd-fme!)
|
||||
'p25': ['-f1'], # P25 Phase 1 (-fp is ProVoice in dsd-fme!)
|
||||
'p25': ['-ft'], # P25 P1/P2 coverage (also includes DMR in dsd-fme)
|
||||
'nxdn': ['-fn'], # NXDN96
|
||||
'dstar': ['-fd'], # D-STAR (-fd in dsd-fme, NOT DMR!)
|
||||
'provoice': ['-fp'], # ProVoice (-fp in dsd-fme, not -fv)
|
||||
@@ -80,7 +82,6 @@ _DSD_FME_PROTOCOL_FLAGS = {
|
||||
# sync reliability vs letting dsd-fme auto-detect modulation type.
|
||||
_DSD_FME_MODULATION = {
|
||||
'dmr': ['-mc'], # C4FM
|
||||
'p25': ['-mc'], # C4FM (Phase 1; Phase 2 would use -mq)
|
||||
'nxdn': ['-mc'], # C4FM
|
||||
}
|
||||
|
||||
@@ -109,6 +110,11 @@ def find_rtl_fm() -> str | None:
|
||||
return shutil.which('rtl_fm')
|
||||
|
||||
|
||||
def find_rx_fm() -> str | None:
|
||||
"""Find SoapySDR rx_fm binary."""
|
||||
return shutil.which('rx_fm')
|
||||
|
||||
|
||||
def find_ffmpeg() -> str | None:
|
||||
"""Find ffmpeg for audio encoding."""
|
||||
return shutil.which('ffmpeg')
|
||||
@@ -214,14 +220,66 @@ _HEARTBEAT_INTERVAL = 3.0 # seconds between heartbeats when decoder is idle
|
||||
_SILENCE_CHUNK = b'\x00' * 1600
|
||||
|
||||
|
||||
def _register_audio_sink(sink: object) -> None:
|
||||
"""Register an ffmpeg stdin sink for mux fanout."""
|
||||
with _ffmpeg_sinks_lock:
|
||||
_ffmpeg_sinks.add(sink)
|
||||
|
||||
|
||||
def _unregister_audio_sink(sink: object) -> None:
|
||||
"""Remove an ffmpeg stdin sink from mux fanout."""
|
||||
with _ffmpeg_sinks_lock:
|
||||
_ffmpeg_sinks.discard(sink)
|
||||
|
||||
|
||||
def _get_audio_sinks() -> tuple[object, ...]:
|
||||
"""Snapshot current audio sinks for lock-free iteration."""
|
||||
with _ffmpeg_sinks_lock:
|
||||
return tuple(_ffmpeg_sinks)
|
||||
|
||||
|
||||
def _stop_process(proc: Optional[subprocess.Popen]) -> None:
|
||||
"""Terminate and unregister a subprocess if present."""
|
||||
if not proc:
|
||||
return
|
||||
if proc.poll() is None:
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
unregister_process(proc)
|
||||
|
||||
|
||||
def _reset_runtime_state(*, release_device: bool) -> None:
|
||||
"""Reset process + runtime state and optionally release SDR ownership."""
|
||||
global dmr_rtl_process, dmr_dsd_process
|
||||
global dmr_running, dmr_has_audio, dmr_active_device
|
||||
|
||||
_stop_process(dmr_dsd_process)
|
||||
_stop_process(dmr_rtl_process)
|
||||
dmr_rtl_process = None
|
||||
dmr_dsd_process = None
|
||||
dmr_running = False
|
||||
dmr_has_audio = False
|
||||
with _ffmpeg_sinks_lock:
|
||||
_ffmpeg_sinks.clear()
|
||||
|
||||
if release_device and dmr_active_device is not None:
|
||||
app_module.release_sdr_device(dmr_active_device)
|
||||
dmr_active_device = None
|
||||
|
||||
|
||||
def _dsd_audio_mux(dsd_stdout):
|
||||
"""Mux thread: sole reader of dsd-fme stdout.
|
||||
|
||||
Always drains dsd-fme's audio output to prevent the process from
|
||||
blocking on stdout writes (which would also freeze stderr / text
|
||||
data). When an audio streaming client is connected, forwards audio
|
||||
to its ffmpeg stdin with silence fill during voice gaps. When no
|
||||
client is connected, simply discards the data.
|
||||
data). When streaming clients are connected, forwards data to all
|
||||
active ffmpeg stdin sinks with silence fill during voice gaps.
|
||||
"""
|
||||
try:
|
||||
while dmr_running:
|
||||
@@ -230,22 +288,22 @@ def _dsd_audio_mux(dsd_stdout):
|
||||
data = os.read(dsd_stdout.fileno(), 4096)
|
||||
if not data:
|
||||
break
|
||||
sink = _active_ffmpeg_stdin
|
||||
if sink:
|
||||
sinks = _get_audio_sinks()
|
||||
for sink in sinks:
|
||||
try:
|
||||
sink.write(data)
|
||||
sink.flush()
|
||||
except (BrokenPipeError, OSError, ValueError):
|
||||
pass
|
||||
_unregister_audio_sink(sink)
|
||||
else:
|
||||
# No audio from decoder — feed silence if client connected
|
||||
sink = _active_ffmpeg_stdin
|
||||
if sink:
|
||||
sinks = _get_audio_sinks()
|
||||
for sink in sinks:
|
||||
try:
|
||||
sink.write(_SILENCE_CHUNK)
|
||||
sink.flush()
|
||||
except (BrokenPipeError, OSError, ValueError):
|
||||
pass
|
||||
_unregister_audio_sink(sink)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
@@ -316,7 +374,11 @@ def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Pop
|
||||
logger.error(f"DSD stream error: {e}")
|
||||
finally:
|
||||
global dmr_active_device, dmr_rtl_process, dmr_dsd_process
|
||||
global dmr_has_audio
|
||||
dmr_running = False
|
||||
dmr_has_audio = False
|
||||
with _ffmpeg_sinks_lock:
|
||||
_ffmpeg_sinks.clear()
|
||||
# Capture exit info for diagnostics
|
||||
rc = dsd_process.poll()
|
||||
reason = 'stopped'
|
||||
@@ -331,18 +393,8 @@ def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Pop
|
||||
pass
|
||||
logger.warning(f"DSD process exited with code {rc}: {detail}")
|
||||
# Cleanup decoder + demod processes
|
||||
for proc in [dsd_process, rtl_process]:
|
||||
if proc and proc.poll() is None:
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
if proc:
|
||||
unregister_process(proc)
|
||||
_stop_process(dsd_process)
|
||||
_stop_process(rtl_process)
|
||||
dmr_rtl_process = None
|
||||
dmr_dsd_process = None
|
||||
_queue_put({'type': 'status', 'text': reason, 'exit_code': rc, 'detail': detail})
|
||||
@@ -362,12 +414,14 @@ def check_tools() -> Response:
|
||||
"""Check for required tools."""
|
||||
dsd_path, _ = find_dsd()
|
||||
rtl_fm = find_rtl_fm()
|
||||
rx_fm = find_rx_fm()
|
||||
ffmpeg = find_ffmpeg()
|
||||
return jsonify({
|
||||
'dsd': dsd_path is not None,
|
||||
'rtl_fm': rtl_fm is not None,
|
||||
'rx_fm': rx_fm is not None,
|
||||
'ffmpeg': ffmpeg is not None,
|
||||
'available': dsd_path is not None and rtl_fm is not None,
|
||||
'available': dsd_path is not None and (rtl_fm is not None or rx_fm is not None),
|
||||
'protocols': VALID_PROTOCOLS,
|
||||
})
|
||||
|
||||
@@ -378,18 +432,10 @@ def start_dmr() -> Response:
|
||||
global dmr_rtl_process, dmr_dsd_process, dmr_thread
|
||||
global dmr_running, dmr_has_audio, dmr_active_device
|
||||
|
||||
with dmr_lock:
|
||||
if dmr_running:
|
||||
return jsonify({'status': 'error', 'message': 'Already running'}), 409
|
||||
|
||||
dsd_path, is_fme = find_dsd()
|
||||
if not dsd_path:
|
||||
return jsonify({'status': 'error', 'message': 'dsd not found. Install dsd-fme or dsd.'}), 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:
|
||||
@@ -401,9 +447,25 @@ def start_dmr() -> Response:
|
||||
except (ValueError, TypeError) as e:
|
||||
return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400
|
||||
|
||||
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
if protocol not in VALID_PROTOCOLS:
|
||||
return jsonify({'status': 'error', 'message': f'Invalid protocol. Use: {", ".join(VALID_PROTOCOLS)}'}), 400
|
||||
|
||||
if sdr_type == SDRType.RTL_SDR:
|
||||
if not find_rtl_fm():
|
||||
return jsonify({'status': 'error', 'message': 'rtl_fm not found. Install rtl-sdr tools.'}), 503
|
||||
else:
|
||||
if not find_rx_fm():
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
|
||||
}), 503
|
||||
|
||||
# Clear stale queue
|
||||
try:
|
||||
while True:
|
||||
@@ -411,32 +473,45 @@ def start_dmr() -> Response:
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
# Reserve running state before we start claiming resources/processes
|
||||
# so concurrent /start requests cannot race each other.
|
||||
with dmr_lock:
|
||||
if dmr_running:
|
||||
return jsonify({'status': 'error', 'message': 'Already running'}), 409
|
||||
dmr_running = True
|
||||
dmr_has_audio = False
|
||||
|
||||
# Claim SDR device — use protocol name so the device panel shows
|
||||
# "D-STAR", "P25", etc. instead of always "DMR"
|
||||
mode_label = protocol.upper() if protocol != 'auto' else 'DMR'
|
||||
error = app_module.claim_sdr_device(device, mode_label)
|
||||
if error:
|
||||
with dmr_lock:
|
||||
dmr_running = False
|
||||
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).
|
||||
# Squelch disabled (-l 0): rtl_fm's squelch chops the bitstream
|
||||
# mid-frame, destroying DSD sync. The decoder handles silence
|
||||
# internally via its own frame-sync detection.
|
||||
rtl_cmd = [
|
||||
rtl_fm_path,
|
||||
'-M', 'fm',
|
||||
'-f', str(freq_hz),
|
||||
'-s', '48000',
|
||||
'-g', str(gain),
|
||||
'-d', str(device),
|
||||
'-l', '0',
|
||||
]
|
||||
if ppm != 0:
|
||||
rtl_cmd.extend(['-p', str(ppm)])
|
||||
# Build FM demodulation command via SDR abstraction.
|
||||
try:
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
rtl_cmd = builder.build_fm_demod_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=frequency,
|
||||
sample_rate=48000,
|
||||
gain=float(gain) if gain > 0 else None,
|
||||
ppm=int(ppm) if ppm != 0 else None,
|
||||
modulation='fm',
|
||||
squelch=None,
|
||||
bias_t=bool(data.get('bias_t', False)),
|
||||
)
|
||||
if sdr_type == SDRType.RTL_SDR:
|
||||
# Keep squelch fully open for digital bitstreams.
|
||||
rtl_cmd.extend(['-l', '0'])
|
||||
except Exception as e:
|
||||
_reset_runtime_state(release_device=True)
|
||||
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
|
||||
|
||||
# Build DSD command
|
||||
# Audio output: pipe decoded audio (8kHz s16le PCM) to stdout for
|
||||
@@ -508,25 +583,8 @@ def start_dmr() -> Response:
|
||||
if dmr_dsd_process.stderr:
|
||||
dsd_err = dmr_dsd_process.stderr.read().decode('utf-8', errors='replace')[:500]
|
||||
logger.error(f"DSD pipeline died: rtl_fm rc={rtl_rc} err={rtl_err!r}, dsd rc={dsd_rc} err={dsd_err!r}")
|
||||
# Terminate surviving processes and unregister all
|
||||
dmr_has_audio = 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
|
||||
if proc:
|
||||
unregister_process(proc)
|
||||
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
|
||||
# Terminate surviving processes and release resources.
|
||||
_reset_runtime_state(release_device=True)
|
||||
# Surface a clear error to the user
|
||||
detail = rtl_err.strip() or dsd_err.strip()
|
||||
if 'usb_claim_interface' in rtl_err or 'Failed to open' in rtl_err:
|
||||
@@ -547,7 +605,6 @@ def start_dmr() -> Response:
|
||||
|
||||
threading.Thread(target=_drain_rtl_stderr, args=(dmr_rtl_process,), daemon=True).start()
|
||||
|
||||
dmr_running = True
|
||||
dmr_thread = threading.Thread(
|
||||
target=stream_dsd_output,
|
||||
args=(dmr_rtl_process, dmr_dsd_process),
|
||||
@@ -559,46 +616,21 @@ def start_dmr() -> Response:
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
'protocol': protocol,
|
||||
'sdr_type': sdr_type.value,
|
||||
'has_audio': dmr_has_audio,
|
||||
})
|
||||
|
||||
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
|
||||
_reset_runtime_state(release_device=True)
|
||||
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
|
||||
global dmr_running, dmr_has_audio, dmr_active_device
|
||||
|
||||
with dmr_lock:
|
||||
dmr_running = False
|
||||
dmr_has_audio = 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
|
||||
if proc:
|
||||
unregister_process(proc)
|
||||
|
||||
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
|
||||
_reset_runtime_state(release_device=True)
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
@@ -625,8 +657,6 @@ def stream_dmr_audio() -> Response:
|
||||
it, back-pressuring the entire pipeline and freezing stderr/text
|
||||
data output).
|
||||
"""
|
||||
global _active_ffmpeg_stdin
|
||||
|
||||
if not dmr_running or not dmr_has_audio:
|
||||
return Response(b'', mimetype='audio/wav', status=204)
|
||||
|
||||
@@ -653,11 +683,10 @@ def stream_dmr_audio() -> Response:
|
||||
args=(audio_proc,), daemon=True,
|
||||
).start()
|
||||
|
||||
# Tell the mux thread to start writing to this ffmpeg
|
||||
_active_ffmpeg_stdin = audio_proc.stdin
|
||||
if audio_proc.stdin:
|
||||
_register_audio_sink(audio_proc.stdin)
|
||||
|
||||
def generate():
|
||||
global _active_ffmpeg_stdin
|
||||
try:
|
||||
while dmr_running and audio_proc.poll() is None:
|
||||
ready, _, _ = select.select([audio_proc.stdout], [], [], 2.0)
|
||||
@@ -676,7 +705,8 @@ def stream_dmr_audio() -> Response:
|
||||
logger.error(f"DMR audio stream error: {e}")
|
||||
finally:
|
||||
# Disconnect mux → ffmpeg, then clean up
|
||||
_active_ffmpeg_stdin = None
|
||||
if audio_proc.stdin:
|
||||
_unregister_audio_sink(audio_proc.stdin)
|
||||
try:
|
||||
audio_proc.stdin.close()
|
||||
except Exception:
|
||||
|
||||
+31
-25
@@ -102,15 +102,21 @@ def find_ffmpeg() -> str | None:
|
||||
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
|
||||
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 _rtl_fm_demod_mode(modulation: str) -> str:
|
||||
"""Map UI modulation names to rtl_fm demod tokens."""
|
||||
mod = str(modulation or '').lower().strip()
|
||||
return 'wbfm' if mod == 'wfm' else mod
|
||||
|
||||
|
||||
|
||||
@@ -207,14 +213,14 @@ def scanner_loop():
|
||||
resample_rate = 24000
|
||||
|
||||
# Don't use squelch in rtl_fm - we want to analyze raw audio
|
||||
rtl_cmd = [
|
||||
rtl_fm_path,
|
||||
'-M', mod,
|
||||
'-f', str(freq_hz),
|
||||
'-s', str(sample_rate),
|
||||
'-r', str(resample_rate),
|
||||
'-g', str(gain),
|
||||
'-d', str(device),
|
||||
rtl_cmd = [
|
||||
rtl_fm_path,
|
||||
'-M', _rtl_fm_demod_mode(mod),
|
||||
'-f', str(freq_hz),
|
||||
'-s', str(sample_rate),
|
||||
'-r', str(resample_rate),
|
||||
'-g', str(gain),
|
||||
'-d', str(device),
|
||||
]
|
||||
# Add bias-t flag if enabled (for external LNA power)
|
||||
if scanner_config.get('bias_t', False):
|
||||
@@ -679,14 +685,14 @@ def _start_audio_stream(frequency: float, modulation: str):
|
||||
return
|
||||
|
||||
freq_hz = int(frequency * 1e6)
|
||||
sdr_cmd = [
|
||||
rtl_fm_path,
|
||||
'-M', modulation,
|
||||
'-f', str(freq_hz),
|
||||
'-s', str(sample_rate),
|
||||
'-r', str(resample_rate),
|
||||
'-g', str(scanner_config['gain']),
|
||||
'-d', str(scanner_config['device']),
|
||||
sdr_cmd = [
|
||||
rtl_fm_path,
|
||||
'-M', _rtl_fm_demod_mode(modulation),
|
||||
'-f', str(freq_hz),
|
||||
'-s', str(sample_rate),
|
||||
'-r', str(resample_rate),
|
||||
'-g', str(scanner_config['gain']),
|
||||
'-d', str(scanner_config['device']),
|
||||
'-l', str(scanner_config['squelch']),
|
||||
]
|
||||
if scanner_config.get('bias_t', False):
|
||||
|
||||
+17
-29
@@ -168,17 +168,13 @@ def predict_passes():
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
norad_to_name = {
|
||||
25544: 'ISS',
|
||||
25338: 'NOAA-15',
|
||||
28654: 'NOAA-18',
|
||||
33591: 'NOAA-19',
|
||||
43013: 'NOAA-20',
|
||||
40069: 'METEOR-M2',
|
||||
57166: 'METEOR-M2-3'
|
||||
}
|
||||
|
||||
sat_input = data.get('satellites', ['ISS', 'NOAA-15', 'NOAA-18', 'NOAA-19'])
|
||||
norad_to_name = {
|
||||
25544: 'ISS',
|
||||
40069: 'METEOR-M2',
|
||||
57166: 'METEOR-M2-3'
|
||||
}
|
||||
|
||||
sat_input = data.get('satellites', ['ISS', 'METEOR-M2', 'METEOR-M2-3'])
|
||||
satellites = []
|
||||
for sat in sat_input:
|
||||
if isinstance(sat, int) and sat in norad_to_name:
|
||||
@@ -187,15 +183,11 @@ def predict_passes():
|
||||
satellites.append(sat)
|
||||
|
||||
passes = []
|
||||
colors = {
|
||||
'ISS': '#00ffff',
|
||||
'NOAA-15': '#00ff00',
|
||||
'NOAA-18': '#ff6600',
|
||||
'NOAA-19': '#ff3366',
|
||||
'NOAA-20': '#00ffaa',
|
||||
'METEOR-M2': '#9370DB',
|
||||
'METEOR-M2-3': '#ff00ff'
|
||||
}
|
||||
colors = {
|
||||
'ISS': '#00ffff',
|
||||
'METEOR-M2': '#9370DB',
|
||||
'METEOR-M2-3': '#ff00ff'
|
||||
}
|
||||
name_to_norad = {v: k for k, v in norad_to_name.items()}
|
||||
|
||||
ts = load.timescale()
|
||||
@@ -327,15 +319,11 @@ def get_satellite_position():
|
||||
sat_input = data.get('satellites', [])
|
||||
include_track = bool(data.get('includeTrack', True))
|
||||
|
||||
norad_to_name = {
|
||||
25544: 'ISS',
|
||||
25338: 'NOAA-15',
|
||||
28654: 'NOAA-18',
|
||||
33591: 'NOAA-19',
|
||||
43013: 'NOAA-20',
|
||||
40069: 'METEOR-M2',
|
||||
57166: 'METEOR-M2-3'
|
||||
}
|
||||
norad_to_name = {
|
||||
25544: 'ISS',
|
||||
40069: 'METEOR-M2',
|
||||
57166: 'METEOR-M2-3'
|
||||
}
|
||||
|
||||
satellites = []
|
||||
for sat in sat_input:
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
"""SubGHz transceiver routes.
|
||||
|
||||
Provides endpoints for HackRF-based SubGHz signal capture, protocol decoding,
|
||||
signal replay/transmit, and wideband spectrum analysis.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response, send_file
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import sse_stream
|
||||
from utils.subghz import get_subghz_manager
|
||||
from utils.constants import (
|
||||
SUBGHZ_FREQ_MIN_MHZ,
|
||||
SUBGHZ_FREQ_MAX_MHZ,
|
||||
SUBGHZ_LNA_GAIN_MAX,
|
||||
SUBGHZ_VGA_GAIN_MAX,
|
||||
SUBGHZ_TX_VGA_GAIN_MAX,
|
||||
SUBGHZ_TX_MAX_DURATION,
|
||||
SUBGHZ_SAMPLE_RATES,
|
||||
SUBGHZ_PRESETS,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.subghz')
|
||||
|
||||
subghz_bp = Blueprint('subghz', __name__, url_prefix='/subghz')
|
||||
|
||||
# SSE queue for streaming events to frontend
|
||||
_subghz_queue: queue.Queue = queue.Queue(maxsize=200)
|
||||
|
||||
|
||||
def _event_callback(event: dict) -> None:
|
||||
"""Forward SubGhzManager events to the SSE queue."""
|
||||
try:
|
||||
_subghz_queue.put_nowait(event)
|
||||
except queue.Full:
|
||||
try:
|
||||
_subghz_queue.get_nowait()
|
||||
_subghz_queue.put_nowait(event)
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
|
||||
def _validate_frequency_hz(data: dict, key: str = 'frequency_hz') -> tuple[int | None, str | None]:
|
||||
"""Validate frequency in Hz from request data. Returns (freq_hz, error_msg)."""
|
||||
raw = data.get(key)
|
||||
if raw is None:
|
||||
return None, f'{key} is required'
|
||||
try:
|
||||
freq_hz = int(raw)
|
||||
freq_mhz = freq_hz / 1_000_000
|
||||
if not (SUBGHZ_FREQ_MIN_MHZ <= freq_mhz <= SUBGHZ_FREQ_MAX_MHZ):
|
||||
return None, f'Frequency must be between {SUBGHZ_FREQ_MIN_MHZ}-{SUBGHZ_FREQ_MAX_MHZ} MHz'
|
||||
return freq_hz, None
|
||||
except (ValueError, TypeError):
|
||||
return None, f'Invalid {key}'
|
||||
|
||||
|
||||
def _validate_serial(data: dict) -> str | None:
|
||||
"""Extract and validate optional HackRF device serial."""
|
||||
serial = data.get('device_serial', '')
|
||||
if not serial or not isinstance(serial, str):
|
||||
return None
|
||||
# HackRF serials are hex strings
|
||||
serial = serial.strip()
|
||||
if serial and all(c in '0123456789abcdefABCDEF' for c in serial):
|
||||
return serial
|
||||
return None
|
||||
|
||||
|
||||
def _validate_int(data: dict, key: str, default: int, min_val: int, max_val: int) -> int:
|
||||
"""Validate integer parameter with bounds clamping."""
|
||||
try:
|
||||
val = int(data.get(key, default))
|
||||
return max(min_val, min(max_val, val))
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
def _validate_decode_profile(data: dict, default: str = 'weather') -> str:
|
||||
profile = data.get('decode_profile', default)
|
||||
if not isinstance(profile, str):
|
||||
return default
|
||||
profile = profile.strip().lower()
|
||||
if profile in {'weather', 'all'}:
|
||||
return profile
|
||||
return default
|
||||
|
||||
|
||||
def _validate_optional_float(data: dict, key: str) -> tuple[float | None, str | None]:
|
||||
raw = data.get(key)
|
||||
if raw is None or raw == '':
|
||||
return None, None
|
||||
try:
|
||||
return float(raw), None
|
||||
except (ValueError, TypeError):
|
||||
return None, f'Invalid {key}'
|
||||
|
||||
|
||||
def _validate_bool(data: dict, key: str, default: bool = False) -> bool:
|
||||
raw = data.get(key, default)
|
||||
if isinstance(raw, bool):
|
||||
return raw
|
||||
if isinstance(raw, (int, float)):
|
||||
return bool(raw)
|
||||
if isinstance(raw, str):
|
||||
return raw.strip().lower() in {'1', 'true', 'yes', 'on', 'enabled'}
|
||||
return default
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# STATUS
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@subghz_bp.route('/status')
|
||||
def get_status():
|
||||
manager = get_subghz_manager()
|
||||
return jsonify(manager.get_status())
|
||||
|
||||
|
||||
@subghz_bp.route('/presets')
|
||||
def get_presets():
|
||||
return jsonify({'presets': SUBGHZ_PRESETS, 'sample_rates': SUBGHZ_SAMPLE_RATES})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# RECEIVE
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@subghz_bp.route('/receive/start', methods=['POST'])
|
||||
def start_receive():
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
freq_hz, err = _validate_frequency_hz(data)
|
||||
if err:
|
||||
return jsonify({'status': 'error', 'message': err}), 400
|
||||
|
||||
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
|
||||
lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX)
|
||||
vga_gain = _validate_int(data, 'vga_gain', 20, 0, SUBGHZ_VGA_GAIN_MAX)
|
||||
trigger_enabled = _validate_bool(data, 'trigger_enabled', False)
|
||||
trigger_pre_ms = _validate_int(data, 'trigger_pre_ms', 350, 50, 5000)
|
||||
trigger_post_ms = _validate_int(data, 'trigger_post_ms', 700, 100, 10000)
|
||||
device_serial = _validate_serial(data)
|
||||
|
||||
manager = get_subghz_manager()
|
||||
manager.set_callback(_event_callback)
|
||||
|
||||
result = manager.start_receive(
|
||||
frequency_hz=freq_hz,
|
||||
sample_rate=sample_rate,
|
||||
lna_gain=lna_gain,
|
||||
vga_gain=vga_gain,
|
||||
trigger_enabled=trigger_enabled,
|
||||
trigger_pre_ms=trigger_pre_ms,
|
||||
trigger_post_ms=trigger_post_ms,
|
||||
device_serial=device_serial,
|
||||
)
|
||||
|
||||
status_code = 200 if result.get('status') != 'error' else 409
|
||||
return jsonify(result), status_code
|
||||
|
||||
|
||||
@subghz_bp.route('/receive/stop', methods=['POST'])
|
||||
def stop_receive():
|
||||
manager = get_subghz_manager()
|
||||
result = manager.stop_receive()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DECODE
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@subghz_bp.route('/decode/start', methods=['POST'])
|
||||
def start_decode():
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
freq_hz, err = _validate_frequency_hz(data)
|
||||
if err:
|
||||
return jsonify({'status': 'error', 'message': err}), 400
|
||||
|
||||
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
|
||||
lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX)
|
||||
vga_gain = _validate_int(data, 'vga_gain', 20, 0, SUBGHZ_VGA_GAIN_MAX)
|
||||
decode_profile = _validate_decode_profile(data)
|
||||
device_serial = _validate_serial(data)
|
||||
|
||||
manager = get_subghz_manager()
|
||||
manager.set_callback(_event_callback)
|
||||
|
||||
result = manager.start_decode(
|
||||
frequency_hz=freq_hz,
|
||||
sample_rate=sample_rate,
|
||||
lna_gain=lna_gain,
|
||||
vga_gain=vga_gain,
|
||||
decode_profile=decode_profile,
|
||||
device_serial=device_serial,
|
||||
)
|
||||
|
||||
status_code = 200 if result.get('status') != 'error' else 409
|
||||
return jsonify(result), status_code
|
||||
|
||||
|
||||
@subghz_bp.route('/decode/stop', methods=['POST'])
|
||||
def stop_decode():
|
||||
manager = get_subghz_manager()
|
||||
result = manager.stop_decode()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# TRANSMIT
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@subghz_bp.route('/transmit', methods=['POST'])
|
||||
def start_transmit():
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
capture_id = data.get('capture_id')
|
||||
if not capture_id or not isinstance(capture_id, str):
|
||||
return jsonify({'status': 'error', 'message': 'capture_id is required'}), 400
|
||||
|
||||
# Sanitize capture_id
|
||||
if not capture_id.isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
||||
|
||||
tx_gain = _validate_int(data, 'tx_gain', 20, 0, SUBGHZ_TX_VGA_GAIN_MAX)
|
||||
max_duration = _validate_int(data, 'max_duration', 10, 1, SUBGHZ_TX_MAX_DURATION)
|
||||
start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
|
||||
if start_err:
|
||||
return jsonify({'status': 'error', 'message': start_err}), 400
|
||||
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
|
||||
if duration_err:
|
||||
return jsonify({'status': 'error', 'message': duration_err}), 400
|
||||
device_serial = _validate_serial(data)
|
||||
|
||||
manager = get_subghz_manager()
|
||||
manager.set_callback(_event_callback)
|
||||
|
||||
result = manager.transmit(
|
||||
capture_id=capture_id,
|
||||
tx_gain=tx_gain,
|
||||
max_duration=max_duration,
|
||||
start_seconds=start_seconds,
|
||||
duration_seconds=duration_seconds,
|
||||
device_serial=device_serial,
|
||||
)
|
||||
|
||||
status_code = 200 if result.get('status') != 'error' else 400
|
||||
return jsonify(result), status_code
|
||||
|
||||
|
||||
@subghz_bp.route('/transmit/stop', methods=['POST'])
|
||||
def stop_transmit():
|
||||
manager = get_subghz_manager()
|
||||
result = manager.stop_transmit()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SWEEP
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@subghz_bp.route('/sweep/start', methods=['POST'])
|
||||
def start_sweep():
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
try:
|
||||
freq_start = float(data.get('freq_start_mhz', 300))
|
||||
freq_end = float(data.get('freq_end_mhz', 928))
|
||||
if freq_start >= freq_end:
|
||||
return jsonify({'status': 'error', 'message': 'freq_start must be less than freq_end'}), 400
|
||||
if freq_start < SUBGHZ_FREQ_MIN_MHZ or freq_end > SUBGHZ_FREQ_MAX_MHZ:
|
||||
return jsonify({'status': 'error', 'message': f'Frequency range: {SUBGHZ_FREQ_MIN_MHZ}-{SUBGHZ_FREQ_MAX_MHZ} MHz'}), 400
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid frequency range'}), 400
|
||||
|
||||
bin_width = _validate_int(data, 'bin_width', 100000, 10000, 5000000)
|
||||
device_serial = _validate_serial(data)
|
||||
|
||||
manager = get_subghz_manager()
|
||||
manager.set_callback(_event_callback)
|
||||
|
||||
result = manager.start_sweep(
|
||||
freq_start_mhz=freq_start,
|
||||
freq_end_mhz=freq_end,
|
||||
bin_width=bin_width,
|
||||
device_serial=device_serial,
|
||||
)
|
||||
|
||||
status_code = 200 if result.get('status') != 'error' else 409
|
||||
return jsonify(result), status_code
|
||||
|
||||
|
||||
@subghz_bp.route('/sweep/stop', methods=['POST'])
|
||||
def stop_sweep():
|
||||
manager = get_subghz_manager()
|
||||
result = manager.stop_sweep()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CAPTURES LIBRARY
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@subghz_bp.route('/captures')
|
||||
def list_captures():
|
||||
manager = get_subghz_manager()
|
||||
captures = manager.list_captures()
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'captures': [c.to_dict() for c in captures],
|
||||
'count': len(captures),
|
||||
})
|
||||
|
||||
|
||||
@subghz_bp.route('/captures/<capture_id>')
|
||||
def get_capture(capture_id: str):
|
||||
if not capture_id.isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
||||
|
||||
manager = get_subghz_manager()
|
||||
capture = manager.get_capture(capture_id)
|
||||
if not capture:
|
||||
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
|
||||
|
||||
return jsonify({'status': 'ok', 'capture': capture.to_dict()})
|
||||
|
||||
|
||||
@subghz_bp.route('/captures/<capture_id>/download')
|
||||
def download_capture(capture_id: str):
|
||||
if not capture_id.isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
||||
|
||||
manager = get_subghz_manager()
|
||||
path = manager.get_capture_path(capture_id)
|
||||
if not path:
|
||||
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
|
||||
|
||||
return send_file(
|
||||
path,
|
||||
mimetype='application/octet-stream',
|
||||
as_attachment=True,
|
||||
download_name=path.name,
|
||||
)
|
||||
|
||||
|
||||
@subghz_bp.route('/captures/<capture_id>/trim', methods=['POST'])
|
||||
def trim_capture(capture_id: str):
|
||||
if not capture_id.isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
|
||||
if start_err:
|
||||
return jsonify({'status': 'error', 'message': start_err}), 400
|
||||
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
|
||||
if duration_err:
|
||||
return jsonify({'status': 'error', 'message': duration_err}), 400
|
||||
|
||||
label = data.get('label', '')
|
||||
if label is None:
|
||||
label = ''
|
||||
if not isinstance(label, str) or len(label) > 100:
|
||||
return jsonify({'status': 'error', 'message': 'Label must be a string (max 100 chars)'}), 400
|
||||
|
||||
manager = get_subghz_manager()
|
||||
result = manager.trim_capture(
|
||||
capture_id=capture_id,
|
||||
start_seconds=start_seconds,
|
||||
duration_seconds=duration_seconds,
|
||||
label=label,
|
||||
)
|
||||
|
||||
if result.get('status') == 'ok':
|
||||
return jsonify(result), 200
|
||||
message = str(result.get('message') or 'Trim failed')
|
||||
status_code = 404 if 'not found' in message.lower() else 400
|
||||
return jsonify(result), status_code
|
||||
|
||||
|
||||
@subghz_bp.route('/captures/<capture_id>', methods=['DELETE'])
|
||||
def delete_capture(capture_id: str):
|
||||
if not capture_id.isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
||||
|
||||
manager = get_subghz_manager()
|
||||
if manager.delete_capture(capture_id):
|
||||
return jsonify({'status': 'deleted', 'id': capture_id})
|
||||
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
|
||||
|
||||
|
||||
@subghz_bp.route('/captures/<capture_id>', methods=['PATCH'])
|
||||
def update_capture(capture_id: str):
|
||||
if not capture_id.isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
label = data.get('label', '')
|
||||
|
||||
if not isinstance(label, str) or len(label) > 100:
|
||||
return jsonify({'status': 'error', 'message': 'Label must be a string (max 100 chars)'}), 400
|
||||
|
||||
manager = get_subghz_manager()
|
||||
if manager.update_capture_label(capture_id, label):
|
||||
return jsonify({'status': 'updated', 'id': capture_id, 'label': label})
|
||||
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SSE STREAM
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@subghz_bp.route('/stream')
|
||||
def stream():
|
||||
response = Response(sse_stream(_subghz_queue), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
Reference in New Issue
Block a user