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:
Smittix
2026-02-12 23:30:37 +00:00
parent 4639146f05
commit 7c3ec9e920
46 changed files with 10792 additions and 462 deletions

3
.gitignore vendored
View File

@@ -58,6 +58,9 @@ intercept_agent_*.cfg
# Weather satellite runtime data (decoded images, samples, SatDump output)
data/weather_sat/
# SDR capture files (large IQ recordings)
data/subghz/captures/
# Env files
.env
.env.*

48
app.py
View File

@@ -182,6 +182,10 @@ dmr_lock = threading.Lock()
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
tscm_lock = threading.Lock()
# SubGHz Transceiver (HackRF)
subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
subghz_lock = threading.Lock()
# Deauth Attack Detection
deauth_detector = None
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
@@ -643,8 +647,27 @@ def export_bluetooth() -> Response:
})
@app.route('/health')
def health_check() -> Response:
def _get_subghz_active() -> bool:
"""Check if SubGHz manager has an active process."""
try:
from utils.subghz import get_subghz_manager
return get_subghz_manager().active_mode != 'idle'
except Exception:
return False
def _get_dmr_active() -> bool:
"""Check if Digital Voice decoder has an active process."""
try:
from routes import dmr as dmr_module
proc = dmr_module.dmr_dsd_process
return bool(dmr_module.dmr_running and proc and proc.poll() is None)
except Exception:
return False
@app.route('/health')
def health_check() -> Response:
"""Health check endpoint for monitoring."""
import time
return jsonify({
@@ -658,11 +681,12 @@ def health_check() -> Response:
'ais': ais_process is not None and (ais_process.poll() is None if ais_process else False),
'acars': acars_process is not None and (acars_process.poll() is None if acars_process else False),
'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False),
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False),
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
'dmr': dmr_process is not None and (dmr_process.poll() is None if dmr_process else False),
},
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False),
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
'dmr': _get_dmr_active(),
'subghz': _get_subghz_active(),
},
'data': {
'aircraft_count': len(adsb_aircraft),
'vessel_count': len(ais_vessels),
@@ -692,7 +716,8 @@ def kill_all() -> Response:
'airodump-ng', 'aireplay-ng', 'airmon-ng',
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher',
'hcitool', 'bluetoothctl', 'satdump', 'dsd',
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg'
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
'hackrf_transfer', 'hackrf_sweep'
]
for proc in processes_to_kill:
@@ -761,6 +786,13 @@ def kill_all() -> Response:
except Exception:
pass
# Reset SubGHz state
try:
from utils.subghz import get_subghz_manager
get_subghz_manager().stop_all()
except Exception:
pass
# Clear SDR device registry
with sdr_device_registry_lock:
sdr_device_registry.clear()

View File

@@ -227,6 +227,16 @@ WEATHER_SAT_PREDICTION_HOURS = _get_env_int('WEATHER_SAT_PREDICTION_HOURS', 24)
WEATHER_SAT_SCHEDULE_REFRESH_MINUTES = _get_env_int('WEATHER_SAT_SCHEDULE_REFRESH_MINUTES', 30)
WEATHER_SAT_CAPTURE_BUFFER_SECONDS = _get_env_int('WEATHER_SAT_CAPTURE_BUFFER_SECONDS', 30)
# SubGHz transceiver settings (HackRF)
SUBGHZ_DEFAULT_FREQUENCY = _get_env_float('SUBGHZ_FREQUENCY', 433.92)
SUBGHZ_DEFAULT_SAMPLE_RATE = _get_env_int('SUBGHZ_SAMPLE_RATE', 2000000)
SUBGHZ_DEFAULT_LNA_GAIN = _get_env_int('SUBGHZ_LNA_GAIN', 32)
SUBGHZ_DEFAULT_VGA_GAIN = _get_env_int('SUBGHZ_VGA_GAIN', 20)
SUBGHZ_DEFAULT_TX_GAIN = _get_env_int('SUBGHZ_TX_GAIN', 20)
SUBGHZ_MAX_TX_DURATION = _get_env_int('SUBGHZ_MAX_TX_DURATION', 10)
SUBGHZ_SWEEP_START_MHZ = _get_env_float('SUBGHZ_SWEEP_START', 300.0)
SUBGHZ_SWEEP_END_MHZ = _get_env_float('SUBGHZ_SWEEP_END', 928.0)
# Update checking
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)

View File

@@ -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

View File

@@ -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}")

View File

@@ -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),
]

View File

@@ -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:

View File

@@ -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):

View File

@@ -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:

424
routes/subghz.py Normal file
View File

@@ -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

View File

@@ -214,6 +214,8 @@ check_tools() {
check_required "multimon-ng" "Pager decoder" multimon-ng
check_required "rtl_433" "433MHz sensor decoder" rtl_433 rtl433
check_optional "rtlamr" "Utility meter decoder (requires Go)" rtlamr
check_optional "hackrf_transfer" "HackRF SubGHz transceiver" hackrf_transfer
check_optional "hackrf_sweep" "HackRF spectrum analyzer" hackrf_sweep
check_required "dump1090" "ADS-B decoder" dump1090
check_required "acarsdec" "ACARS decoder" acarsdec
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
@@ -746,6 +748,9 @@ install_macos_packages() {
progress "Installing rtl_433"
brew_install rtl_433
progress "Installing HackRF tools"
brew_install hackrf
progress "Installing rtlamr (optional)"
# rtlamr is optional - used for utility meter monitoring
if ! cmd_exists rtlamr; then
@@ -1169,6 +1174,9 @@ install_debian_packages() {
progress "Installing rtl_433"
apt_try_install_any rtl-433 rtl433 || warn "rtl-433 not available"
progress "Installing HackRF tools"
apt_install hackrf || warn "hackrf tools not available"
progress "Installing rtlamr (optional)"
# rtlamr is optional - used for utility meter monitoring
if ! cmd_exists rtlamr; then

View File

@@ -1448,6 +1448,7 @@ header h1 .tagline {
height: calc(100dvh - 96px);
height: calc(100vh - 96px); /* Fallback */
overflow: hidden;
position: relative;
}
@media (min-width: 1024px) {
@@ -1457,6 +1458,18 @@ header h1 .tagline {
height: calc(100dvh - 96px);
height: calc(100vh - 96px); /* Fallback */
}
.main-content.sidebar-collapsed {
grid-template-columns: 0 1fr;
}
.main-content.sidebar-collapsed > .sidebar {
width: 0;
min-width: 0;
padding: 0;
border-right: 0;
overflow: hidden;
}
}
.sidebar {
@@ -1480,6 +1493,63 @@ header h1 .tagline {
display: none;
}
.sidebar-collapse-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 10px;
margin-bottom: 6px;
border: 1px solid var(--border-color);
background: var(--bg-tertiary);
color: var(--text-secondary);
border-radius: 6px;
cursor: pointer;
font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.sidebar-collapse-btn:hover {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
.sidebar-expand-handle {
display: none;
position: absolute;
top: 12px;
left: 10px;
z-index: 12;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
border: 1px solid var(--border-color);
background: var(--bg-tertiary);
color: var(--accent-cyan);
border-radius: 6px;
cursor: pointer;
}
.main-content.sidebar-collapsed .sidebar-expand-handle {
display: inline-flex;
}
/* Reserve space for the expand handle so it doesn't overlap mode titles */
.main-content.sidebar-collapsed .output-header {
padding-left: 48px;
}
@media (max-width: 1023px) {
.sidebar-collapse-btn,
.sidebar-expand-handle {
display: none !important;
}
}
.section {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
@@ -1528,8 +1598,10 @@ header h1 .tagline {
.section.collapsed h3 {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 10px;
margin-bottom: 0 !important;
min-height: 0 !important;
padding-top: 10px !important;
padding-bottom: 10px !important;
}
.section.collapsed h3::after {
@@ -1538,7 +1610,8 @@ header h1 .tagline {
}
.section.collapsed {
padding-bottom: 0;
padding-bottom: 0 !important;
min-height: 0;
}
.section.collapsed>*:not(h3) {
@@ -2313,6 +2386,45 @@ header h1 .tagline {
display: block;
}
/* Normalize spacing for all sidebar mode panels */
.sidebar .mode-content.active:not(#meshtasticMode) {
display: flex;
flex-direction: column;
gap: 10px;
}
.sidebar .mode-content.active:not(#meshtasticMode) > * {
margin: 0 !important;
}
.sidebar .mode-content.active:not(#meshtasticMode) > .section {
margin: 0 !important;
}
.mode-actions-bottom {
display: flex;
flex-direction: column;
gap: 8px;
}
.sidebar .mode-content.active:not(#meshtasticMode) > .mode-actions-bottom {
margin-top: auto !important;
}
#btMessageContainer:empty {
display: none;
}
.alpha-mode-notice {
padding: 8px 10px;
border: 1px solid rgba(245, 158, 11, 0.45);
background: rgba(245, 158, 11, 0.12);
color: var(--accent-yellow);
border-radius: 6px;
font-size: 10px;
line-height: 1.45;
}
/* Aircraft (ADS-B) Styles */
.aircraft-card {
padding: 12px;

View File

@@ -326,3 +326,50 @@
.aprs-meter-status.no-signal {
color: var(--accent-yellow);
}
/* APRS map markers (flat SVG icons) */
.aprs-map-marker-wrap {
background: transparent;
border: none;
}
.aprs-map-marker {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 7px 2px 5px;
border-radius: 999px;
border: 1px solid rgba(74, 158, 255, 0.35);
background: rgba(10, 18, 28, 0.88);
color: var(--text-primary);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
}
.aprs-map-marker-icon {
display: inline-flex;
width: 14px;
height: 14px;
color: var(--accent-cyan);
}
.aprs-map-marker-icon svg {
width: 14px;
height: 14px;
display: block;
fill: currentColor;
}
.aprs-map-marker-label {
font-size: 9px;
font-weight: 600;
line-height: 1;
letter-spacing: 0.02em;
}
.aprs-map-marker.vehicle .aprs-map-marker-icon {
color: var(--accent-green);
}
.aprs-map-marker.tower .aprs-map-marker-icon {
color: var(--accent-cyan);
}

View File

@@ -340,7 +340,9 @@
MODE VISIBILITY - Ensure sidebar shows when active
============================================ */
#spystationsMode.active {
display: block !important;
display: flex !important;
flex-direction: column;
gap: 10px;
}
/* ============================================

View File

@@ -7,7 +7,9 @@
MODE VISIBILITY
============================================ */
#sstvGeneralMode.active {
display: block !important;
display: flex !important;
flex-direction: column;
gap: 10px;
}
/* ============================================

View File

@@ -7,7 +7,9 @@
MODE VISIBILITY
============================================ */
#sstvMode.active {
display: block !important;
display: flex !important;
flex-direction: column;
gap: 10px;
}
/* ============================================

2086
static/css/modes/subghz.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -114,7 +114,7 @@
position: fixed;
top: 0;
left: 0;
width: min(320px, 85vw);
width: min(360px, 100vw);
height: 100dvh;
height: 100vh; /* Fallback */
background: var(--bg-secondary, #0f1218);
@@ -381,6 +381,33 @@
-webkit-overflow-scrolling: touch;
}
.sidebar {
padding: 10px;
gap: 10px;
}
.output-panel {
min-height: 58vh;
}
.output-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.header-controls {
width: 100%;
gap: 8px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 2px;
}
.header-controls .stats {
min-width: max-content;
}
/* Container should not clip content */
.container {
overflow: visible;

View File

@@ -24,7 +24,7 @@
background: var(--bg-dark, #0a0a0f);
border: 1px solid var(--border-color, #1a1a2e);
border-radius: 8px;
max-width: 600px;
max-width: 900px;
width: 100%;
max-height: calc(100vh - 80px);
display: flex;
@@ -74,22 +74,28 @@
/* Settings Tabs */
.settings-tabs {
display: flex;
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
border-bottom: 1px solid var(--border-color, #1a1a2e);
padding: 0 20px;
gap: 4px;
gap: 0;
}
.settings-tab {
background: none;
border: none;
padding: 12px 16px;
padding: 12px 10px;
color: var(--text-muted, #666);
font-size: 13px;
font-weight: 500;
cursor: pointer;
position: relative;
transition: color 0.2s;
min-width: 0;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.settings-tab:hover {
@@ -474,6 +480,12 @@
}
/* Responsive */
@media (max-width: 960px) {
.settings-tabs {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.settings-modal.active {
padding: 20px 10px;
@@ -483,6 +495,18 @@
max-width: 100%;
}
.settings-tabs {
padding: 0 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.settings-tab {
padding: 10px 6px;
font-size: 11px;
white-space: normal;
line-height: 1.2;
}
.settings-row {
flex-direction: column;
align-items: flex-start;

View File

@@ -488,10 +488,12 @@ function initApp() {
});
});
// Collapse all sections by default (except SDR Device which is first)
document.querySelectorAll('.section').forEach((section, index) => {
if (index > 0) {
// Collapse sidebar menu sections by default, but skip headerless utility blocks.
document.querySelectorAll('.sidebar .section').forEach((section) => {
if (section.querySelector('h3')) {
section.classList.add('collapsed');
} else {
section.classList.remove('collapsed');
}
});

View File

@@ -17,6 +17,7 @@ let dmrHasAudio = false;
let dmrBookmarks = [];
const DMR_BOOKMARKS_KEY = 'dmrBookmarks';
const DMR_SETTINGS_KEY = 'dmrSettings';
const DMR_BOOKMARK_PROTOCOLS = new Set(['auto', 'dmr', 'p25', 'nxdn', 'dstar', 'provoice']);
// ============== SYNTHESIZER STATE ==============
let dmrSynthCanvas = null;
@@ -44,9 +45,16 @@ function checkDmrTools() {
const warningText = document.getElementById('dmrToolsWarningText');
if (!warning) return;
const selectedType = (typeof getSelectedSDRType === 'function')
? getSelectedSDRType()
: 'rtlsdr';
const missing = [];
if (!data.dsd) missing.push('dsd (Digital Speech Decoder)');
if (!data.rtl_fm) missing.push('rtl_fm (RTL-SDR)');
if (selectedType === 'rtlsdr') {
if (!data.rtl_fm) missing.push('rtl_fm (RTL-SDR)');
} else if (!data.rx_fm) {
missing.push('rx_fm (SoapySDR demodulator)');
}
if (!data.ffmpeg) missing.push('ffmpeg (audio output — optional)');
if (missing.length > 0) {
@@ -71,6 +79,7 @@ function startDmr() {
const ppm = parseInt(document.getElementById('dmrPPM')?.value || 0);
const relaxCrc = document.getElementById('dmrRelaxCrc')?.checked || false;
const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0;
const sdrType = (typeof getSelectedSDRType === 'function') ? getSelectedSDRType() : 'rtlsdr';
// Use protocol name for device reservation so panel shows "D-STAR", "P25", etc.
dmrModeLabel = protocol !== 'auto' ? protocol : 'dmr';
@@ -90,7 +99,7 @@ function startDmr() {
fetch('/dmr/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ frequency, protocol, gain, device, ppm, relaxCrc })
body: JSON.stringify({ frequency, protocol, gain, device, ppm, relaxCrc, sdr_type: sdrType })
})
.then(r => r.json())
.then(data => {
@@ -630,7 +639,26 @@ function restoreDmrSettings() {
function loadDmrBookmarks() {
try {
const saved = localStorage.getItem(DMR_BOOKMARKS_KEY);
dmrBookmarks = saved ? JSON.parse(saved) : [];
const parsed = saved ? JSON.parse(saved) : [];
if (!Array.isArray(parsed)) {
dmrBookmarks = [];
} else {
dmrBookmarks = parsed
.map((entry) => {
const freq = Number(entry?.freq);
if (!Number.isFinite(freq) || freq <= 0) return null;
const protocol = sanitizeDmrBookmarkProtocol(entry?.protocol);
const rawLabel = String(entry?.label || '').trim();
const label = rawLabel || `${freq.toFixed(4)} MHz`;
return {
freq,
protocol,
label,
added: entry?.added,
};
})
.filter(Boolean);
}
} catch (e) {
dmrBookmarks = [];
}
@@ -643,6 +671,11 @@ function saveDmrBookmarks() {
} catch (e) { /* localStorage unavailable */ }
}
function sanitizeDmrBookmarkProtocol(protocol) {
const value = String(protocol || 'auto').toLowerCase();
return DMR_BOOKMARK_PROTOCOLS.has(value) ? value : 'auto';
}
function addDmrBookmark() {
const freqInput = document.getElementById('dmrBookmarkFreq');
const labelInput = document.getElementById('dmrBookmarkLabel');
@@ -656,7 +689,7 @@ function addDmrBookmark() {
return;
}
const protocol = document.getElementById('dmrProtocol')?.value || 'auto';
const protocol = sanitizeDmrBookmarkProtocol(document.getElementById('dmrProtocol')?.value || 'auto');
const label = (labelInput?.value || '').trim() || `${freq.toFixed(4)} MHz`;
// Duplicate check
@@ -696,26 +729,73 @@ function removeDmrBookmark(index) {
function dmrQuickTune(freq, protocol) {
const freqEl = document.getElementById('dmrFrequency');
const protoEl = document.getElementById('dmrProtocol');
if (freqEl) freqEl.value = freq;
if (protoEl) protoEl.value = protocol;
if (freqEl && Number.isFinite(freq)) freqEl.value = freq;
if (protoEl) protoEl.value = sanitizeDmrBookmarkProtocol(protocol);
}
function renderDmrBookmarks() {
const container = document.getElementById('dmrBookmarksList');
if (!container) return;
container.replaceChildren();
if (dmrBookmarks.length === 0) {
container.innerHTML = '<div style="color: var(--text-muted); text-align: center; padding: 10px; font-size: 11px;">No bookmarks saved</div>';
const emptyEl = document.createElement('div');
emptyEl.style.color = 'var(--text-muted)';
emptyEl.style.textAlign = 'center';
emptyEl.style.padding = '10px';
emptyEl.style.fontSize = '11px';
emptyEl.textContent = 'No bookmarks saved';
container.appendChild(emptyEl);
return;
}
container.innerHTML = dmrBookmarks.map((b, i) => `
<div style="display: flex; justify-content: space-between; align-items: center; padding: 4px 6px; background: rgba(0,0,0,0.2); border-radius: 3px; margin-bottom: 3px;">
<span style="cursor: pointer; color: var(--accent-cyan); font-size: 11px; flex: 1;" onclick="dmrQuickTune(${b.freq}, '${b.protocol}')" title="${b.freq.toFixed(4)} MHz (${b.protocol.toUpperCase()})">${b.label}</span>
<span style="color: var(--text-muted); font-size: 9px; margin: 0 6px;">${b.protocol.toUpperCase()}</span>
<button onclick="removeDmrBookmark(${i})" style="background: none; border: none; color: var(--accent-red); cursor: pointer; font-size: 12px; padding: 0 4px;">&times;</button>
</div>
`).join('');
dmrBookmarks.forEach((b, i) => {
const row = document.createElement('div');
row.style.display = 'flex';
row.style.justifyContent = 'space-between';
row.style.alignItems = 'center';
row.style.padding = '4px 6px';
row.style.background = 'rgba(0,0,0,0.2)';
row.style.borderRadius = '3px';
row.style.marginBottom = '3px';
const tuneBtn = document.createElement('button');
tuneBtn.type = 'button';
tuneBtn.style.cursor = 'pointer';
tuneBtn.style.color = 'var(--accent-cyan)';
tuneBtn.style.fontSize = '11px';
tuneBtn.style.flex = '1';
tuneBtn.style.background = 'none';
tuneBtn.style.border = 'none';
tuneBtn.style.textAlign = 'left';
tuneBtn.style.padding = '0';
tuneBtn.textContent = b.label;
tuneBtn.title = `${b.freq.toFixed(4)} MHz (${b.protocol.toUpperCase()})`;
tuneBtn.addEventListener('click', () => dmrQuickTune(b.freq, b.protocol));
const protocolEl = document.createElement('span');
protocolEl.style.color = 'var(--text-muted)';
protocolEl.style.fontSize = '9px';
protocolEl.style.margin = '0 6px';
protocolEl.textContent = b.protocol.toUpperCase();
const deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.style.background = 'none';
deleteBtn.style.border = 'none';
deleteBtn.style.color = 'var(--accent-red)';
deleteBtn.style.cursor = 'pointer';
deleteBtn.style.fontSize = '12px';
deleteBtn.style.padding = '0 4px';
deleteBtn.textContent = '\u00d7';
deleteBtn.addEventListener('click', () => removeDmrBookmark(i));
row.appendChild(tuneBtn);
row.appendChild(protocolEl);
row.appendChild(deleteBtn);
container.appendChild(row);
});
}
// ============== STATUS SYNC ==============

View File

@@ -3273,7 +3273,7 @@ async function syncWaterfallToFrequency(freq, options = {}) {
span_mhz: Math.max(0.1, ef - sf),
gain: g,
device: dev,
sdr_type: (typeof getSelectedSdrType === 'function') ? getSelectedSdrType() : 'rtlsdr',
sdr_type: (typeof getSelectedSDRType === 'function') ? getSelectedSDRType() : 'rtlsdr',
fft_size: fft,
fps: 25,
avg_count: 4,
@@ -3318,7 +3318,7 @@ async function zoomWaterfall(direction) {
span_mhz: Math.max(0.1, ef - sf),
gain: g,
device: dev,
sdr_type: (typeof getSelectedSdrType === 'function') ? getSelectedSdrType() : 'rtlsdr',
sdr_type: (typeof getSelectedSDRType === 'function') ? getSelectedSDRType() : 'rtlsdr',
fft_size: fft,
fps: 25,
avg_count: 4,
@@ -3817,7 +3817,7 @@ async function startWaterfall(options = {}) {
span_mhz: spanMhz,
gain: gain,
device: device,
sdr_type: (typeof getSelectedSdrType === 'function') ? getSelectedSdrType() : 'rtlsdr',
sdr_type: (typeof getSelectedSDRType === 'function') ? getSelectedSDRType() : 'rtlsdr',
fft_size: fftSize,
fps: 25,
avg_count: 4,

2746
static/js/modes/subghz.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -160,7 +160,7 @@ const WeatherSat = (function() {
const biasTInput = document.getElementById('weatherSatBiasT');
const deviceSelect = document.getElementById('deviceSelect');
const satellite = satSelect?.value || 'NOAA-18';
const satellite = satSelect?.value || 'METEOR-M2-3';
const gain = parseFloat(gainInput?.value || '40');
const biasT = biasTInput?.checked || false;
const device = parseInt(deviceSelect?.value || '0', 10);
@@ -237,7 +237,7 @@ const WeatherSat = (function() {
const fileInput = document.getElementById('wxsatTestFilePath');
const rateSelect = document.getElementById('wxsatTestSampleRate');
const satellite = satSelect?.value || 'NOAA-18';
const satellite = satSelect?.value || 'METEOR-M2-3';
const inputFile = (fileInput?.value || '').trim();
const sampleRate = parseInt(rateSelect?.value || '1000000', 10);

View File

@@ -63,6 +63,7 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/sstv.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/weather-satellite.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/sstv-general.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/subghz.css') }}?v={{ version }}&r=subghz_layout9">
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/function-strip.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/toast.css') }}">
@@ -190,9 +191,9 @@
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg></span>
<span class="mode-name">Meshtastic</span>
</button>
<button class="mode-card mode-card-sm" onclick="selectMode('dmr')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="22"/></svg></span>
<span class="mode-name">Digital Voice</span>
<button class="mode-card mode-card-sm" onclick="selectMode('subghz')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg></span>
<span class="mode-name">SubGHz</span>
</button>
<button class="mode-card mode-card-sm" onclick="selectMode('websdr')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span>
@@ -387,6 +388,10 @@
<div class="container">
<div class="main-content">
<div class="sidebar mobile-drawer" id="mainSidebar">
<button class="sidebar-collapse-btn" id="sidebarCollapseBtn" onclick="toggleMainSidebarCollapse()" title="Collapse sidebar">
<span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg></span>
Collapse Sidebar
</button>
<!-- Agent Selector -->
<div class="section" id="agentSection">
<h3>Signal Source</h3>
@@ -547,6 +552,8 @@
{% include 'partials/modes/websdr.html' %}
{% include 'partials/modes/subghz.html' %}
<button class="preset-btn" onclick="killAll()"
style="width: 100%; margin-top: 10px; border-color: #ff3366; color: #ff3366;">
Kill All Processes
@@ -554,6 +561,9 @@
</div>
<div class="output-panel">
<button class="sidebar-expand-handle" id="sidebarExpandHandle" onclick="toggleMainSidebarCollapse()" title="Expand sidebar">
<span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg></span>
</button>
<div class="output-header">
<h3 id="outputTitle">Pager Decoder</h3>
<div class="header-controls">
@@ -1759,6 +1769,301 @@
</div>
</div>
<!-- SubGHz Transceiver Dashboard -->
<div id="subghzVisuals" class="subghz-visuals-container" style="display: none;">
<!-- Stats Strip -->
<div class="subghz-stats-strip">
<div class="subghz-strip-group">
<span class="subghz-strip-device-badge" id="subghzStripDevice">
<span class="subghz-strip-device-dot" id="subghzStripDeviceDot"></span>
HackRF
</span>
<div class="subghz-strip-status">
<span class="subghz-strip-dot" id="subghzStripDot"></span>
<span class="subghz-strip-status-text" id="subghzStripStatus">Idle</span>
</div>
</div>
<div class="subghz-strip-divider"></div>
<div class="subghz-strip-group">
<div class="subghz-strip-stat">
<span class="subghz-strip-value accent-cyan" id="subghzStripFreq">--</span>
<span class="subghz-strip-label">MHZ</span>
</div>
<div class="subghz-strip-stat">
<span class="subghz-strip-value" id="subghzStripMode">--</span>
<span class="subghz-strip-label">MODE</span>
</div>
</div>
<div class="subghz-strip-divider"></div>
<div class="subghz-strip-group">
<div class="subghz-strip-stat">
<span class="subghz-strip-value accent-green" id="subghzStripSignals">0</span>
<span class="subghz-strip-label">SIGNALS</span>
</div>
<div class="subghz-strip-stat">
<span class="subghz-strip-value accent-orange" id="subghzStripCaptures">0</span>
<span class="subghz-strip-label">CAPTURES</span>
</div>
</div>
<div class="subghz-strip-divider"></div>
<div class="subghz-strip-group">
<span class="subghz-strip-timer" id="subghzStripTimer"></span>
</div>
</div>
<!-- Signal Console (collapsible) -->
<div class="subghz-signal-console" id="subghzConsole" style="display: none;">
<div class="subghz-console-header" onclick="SubGhz.toggleConsole()">
<div class="subghz-phase-strip">
<span class="subghz-phase-step" id="subghzPhaseTuning">TUNING</span>
<span class="subghz-phase-arrow">&#9656;</span>
<span class="subghz-phase-step" id="subghzPhaseListening">LISTENING</span>
<span class="subghz-phase-arrow">&#9656;</span>
<span class="subghz-phase-step" id="subghzPhaseDecoding">DECODING</span>
</div>
<div class="subghz-burst-indicator" id="subghzBurstIndicator" title="Live burst detector">
<span class="subghz-burst-dot"></span>
<span class="subghz-burst-text" id="subghzBurstText">NO BURST</span>
</div>
<button class="subghz-console-toggle" id="subghzConsoleToggleBtn">&#9660;</button>
</div>
<div class="subghz-console-body" id="subghzConsoleBody">
<div class="subghz-console-log" id="subghzConsoleLog"></div>
</div>
</div>
<!-- Action Hub (idle state — 2x2 Flipper-style cards) -->
<div class="subghz-action-hub" id="subghzActionHub">
<div class="subghz-hub-header">
<div class="subghz-hub-header-title">HackRF One</div>
<div class="subghz-hub-header-sub">SubGHz Transceiver &mdash; 1 MHz - 6 GHz</div>
</div>
<div class="subghz-hub-grid">
<div class="subghz-hub-card subghz-hub-card--green" onclick="SubGhz.hubAction('rx')">
<div class="subghz-hub-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="32" height="32"><circle cx="12" cy="12" r="3"/><path d="M12 1v4m0 14v4M1 12h4m14 0h4"/><path d="M5.6 5.6l2.85 2.85m7.1 7.1l2.85 2.85M5.6 18.4l2.85-2.85m7.1-7.1l2.85-2.85"/></svg>
</div>
<div class="subghz-hub-title">Read RAW</div>
<div class="subghz-hub-desc">Capture raw IQ via hackrf_transfer</div>
</div>
<div class="subghz-hub-card subghz-hub-card--red" onclick="SubGhz.hubAction('txselect')">
<div class="subghz-hub-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="32" height="32"><path d="M5 19h14"/><path d="M12 5v11"/><path d="M8 9l4-4 4 4"/></svg>
</div>
<div class="subghz-hub-title">Transmit</div>
<div class="subghz-hub-desc">Replay a saved capture</div>
</div>
<div class="subghz-hub-card subghz-hub-card--orange" onclick="SubGhz.hubAction('sweep')">
<div class="subghz-hub-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="32" height="32"><path d="M3 20h18"/><path d="M3 17l3-7 3 4 3-9 3 6 3-3 3 9"/></svg>
</div>
<div class="subghz-hub-title">Freq Analyzer</div>
<div class="subghz-hub-desc">Wideband sweep via hackrf_sweep</div>
</div>
<div class="subghz-hub-card subghz-hub-card--purple" onclick="SubGhz.hubAction('saved')">
<div class="subghz-hub-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="32" height="32"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
</div>
<div class="subghz-hub-title">Saved</div>
<div class="subghz-hub-desc">Signal library & replay</div>
</div>
</div>
</div>
<!-- Operation Panels (one visible at a time, replaces hub) -->
<!-- RX (Raw Capture) Panel -->
<div class="subghz-op-panel" id="subghzPanelRx" style="display: none;">
<div class="subghz-op-panel-header">
<button class="subghz-op-back-btn" onclick="SubGhz.backToHub()" title="Back to hub">&#9664; Back</button>
<span class="subghz-op-panel-title">Read RAW — Signal Capture</span>
<div class="subghz-op-panel-actions">
<button class="subghz-btn start" id="subghzRxStartBtnPanel" onclick="SubGhz.startRx()">Start</button>
<button class="subghz-btn stop" id="subghzRxStopBtnPanel" onclick="SubGhz.stopRx()" disabled>Stop</button>
</div>
</div>
<div class="subghz-rx-display">
<div class="subghz-rx-recording" id="subghzRxRecording" style="display: none;">
<span class="subghz-rx-rec-dot"></span>
<span>RECORDING</span>
</div>
<div class="subghz-rx-info-grid">
<div class="subghz-rx-info-item">
<span class="subghz-rx-info-label">FREQUENCY</span>
<span class="subghz-rx-info-value accent-cyan" id="subghzRxFreq">--</span>
</div>
<div class="subghz-rx-info-item">
<span class="subghz-rx-info-label">LNA GAIN</span>
<span class="subghz-rx-info-value" id="subghzRxLna">--</span>
</div>
<div class="subghz-rx-info-item">
<span class="subghz-rx-info-label">VGA GAIN</span>
<span class="subghz-rx-info-value" id="subghzRxVga">--</span>
</div>
<div class="subghz-rx-info-item">
<span class="subghz-rx-info-label">SAMPLE RATE</span>
<span class="subghz-rx-info-value" id="subghzRxSampleRate">--</span>
</div>
<div class="subghz-rx-info-item">
<span class="subghz-rx-info-label">FILE SIZE</span>
<span class="subghz-rx-info-value" id="subghzRxFileSize">0 KB</span>
</div>
<div class="subghz-rx-info-item">
<span class="subghz-rx-info-label">DATA RATE</span>
<span class="subghz-rx-info-value" id="subghzRxRate">0 KB/s</span>
</div>
</div>
<div class="subghz-rx-level-wrapper">
<span class="subghz-rx-level-label">SIGNAL</span>
<span class="subghz-rx-burst-pill" id="subghzRxBurstPill">IDLE</span>
<div class="subghz-rx-level-bar">
<div class="subghz-rx-level-fill" id="subghzRxLevel" style="width: 0%;"></div>
</div>
</div>
<div class="subghz-rx-hint" id="subghzRxHint">
<span class="subghz-rx-hint-label">ANALYSIS</span>
<span class="subghz-rx-hint-text" id="subghzRxHintText">No modulation hint yet</span>
<span class="subghz-rx-hint-confidence" id="subghzRxHintConfidence">--</span>
</div>
<div class="subghz-rx-scope-wrap">
<span class="subghz-rx-scope-label">WAVEFORM</span>
<div class="subghz-rx-scope">
<canvas id="subghzRxScope"></canvas>
</div>
</div>
<div class="subghz-rx-scope-wrap">
<div class="subghz-rx-waterfall-header">
<span class="subghz-rx-scope-label">WATERFALL</span>
<div class="subghz-rx-waterfall-controls">
<div class="subghz-wf-control">
<span>FLOOR</span>
<input type="range" id="subghzWfFloor" min="0" max="200" value="20" oninput="SubGhz.setWaterfallFloor(this.value)">
<span class="subghz-wf-value" id="subghzWfFloorVal">20</span>
</div>
<div class="subghz-wf-control">
<span>RANGE</span>
<input type="range" id="subghzWfRange" min="16" max="255" value="180" oninput="SubGhz.setWaterfallRange(this.value)">
<span class="subghz-wf-value" id="subghzWfRangeVal">180</span>
</div>
<button class="subghz-wf-pause-btn" id="subghzWfPauseBtn" onclick="SubGhz.toggleWaterfall()">PAUSE</button>
</div>
</div>
<div class="subghz-rx-waterfall">
<canvas id="subghzRxWaterfall"></canvas>
</div>
</div>
</div>
</div>
<!-- Sweep Panel -->
<div class="subghz-op-panel" id="subghzPanelSweep" style="display: none;">
<div class="subghz-op-panel-header">
<button class="subghz-op-back-btn" onclick="SubGhz.backToHub()" title="Back to hub">&#9664; Back</button>
<span class="subghz-op-panel-title">Frequency Analyzer</span>
<div class="subghz-op-panel-actions">
<button class="subghz-btn start" id="subghzSweepStartBtnPanel" onclick="SubGhz.startSweep()">Start</button>
<button class="subghz-btn stop" id="subghzSweepStopBtnPanel" onclick="SubGhz.stopSweep()" disabled>Stop</button>
</div>
</div>
<div class="subghz-sweep-layout">
<div class="subghz-sweep-chart-wrapper" id="subghzSweepChartWrapper">
<canvas id="subghzSweepCanvas"></canvas>
</div>
<div class="subghz-sweep-peaks-sidebar" id="subghzSweepPeaksSidebar">
<div class="subghz-sweep-peaks-title">PEAKS</div>
<div class="subghz-peak-list" id="subghzSweepPeakList"></div>
</div>
</div>
</div>
<!-- TX Panel -->
<div class="subghz-op-panel" id="subghzPanelTx" style="display: none;">
<div class="subghz-op-panel-header">
<button class="subghz-op-back-btn" onclick="SubGhz.backToHub()" title="Back to hub">&#9664; Back</button>
<span class="subghz-op-panel-title">Transmit</span>
</div>
<div class="subghz-tx-display" id="subghzTxDisplay">
<div class="subghz-tx-pulse-ring">
<div class="subghz-tx-pulse-dot"></div>
</div>
<div class="subghz-tx-label" id="subghzTxStateLabel">READY</div>
<div class="subghz-tx-info-grid">
<div class="subghz-tx-info-item">
<span class="subghz-tx-info-label">FREQUENCY</span>
<span class="subghz-tx-info-value accent-red" id="subghzTxFreqDisplay">--</span>
</div>
<div class="subghz-tx-info-item">
<span class="subghz-tx-info-label">TX GAIN</span>
<span class="subghz-tx-info-value" id="subghzTxGainDisplay">--</span>
</div>
<div class="subghz-tx-info-item">
<span class="subghz-tx-info-label">ELAPSED</span>
<span class="subghz-tx-info-value" id="subghzTxElapsed">0s</span>
</div>
</div>
<div class="subghz-btn-row" style="max-width: 420px; margin: 16px auto 0;">
<button class="subghz-btn" id="subghzTxChooseCaptureBtn" onclick="SubGhz.showPanel('saved')">Choose Capture</button>
<button class="subghz-btn stop" id="subghzTxStopBtn" onclick="SubGhz.stopTx()">Stop Transmission</button>
<button class="subghz-btn start" id="subghzTxReplayLastBtn" onclick="SubGhz.replayLastTx()" style="display: none;">Replay Last</button>
</div>
</div>
</div>
<!-- Saved Panel -->
<div class="subghz-op-panel" id="subghzPanelSaved" style="display: none;">
<div class="subghz-op-panel-header">
<button class="subghz-op-back-btn" onclick="SubGhz.backToHub()" title="Back to hub">&#9664; Back</button>
<span class="subghz-op-panel-title">Saved Captures</span>
<div class="subghz-op-panel-actions subghz-saved-actions">
<span class="subghz-saved-selection-count" id="subghzSavedSelectionCount" style="display: none;">0 selected</span>
<button class="subghz-btn" id="subghzSavedSelectBtn" onclick="SubGhz.toggleCaptureSelectMode()">Select</button>
<button class="subghz-btn" id="subghzSavedSelectAllBtn" onclick="SubGhz.selectAllCaptures()" style="display: none;">Select All</button>
<button class="subghz-btn stop" id="subghzSavedDeleteSelectedBtn" onclick="SubGhz.deleteSelectedCaptures()" style="display: none;" disabled>Delete Selected</button>
</div>
</div>
<div class="subghz-captures-list subghz-captures-list-main" id="subghzCapturesList" style="flex: 1; min-height: 0; max-height: none; overflow-y: auto;">
<div class="subghz-empty" id="subghzCapturesEmpty">No captures yet</div>
</div>
</div>
<!-- TX Confirmation Modal -->
<div id="subghzTxModalOverlay" class="subghz-tx-modal-overlay">
<div class="subghz-tx-modal">
<h3>Confirm Transmission</h3>
<p>You are about to transmit a radio signal on:</p>
<p class="tx-freq" id="subghzTxModalFreq">--- MHz</p>
<p class="tx-duration">Capture duration: <span id="subghzTxModalDuration">--</span></p>
<div class="subghz-tx-segment-box">
<label class="subghz-tx-segment-toggle">
<input type="checkbox" id="subghzTxSegmentEnabled" onchange="SubGhz.syncTxSegmentSelection()">
Transmit selected segment only
</label>
<div class="subghz-tx-segment-grid">
<label>Start (s)</label>
<input type="number" id="subghzTxSegmentStart" min="0" step="0.01" value="0" disabled oninput="SubGhz.syncTxSegmentSelection('start')">
<label>End (s)</label>
<input type="number" id="subghzTxSegmentEnd" min="0" step="0.01" value="0" disabled oninput="SubGhz.syncTxSegmentSelection('end')">
</div>
<p class="subghz-tx-segment-summary" id="subghzTxSegmentSummary">Full capture</p>
</div>
<div class="subghz-tx-burst-assist" id="subghzTxBurstAssist" style="display: none;">
<div class="subghz-tx-burst-title">Detected Bursts</div>
<div class="subghz-tx-burst-timeline" id="subghzTxBurstTimeline"></div>
<div class="subghz-tx-burst-range" id="subghzTxBurstRange">Drag on timeline to select TX segment</div>
<div class="subghz-tx-burst-list" id="subghzTxBurstList"></div>
</div>
<p>Ensure you have proper authorization to transmit on this frequency.</p>
<div class="subghz-tx-modal-actions">
<button class="subghz-tx-cancel-btn" onclick="SubGhz.cancelTx()">Cancel</button>
<button class="subghz-tx-trim-btn" id="subghzTxTrimBtn" onclick="SubGhz.trimCaptureSelection()">Trim + Save</button>
<button class="subghz-tx-confirm-btn" onclick="SubGhz.confirmTx()">Transmit</button>
</div>
</div>
</div>
</div>
<!-- WebSDR Dashboard -->
<div id="websdrVisuals" style="display: none; padding: 12px; flex-direction: column; gap: 12px; flex: 1; min-height: 0; overflow: hidden;">
<!-- Audio Control Bar (hidden until connected) -->
@@ -2538,6 +2843,7 @@
<script src="{{ url_for('static', filename='js/modes/sstv-general.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/dmr.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/websdr.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/subghz.js') }}?v={{ version }}&r=subghz_layout9"></script>
<script>
// ============================================
@@ -2673,7 +2979,7 @@
const validModes = new Set([
'pager', 'sensor', 'rtlamr', 'aprs', 'listening',
'spystations', 'meshtastic', 'wifi', 'bluetooth',
'tscm', 'satellite', 'sstv', 'weathersat', 'sstv_general', 'dmr', 'websdr'
'tscm', 'satellite', 'sstv', 'weathersat', 'sstv_general', 'websdr', 'subghz'
]);
function getModeFromQuery() {
@@ -3006,6 +3312,41 @@
return parsed;
}
const SIDEBAR_COLLAPSE_KEY = 'mainSidebarCollapsed';
function setMainSidebarCollapsed(collapsed) {
const mainContent = document.querySelector('.main-content');
const collapseBtn = document.getElementById('sidebarCollapseBtn');
if (!mainContent) return;
mainContent.classList.toggle('sidebar-collapsed', collapsed);
if (collapseBtn) {
collapseBtn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
}
localStorage.setItem(SIDEBAR_COLLAPSE_KEY, collapsed ? 'true' : 'false');
}
function toggleMainSidebarCollapse(forceState = null) {
const mainContent = document.querySelector('.main-content');
if (!mainContent || window.innerWidth < 1024) return;
const collapsed = mainContent.classList.contains('sidebar-collapsed');
const nextState = forceState === null ? !collapsed : !!forceState;
setMainSidebarCollapsed(nextState);
}
function applySidebarCollapsePreference() {
const mainContent = document.querySelector('.main-content');
if (!mainContent) return;
if (window.innerWidth < 1024) {
mainContent.classList.remove('sidebar-collapsed');
return;
}
const savedCollapsed = localStorage.getItem(SIDEBAR_COLLAPSE_KEY) === 'true';
setMainSidebarCollapsed(savedCollapsed);
}
window.addEventListener('resize', applySidebarCollapsePreference);
// Make sections collapsible
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('.section h3').forEach(h3 => {
@@ -3014,14 +3355,17 @@
});
});
// Collapse all sections by default (except Signal Source and SDR Device)
document.querySelectorAll('.section').forEach((section, index) => {
// Keep first two sections expanded (Signal Source, SDR Device), collapse rest
if (index > 1) {
// Collapse sidebar menu sections by default, but skip headerless utility blocks.
document.querySelectorAll('.sidebar .section').forEach((section) => {
if (section.querySelector('h3')) {
section.classList.add('collapsed');
} else {
section.classList.remove('collapsed');
}
});
applySidebarCollapsePreference();
// Load bias-T setting from localStorage
loadBiasTSetting();
@@ -3095,7 +3439,8 @@
'tscm': 'security',
'rtlamr': 'sdr', 'ais': 'sdr', 'spystations': 'sdr',
'meshtastic': 'sdr',
'satellite': 'space', 'sstv': 'space', 'weathersat': 'space', 'sstv_general': 'space'
'satellite': 'space', 'sstv': 'space', 'weathersat': 'space', 'sstv_general': 'space',
'subghz': 'sdr'
};
// Remove has-active from all dropdowns
@@ -3141,6 +3486,11 @@
if (isTscmRunning) stopTscmSweep();
}
// Clean up SubGHz SSE connection when leaving the mode
if (typeof SubGhz !== 'undefined' && currentMode === 'subghz' && mode !== 'subghz') {
SubGhz.destroy();
}
currentMode = mode;
if (updateUrl) {
updateModeUrl(mode);
@@ -3190,6 +3540,7 @@
document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic');
document.getElementById('dmrMode')?.classList.toggle('active', mode === 'dmr');
document.getElementById('websdrMode')?.classList.toggle('active', mode === 'websdr');
document.getElementById('subghzMode')?.classList.toggle('active', mode === 'subghz');
const pagerStats = document.getElementById('pagerStats');
const sensorStats = document.getElementById('sensorStats');
const satelliteStats = document.getElementById('satelliteStats');
@@ -3227,7 +3578,8 @@
'spystations': 'SPY STATIONS',
'meshtastic': 'MESHTASTIC',
'dmr': 'DIGITAL VOICE',
'websdr': 'WEBSDR'
'websdr': 'WEBSDR',
'subghz': 'SUBGHZ'
};
const activeModeIndicator = document.getElementById('activeModeIndicator');
if (activeModeIndicator) activeModeIndicator.innerHTML = '<span class="pulse-dot"></span>' + (modeNames[mode] || mode.toUpperCase());
@@ -3244,6 +3596,7 @@
const sstvGeneralVisuals = document.getElementById('sstvGeneralVisuals');
const dmrVisuals = document.getElementById('dmrVisuals');
const websdrVisuals = document.getElementById('websdrVisuals');
const subghzVisuals = document.getElementById('subghzVisuals');
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none';
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
@@ -3257,6 +3610,7 @@
if (sstvGeneralVisuals) sstvGeneralVisuals.style.display = mode === 'sstv_general' ? 'flex' : 'none';
if (dmrVisuals) dmrVisuals.style.display = mode === 'dmr' ? 'flex' : 'none';
if (websdrVisuals) websdrVisuals.style.display = mode === 'websdr' ? 'flex' : 'none';
if (subghzVisuals) subghzVisuals.style.display = mode === 'subghz' ? 'flex' : 'none';
// Hide sidebar by default for Meshtastic mode, show for others
const mainContent = document.querySelector('.main-content');
@@ -3292,7 +3646,8 @@
'spystations': 'Spy Stations',
'meshtastic': 'Meshtastic Mesh Monitor',
'dmr': 'Digital Voice Decoder',
'websdr': 'HF/Shortwave WebSDR'
'websdr': 'HF/Shortwave WebSDR',
'subghz': 'SubGHz Transceiver'
};
const outputTitle = document.getElementById('outputTitle');
if (outputTitle) outputTitle.textContent = titles[mode] || 'Signal Monitor';
@@ -3310,7 +3665,7 @@
const reconBtn = document.getElementById('reconBtn');
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
const reconPanel = document.getElementById('reconPanel');
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr') {
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr' || mode === 'subghz') {
if (reconPanel) reconPanel.style.display = 'none';
if (reconBtn) reconBtn.style.display = 'none';
if (intelBtn) intelBtn.style.display = 'none';
@@ -3348,8 +3703,8 @@
// Hide output console for modes with their own visualizations
const outputEl = document.getElementById('output');
const statusBar = document.querySelector('.status-bar');
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr') ? 'none' : 'block';
if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'dmr') ? 'none' : 'flex';
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr' || mode === 'subghz') ? 'none' : 'block';
if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'dmr' || mode === 'subghz') ? 'none' : 'flex';
// Restore sidebar when leaving Meshtastic mode (user may have collapsed it)
if (mode !== 'meshtastic') {
@@ -3409,6 +3764,8 @@
if (typeof initDmrSynthesizer === 'function') setTimeout(initDmrSynthesizer, 100);
} else if (mode === 'websdr') {
if (typeof initWebSDR === 'function') initWebSDR();
} else if (mode === 'subghz') {
SubGhz.init();
}
}
@@ -4617,6 +4974,11 @@
}
function fetchSdrStatus() {
// Avoid probing SDR inventory while HackRF SubGHz mode is active.
// Device discovery runs hackrf_info and can disrupt active HackRF streams.
if (typeof currentMode !== 'undefined' && currentMode === 'subghz') {
return;
}
fetch('/devices/status')
.then(r => r.json())
.then(devices => {
@@ -9077,6 +9439,7 @@
const region = document.getElementById('aprsStripRegion').value;
const device = getSelectedDevice();
const gain = document.getElementById('aprsStripGain').value;
const sdrType = (typeof getSelectedSDRType === 'function') ? getSelectedSDRType() : 'rtlsdr';
// Check if using agent mode
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
@@ -9086,7 +9449,8 @@
const requestBody = {
region,
device: parseInt(device),
gain: parseInt(gain)
gain: parseInt(gain),
sdr_type: sdrType
};
// Add custom frequency if selected
@@ -9431,6 +9795,41 @@
updateAprsStationList(packet);
}
function getAprsMarkerCategory(packet) {
const symbolCode = (packet.symbol && packet.symbol.length > 1) ? packet.symbol[1] : '';
const speed = parseFloat(packet.speed || 0);
const vehicleSymbols = new Set(['>', 'k', 'u', 'v', '[', '<', 's', 'b', 'j']);
if ((Number.isFinite(speed) && speed > 2) || vehicleSymbols.has(symbolCode)) {
return 'vehicle';
}
return 'tower';
}
function getAprsMarkerSvg(category) {
if (category === 'vehicle') {
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 14l2-5a2 2 0 0 1 2-1h10a2 2 0 0 1 2 1l2 5v4h-2a2 2 0 0 1-4 0H9a2 2 0 0 1-4 0H3v-4z"/><circle cx="7" cy="18" r="1.7"/><circle cx="17" cy="18" r="1.7"/></svg>';
}
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l3 7h-2l1 3h-2l1 8h-2l1-8h-2l1-3H9l3-7z"/><path d="M5 21h14" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>';
}
function buildAprsMarkerIcon(packet) {
const category = getAprsMarkerCategory(packet);
const callsign = packet.callsign || 'UNKNOWN';
const html = `
<div class="aprs-map-marker ${category}">
<span class="aprs-map-marker-icon">${getAprsMarkerSvg(category)}</span>
<span class="aprs-map-marker-label">${callsign}</span>
</div>
`;
return L.divIcon({
className: 'aprs-map-marker-wrap',
html,
iconSize: [110, 24],
iconAnchor: [55, 12]
});
}
function updateAprsMarker(packet) {
const callsign = packet.callsign;
@@ -9444,6 +9843,7 @@
if (aprsMarkers[callsign]) {
// Update existing marker position and popup
aprsMarkers[callsign].setLatLng([packet.lat, packet.lon]);
aprsMarkers[callsign].setIcon(buildAprsMarkerIcon(packet));
aprsMarkers[callsign].setPopupContent(`
<div style="font-family: monospace;">
<strong>${callsign}</strong><br>
@@ -9461,14 +9861,7 @@
document.getElementById('aprsStationCount').textContent = aprsStationCount;
document.getElementById('aprsStripStations').textContent = aprsStationCount;
const icon = L.divIcon({
className: 'aprs-marker',
html: `<div style="background: var(--accent-cyan); color: #000; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: bold; white-space: nowrap;">${callsign}</div>`,
iconSize: [80, 20],
iconAnchor: [40, 10]
});
const marker = L.marker([packet.lat, packet.lon], { icon: icon }).addTo(aprsMap);
const marker = L.marker([packet.lat, packet.lon], { icon: buildAprsMarkerIcon(packet) }).addTo(aprsMap);
marker.bindPopup(`
<div style="font-family: monospace;">
@@ -10230,9 +10623,6 @@
// Satellite management
let trackedSatellites = [
{ id: 'ISS', name: 'ISS (ZARYA)', norad: '25544', builtin: true, checked: true },
{ id: 'NOAA-15', name: 'NOAA 15', norad: '25338', builtin: true, checked: true },
{ id: 'NOAA-18', name: 'NOAA 18', norad: '28654', builtin: true, checked: true },
{ id: 'NOAA-19', name: 'NOAA 19', norad: '33591', builtin: true, checked: true },
{ id: 'METEOR-M2', name: 'Meteor-M 2', norad: '40069', builtin: true, checked: true }
];
@@ -15146,7 +15536,6 @@
style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; max-height: 300px; overflow-y: auto;">
<button class="preset-btn" onclick="fetchCelestrakCategory('stations')">Space Stations</button>
<button class="preset-btn" onclick="fetchCelestrakCategory('weather')">Weather</button>
<button class="preset-btn" onclick="fetchCelestrakCategory('noaa')">NOAA</button>
<button class="preset-btn" onclick="fetchCelestrakCategory('goes')">GOES</button>
<button class="preset-btn" onclick="fetchCelestrakCategory('amateur')">Amateur</button>
<button class="preset-btn" onclick="fetchCelestrakCategory('cubesat')">CubeSats</button>

View File

@@ -1,20 +1,18 @@
<!-- BLUETOOTH MODE -->
<div id="bluetoothMode" class="mode-content">
<!-- Capability Status -->
<div id="btCapabilityStatus" class="section" style="display: none;">
<!-- Populated by JavaScript with capability warnings -->
</div>
<!-- Show All Agents option (visible when agents are available) -->
<div id="btShowAllAgentsContainer" class="section" style="display: none; padding: 8px;">
<label class="inline-checkbox" style="font-size: 10px;">
<input type="checkbox" id="btShowAllAgents" onchange="if(typeof BluetoothMode !== 'undefined') BluetoothMode.toggleShowAllAgents(this.checked)">
Show devices from all agents
</label>
</div>
<div class="section">
<h3>Scanner Configuration</h3>
<!-- Populated by JavaScript with capability warnings -->
<div id="btCapabilityStatus" style="display: none; margin-bottom: 8px;"></div>
<!-- Show All Agents option (visible when agents are available) -->
<div id="btShowAllAgentsContainer" style="display: none; margin-bottom: 8px;">
<label class="inline-checkbox" style="font-size: 10px;">
<input type="checkbox" id="btShowAllAgents" onchange="if(typeof BluetoothMode !== 'undefined') BluetoothMode.toggleShowAllAgents(this.checked)">
Show devices from all agents
</label>
</div>
<div class="form-group">
<label>Adapter</label>
<select id="btAdapterSelect">
@@ -61,7 +59,7 @@
Stop Scanning
</button>
<div class="section" style="margin-top: 10px;">
<div class="section">
<h3>Export</h3>
<div style="display: flex; gap: 8px;">
<button class="preset-btn" onclick="btExport('csv')" style="flex: 1;">

View File

@@ -2,6 +2,9 @@
<div id="dmrMode" class="mode-content">
<div class="section">
<h3>Digital Voice</h3>
<div class="alpha-mode-notice">
ALPHA: Digital Voice decoding is still in active development. Expect occasional decode instability and false protocol locks.
</div>
<!-- Dependency Warning -->
<div id="dmrToolsWarning" style="display: none; background: rgba(255, 100, 100, 0.1); border: 1px solid var(--accent-red); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
@@ -19,13 +22,16 @@
<div class="form-group">
<label>Protocol</label>
<select id="dmrProtocol">
<option value="auto" selected>Auto Detect</option>
<option value="auto" selected>Auto Detect (DMR/P25/D-STAR)</option>
<option value="dmr">DMR</option>
<option value="p25">P25</option>
<option value="nxdn">NXDN</option>
<option value="dstar">D-STAR</option>
<option value="provoice">ProVoice</option>
</select>
<span style="font-size: 0.75em; color: var(--text-muted); display: block; margin-top: 2px;">
For NXDN and ProVoice, use manual protocol selection for best lock reliability
</span>
</div>
<div class="form-group">
@@ -70,14 +76,6 @@
</div>
</div>
<!-- Actions -->
<button class="run-btn" id="startDmrBtn" onclick="startDmr()" style="margin-top: 12px;">
Start Decoder
</button>
<button class="stop-btn" id="stopDmrBtn" onclick="stopDmr()" style="display: none; margin-top: 12px;">
Stop Decoder
</button>
<!-- Current Call -->
<div class="section" style="margin-top: 12px;">
<h3>Current Call</h3>
@@ -104,4 +102,13 @@
</div>
</div>
</div>
<div class="mode-actions-bottom">
<button class="run-btn" id="startDmrBtn" onclick="startDmr()">
Start Decoder
</button>
<button class="stop-btn" id="stopDmrBtn" onclick="stopDmr()" style="display: none;">
Stop Decoder
</button>
</div>
</div>

View File

@@ -0,0 +1,175 @@
<!-- SUBGHZ TRANSCEIVER MODE -->
<div id="subghzMode" class="mode-content">
<div class="section">
<h3>SubGHz Transceiver</h3>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
HackRF One SubGHz transceiver. Capture raw signals, replay saved bursts,
and scan wideband activity with frequency analysis.
</p>
</div>
<!-- Device -->
<div class="section">
<h3>HackRF Device</h3>
<div class="subghz-device-status" id="subghzDeviceStatus">
<div class="subghz-device-row">
<span class="subghz-device-dot" id="subghzDeviceDot"></span>
<span class="subghz-device-label" id="subghzDeviceLabel">Checking...</span>
</div>
<div class="subghz-device-tools" id="subghzDeviceTools">
<span class="subghz-tool-badge" id="subghzToolHackrf" title="hackrf_transfer">HackRF</span>
<span class="subghz-tool-badge" id="subghzToolSweep" title="hackrf_sweep">Sweep</span>
</div>
</div>
<div class="form-group" style="margin-top: 8px;">
<label>Device Serial <span style="color: var(--text-dim); font-weight: normal;">(optional)</span></label>
<input type="text" id="subghzDeviceSerial" placeholder="auto-detect" style="font-family: 'JetBrains Mono', monospace; font-size: 11px;">
</div>
</div>
<!-- Status -->
<div class="subghz-status-row" id="subghzStatusRow">
<div class="subghz-status-dot" id="subghzStatusDot"></div>
<span class="subghz-status-text" id="subghzStatusText">Idle</span>
<span class="subghz-status-timer" id="subghzStatusTimer"></span>
</div>
<!-- Frequency -->
<div class="section">
<h3>Frequency</h3>
<div class="form-group">
<label>Frequency (MHz)</label>
<input type="number" id="subghzFrequency" value="433.92" step="0.001" min="1" max="6000">
</div>
<div class="subghz-preset-btns">
<button class="subghz-preset-btn" onclick="SubGhz.setFreq(315)">315M</button>
<button class="subghz-preset-btn" onclick="SubGhz.setFreq(433.92)">433.92M</button>
<button class="subghz-preset-btn" onclick="SubGhz.setFreq(868)">868M</button>
<button class="subghz-preset-btn" onclick="SubGhz.setFreq(915)">915M</button>
</div>
</div>
<!-- Gain -->
<div class="section">
<h3>Gain</h3>
<div class="form-group">
<label>LNA Gain (0-40 dB)</label>
<input type="range" id="subghzLnaGain" min="0" max="40" value="24" step="8" oninput="document.getElementById('subghzLnaVal').textContent=this.value">
<span id="subghzLnaVal" style="font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-secondary);">24</span>
</div>
<div class="form-group">
<label>VGA Gain (0-62 dB)</label>
<input type="range" id="subghzVgaGain" min="0" max="62" value="20" step="2" oninput="document.getElementById('subghzVgaVal').textContent=this.value">
<span id="subghzVgaVal" style="font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-secondary);">20</span>
</div>
<div class="form-group">
<label>Sample Rate</label>
<select id="subghzSampleRate" class="mode-select">
<option value="2000000" selected>2 MHz</option>
<option value="4000000">4 MHz</option>
<option value="8000000">8 MHz</option>
<option value="10000000">10 MHz</option>
<option value="20000000">20 MHz</option>
</select>
</div>
</div>
<!-- Tabs: Receive RAW / Sweep -->
<div class="section">
<div class="subghz-tabs">
<button class="subghz-tab active" data-tab="rx" onclick="SubGhz.switchTab('rx')">Read RAW</button>
<button class="subghz-tab" data-tab="sweep" onclick="SubGhz.switchTab('sweep')">Sweep</button>
</div>
<!-- RX Tab -->
<div class="subghz-tab-content active" id="subghzTabRx">
<p style="font-size: 11px; color: var(--text-dim); margin-bottom: 10px;">
Capture raw IQ data to file. Saved captures can be replayed or analyzed.
</p>
<div class="subghz-trigger-box">
<label class="subghz-trigger-toggle">
<input type="checkbox" id="subghzTriggerEnabled" onchange="SubGhz.syncTriggerControls()">
Smart Trigger Capture
</label>
<div class="subghz-trigger-grid">
<label>Pre-roll (ms)</label>
<input type="number" id="subghzTriggerPreMs" min="50" max="5000" step="50" value="350">
<label>Post-roll (ms)</label>
<input type="number" id="subghzTriggerPostMs" min="100" max="10000" step="50" value="700">
</div>
<p class="subghz-trigger-help">Auto-stops after burst + post-roll and trims capture window.</p>
</div>
<div class="subghz-btn-row">
<button class="subghz-btn start" id="subghzRxStartBtn" onclick="SubGhz.startRx()">Start Capture</button>
<button class="subghz-btn stop" id="subghzRxStopBtn" onclick="SubGhz.stopRx()" disabled>Stop Capture</button>
</div>
</div>
<!-- Sweep Tab -->
<div class="subghz-tab-content" id="subghzTabSweep">
<p style="font-size: 11px; color: var(--text-dim); margin-bottom: 10px;">
Wideband spectrum analyzer using hackrf_sweep.
</p>
<div class="form-group">
<label>Frequency Range (MHz)</label>
<div class="subghz-sweep-range">
<input type="number" id="subghzSweepStart" value="300" min="1" max="6000" step="1">
<span>to</span>
<input type="number" id="subghzSweepEnd" value="928" min="1" max="6000" step="1">
</div>
</div>
<div class="subghz-btn-row">
<button class="subghz-btn start" id="subghzSweepStartBtn" onclick="SubGhz.startSweep()">Start Sweep</button>
<button class="subghz-btn stop" id="subghzSweepStopBtn" onclick="SubGhz.stopSweep()" disabled>Stop Sweep</button>
</div>
<div style="margin-top: 10px;">
<label style="font-size: 10px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px;">Detected Peaks</label>
<div class="subghz-peak-list" id="subghzPeakList"></div>
</div>
</div>
</div>
<!-- TX Settings (collapsible) -->
<div class="section">
<h3 style="cursor: pointer;" onclick="document.getElementById('subghzTxSection').classList.toggle('active')">
Transmit Settings <span style="font-size: 10px; color: var(--text-dim);">&#9660;</span>
</h3>
<div id="subghzTxSection" style="display: none;">
<div class="subghz-tx-warning">
WARNING: Transmitting radio signals may be illegal without proper authorization.
Only transmit on frequencies you are licensed for and within ISM band limits.
TX is restricted to ISM bands: 300-348, 387-464, 779-928 MHz.
</div>
<div class="form-group">
<label>TX VGA Gain (0-47 dB)</label>
<input type="range" id="subghzTxGain" min="0" max="47" value="20" step="1" oninput="document.getElementById('subghzTxGainVal').textContent=this.value">
<span id="subghzTxGainVal" style="font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-secondary);">20</span>
</div>
<div class="form-group">
<label>Max Duration (seconds)</label>
<input type="number" id="subghzTxMaxDuration" value="10" min="1" max="30" step="1">
</div>
</div>
</div>
<!-- Saved Signals Library -->
<div class="section">
<h3>Saved Signals</h3>
<div class="subghz-captures-list" id="subghzSidebarCaptures" style="max-height: 220px; overflow-y: auto;">
<div class="subghz-empty" id="subghzSidebarCapturesEmpty">No saved captures yet</div>
</div>
</div>
</div>
<script>
// Toggle TX section visibility
document.addEventListener('DOMContentLoaded', function() {
const h3 = document.querySelector('#subghzTxSection')?.previousElementSibling;
if (h3) {
h3.addEventListener('click', function() {
const section = document.getElementById('subghzTxSection');
if (section) section.style.display = section.style.display === 'none' ? 'block' : 'none';
});
}
});
</script>

View File

@@ -2,7 +2,7 @@
<div id="tscmMode" class="mode-content">
<!-- Configuration -->
<div class="section">
<h3 style="display: flex; align-items: center; gap: 8px; margin-bottom: 12px;">TSCM Sweep <span style="font-size: 9px; font-weight: normal; background: var(--accent-orange); color: #000; padding: 2px 6px; border-radius: 3px; text-transform: uppercase; letter-spacing: 0.5px;">Alpha</span></h3>
<h3>TSCM Sweep <span style="font-size: 9px; font-weight: normal; background: var(--accent-orange); color: #000; padding: 2px 6px; border-radius: 3px; text-transform: uppercase; letter-spacing: 0.5px;">Alpha</span></h3>
<div class="form-group">
<label>Sweep Type</label>
@@ -65,14 +65,6 @@
</div>
</div>
<!-- Actions -->
<button class="run-btn" id="startTscmBtn" onclick="startTscmSweep()" style="margin-top: 12px;">
Start Sweep
</button>
<button class="stop-btn" id="stopTscmBtn" onclick="stopTscmSweep()" style="display: none; margin-top: 12px;">
Stop Sweep
</button>
<!-- Futuristic Scanner Progress -->
<div id="tscmProgress" class="tscm-scanner-progress" style="display: none; margin-top: 12px;">
<div class="scanner-ring">
@@ -115,8 +107,8 @@
</div>
<!-- Advanced -->
<div class="section" style="margin-top: 12px;">
<h3 style="margin-bottom: 12px;">Advanced</h3>
<div class="section">
<h3>Advanced</h3>
<div style="margin-bottom: 16px;">
<label style="display: block; font-size: 11px; font-weight: 600; margin-bottom: 6px; color: var(--text-secondary);">Baseline Recording</label>
@@ -156,8 +148,8 @@
</div>
<!-- Tools -->
<div class="section" style="margin-top: 12px;">
<h3 style="margin-bottom: 10px;">Tools</h3>
<div class="section">
<h3>Tools</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 6px;">
<button class="preset-btn" onclick="tscmShowCapabilities()" style="font-size: 10px; padding: 8px;">
Capabilities
@@ -182,4 +174,13 @@
<!-- Device Warnings -->
<div id="tscmDeviceWarnings" style="display: none; margin-top: 8px; padding: 8px; background: rgba(255,153,51,0.1); border: 1px solid rgba(255,153,51,0.3); border-radius: 4px;"></div>
<div class="mode-actions-bottom">
<button class="run-btn" id="startTscmBtn" onclick="startTscmSweep()">
Start Sweep
</button>
<button class="stop-btn" id="stopTscmBtn" onclick="stopTscmSweep()" style="display: none;">
Stop Sweep
</button>
</div>
</div>

View File

@@ -1,22 +1,26 @@
<!-- WEATHER SATELLITE MODE -->
<div id="weatherSatMode" class="mode-content">
<div class="section">
<h3>Weather Satellite Decoder</h3>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
Receive and decode weather images from NOAA and Meteor satellites.
Uses SatDump for live SDR capture and image processing.
</p>
</div>
<div id="weatherSatMode" class="mode-content">
<div class="section">
<h3>Weather Satellite Decoder</h3>
<div class="alpha-mode-notice">
ALPHA: Weather Satellite capture is experimental and may fail depending on SatDump version, SDR driver support, and pass conditions.
</div>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
Receive and decode weather images from NOAA and Meteor satellites.
Uses SatDump for live SDR capture and image processing.
</p>
</div>
<div class="section">
<h3>Satellite</h3>
<div class="form-group">
<label>Select Satellite</label>
<select id="weatherSatSelect" class="mode-select">
<option value="NOAA-15">NOAA-15 (137.620 MHz APT)</option>
<option value="NOAA-18" selected>NOAA-18 (137.9125 MHz APT)</option>
<option value="NOAA-19">NOAA-19 (137.100 MHz APT)</option>
<option value="METEOR-M2-3">Meteor-M2-3 (137.900 MHz LRPT)</option>
<option value="METEOR-M2-3" selected>Meteor-M2-3 (137.900 MHz LRPT)</option>
<option value="METEOR-M2-4">Meteor-M2-4 (137.900 MHz LRPT)</option>
<option value="NOAA-15" disabled>NOAA-15 (137.620 MHz APT) [DEFUNCT]</option>
<option value="NOAA-18" disabled>NOAA-18 (137.9125 MHz APT) [DEFUNCT]</option>
<option value="NOAA-19" disabled>NOAA-19 (137.100 MHz APT) [DEFUNCT]</option>
</select>
</div>
<div class="form-group">
@@ -187,10 +191,11 @@
<div class="form-group">
<label>Satellite</label>
<select id="wxsatTestSatSelect" class="mode-select">
<option value="METEOR-M2-3" selected>Meteor-M2-3 (LRPT)</option>
<option value="METEOR-M2-4">Meteor-M2-4 (LRPT)</option>
<option value="NOAA-15">NOAA-15 (APT)</option>
<option value="NOAA-18" selected>NOAA-18 (APT)</option>
<option value="NOAA-18">NOAA-18 (APT)</option>
<option value="NOAA-19">NOAA-19 (APT)</option>
<option value="METEOR-M2-3">Meteor-M2-3 (LRPT)</option>
</select>
</div>
<div class="form-group">

View File

@@ -1,7 +1,8 @@
<!-- WiFi MODE -->
<div id="wifiMode" class="mode-content">
<!-- Scan Mode Tabs -->
<div class="section" style="padding: 8px;">
<div class="section">
<h3>Signal Source</h3>
<div class="wifi-scan-mode-tabs" style="display: flex; gap: 4px;">
<button id="wifiScanModeQuick" class="wifi-mode-tab active" style="flex: 1; padding: 8px; font-size: 11px; background: var(--accent-green); color: #000; border: none; border-radius: 4px; cursor: pointer;">
Quick Scan
@@ -168,29 +169,8 @@
</div>
</div>
<!-- v2 Scan Buttons -->
<div style="display: flex; gap: 8px; margin-bottom: 8px;">
<button class="run-btn" id="wifiQuickScanBtn" onclick="WiFiMode.startQuickScan()" style="flex: 1;">
Quick Scan
</button>
<button class="run-btn" id="wifiDeepScanBtn" onclick="WiFiMode.startDeepScan()" style="flex: 1; background: var(--accent-orange);">
Deep Scan
</button>
</div>
<button class="stop-btn" id="wifiStopScanBtn" onclick="WiFiMode.stopScan()" style="display: none; width: 100%;">
Stop Scanning
</button>
<!-- Legacy Scan Buttons (hidden, for backwards compatibility) -->
<button class="run-btn" id="startWifiBtn" onclick="startWifiScan()" style="display: none;">
Start Scanning (Legacy)
</button>
<button class="stop-btn" id="stopWifiBtn" onclick="stopWifiScan()" style="display: none;">
Stop Scanning (Legacy)
</button>
<!-- Export Section -->
<div class="section" style="margin-top: 10px;">
<div class="section">
<h3>Export</h3>
<div style="display: flex; gap: 8px;">
<button class="preset-btn" onclick="WiFiMode.exportData('csv')" style="flex: 1;">
@@ -201,4 +181,27 @@
</button>
</div>
</div>
<div class="mode-actions-bottom">
<!-- v2 Scan Buttons -->
<div style="display: flex; gap: 8px;">
<button class="run-btn" id="wifiQuickScanBtn" onclick="WiFiMode.startQuickScan()" style="flex: 1;">
Quick Scan
</button>
<button class="run-btn" id="wifiDeepScanBtn" onclick="WiFiMode.startDeepScan()" style="flex: 1; background: var(--accent-orange);">
Deep Scan
</button>
</div>
<button class="stop-btn" id="wifiStopScanBtn" onclick="WiFiMode.stopScan()" style="display: none; width: 100%;">
Stop Scanning
</button>
<!-- Legacy Scan Buttons (hidden, for backwards compatibility) -->
<button class="run-btn" id="startWifiBtn" onclick="startWifiScan()" style="display: none;">
Start Scanning (Legacy)
</button>
<button class="stop-btn" id="stopWifiBtn" onclick="stopWifiScan()" style="display: none;">
Stop Scanning (Legacy)
</button>
</div>
</div>

View File

@@ -71,8 +71,8 @@
{{ mode_item('listening', 'Listening Post', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
{{ mode_item('spystations', 'Spy Stations', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
{{ mode_item('meshtastic', 'Meshtastic', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
{{ mode_item('dmr', 'Digital Voice', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="22"/></svg>') }}
{{ mode_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
{{ mode_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
</div>
</div>
@@ -191,8 +191,8 @@
{{ mobile_item('listening', 'Scanner', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
{{ mobile_item('spystations', 'Spy', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
{{ mobile_item('meshtastic', 'Mesh', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
{{ mobile_item('dmr', 'DMR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="22"/></svg>') }}
{{ mobile_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
{{ mobile_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
</nav>
{# JavaScript stub for pages that don't have switchMode defined #}

View File

@@ -323,7 +323,6 @@
<option value="acars">ACARS</option>
<option value="aprs">APRS</option>
<option value="rtlamr">RTLAMR</option>
<option value="dmr">DMR</option>
<option value="tscm">TSCM</option>
<option value="sstv">SSTV</option>
<option value="sstv_general">SSTV General</option>

View File

@@ -107,12 +107,8 @@
<label>TARGET:</label>
<select id="satSelect" onchange="onSatelliteChange()">
<option value="25544">ISS (ZARYA)</option>
<option value="25338">NOAA 15</option>
<option value="28654">NOAA 18</option>
<option value="33591">NOAA 19</option>
<option value="40069">METEOR-M2</option>
<option value="43013">NOAA 20</option>
<option value="54234">METEOR-M2-3</option>
<option value="57166">METEOR-M2-3</option>
</select>
</div>
@@ -275,12 +271,8 @@
const satellites = {
25544: { name: 'ISS (ZARYA)', color: '#00ffff' },
25338: { name: 'NOAA 15', color: '#00ff00' },
28654: { name: 'NOAA 18', color: '#ff6600' },
33591: { name: 'NOAA 19', color: '#ff3366' },
40069: { name: 'METEOR-M2', color: '#9370DB' },
43013: { name: 'NOAA 20', color: '#00ffaa' },
54234: { name: 'METEOR-M2-3', color: '#ff00ff' }
57166: { name: 'METEOR-M2-3', color: '#ff00ff' }
};
function onSatelliteChange() {

View File

@@ -1,7 +1,9 @@
"""Tests for the DMR / Digital Voice decoding module."""
import queue
from unittest.mock import patch, MagicMock
import pytest
import routes.dmr as dmr_module
from routes.dmr import parse_dsd_output, _DSD_PROTOCOL_FLAGS, _DSD_FME_PROTOCOL_FLAGS, _DSD_FME_MODULATION
@@ -132,9 +134,9 @@ def test_dsd_fme_flags_differ_from_classic():
def test_dsd_fme_protocol_flags_known_values():
"""dsd-fme flags use its own flag names (NOT classic DSD mappings)."""
assert _DSD_FME_PROTOCOL_FLAGS['auto'] == ['-ft'] # XDMA
assert _DSD_FME_PROTOCOL_FLAGS['auto'] == ['-fa'] # Broad auto
assert _DSD_FME_PROTOCOL_FLAGS['dmr'] == ['-fs'] # Simplex (-fd is D-STAR!)
assert _DSD_FME_PROTOCOL_FLAGS['p25'] == ['-f1'] # NOT -fp (ProVoice in fme)
assert _DSD_FME_PROTOCOL_FLAGS['p25'] == ['-ft'] # P25 P1/P2 coverage
assert _DSD_FME_PROTOCOL_FLAGS['nxdn'] == ['-fn']
assert _DSD_FME_PROTOCOL_FLAGS['dstar'] == ['-fd'] # -fd is D-STAR in dsd-fme
assert _DSD_FME_PROTOCOL_FLAGS['provoice'] == ['-fp'] # NOT -fv
@@ -153,9 +155,9 @@ def test_dsd_protocol_flags_known_values():
def test_dsd_fme_modulation_hints():
"""C4FM modulation hints should be set for C4FM protocols."""
assert _DSD_FME_MODULATION['dmr'] == ['-mc']
assert _DSD_FME_MODULATION['p25'] == ['-mc']
assert _DSD_FME_MODULATION['nxdn'] == ['-mc']
# D-Star and ProVoice should not have forced modulation
# P25, D-Star and ProVoice should not have forced modulation
assert 'p25' not in _DSD_FME_MODULATION
assert 'dstar' not in _DSD_FME_MODULATION
assert 'provoice' not in _DSD_FME_MODULATION
@@ -172,6 +174,40 @@ def auth_client(client):
return client
@pytest.fixture(autouse=True)
def reset_dmr_globals():
"""Reset DMR globals before/after each test to avoid cross-test bleed."""
dmr_module.dmr_rtl_process = None
dmr_module.dmr_dsd_process = None
dmr_module.dmr_thread = None
dmr_module.dmr_running = False
dmr_module.dmr_has_audio = False
dmr_module.dmr_active_device = None
with dmr_module._ffmpeg_sinks_lock:
dmr_module._ffmpeg_sinks.clear()
try:
while True:
dmr_module.dmr_queue.get_nowait()
except queue.Empty:
pass
yield
dmr_module.dmr_rtl_process = None
dmr_module.dmr_dsd_process = None
dmr_module.dmr_thread = None
dmr_module.dmr_running = False
dmr_module.dmr_has_audio = False
dmr_module.dmr_active_device = None
with dmr_module._ffmpeg_sinks_lock:
dmr_module._ffmpeg_sinks.clear()
try:
while True:
dmr_module.dmr_queue.get_nowait()
except queue.Empty:
pass
def test_dmr_tools(auth_client):
"""Tools endpoint should return availability info."""
resp = auth_client.get('/dmr/tools')
@@ -235,3 +271,41 @@ def test_dmr_stream_mimetype(auth_client):
"""Stream should return event-stream content type."""
resp = auth_client.get('/dmr/stream')
assert resp.content_type.startswith('text/event-stream')
def test_dmr_start_exception_cleans_up_resources(auth_client):
"""If startup fails after rtl_fm launch, process/device state should be reset."""
rtl_proc = MagicMock()
rtl_proc.poll.return_value = None
rtl_proc.wait.return_value = 0
rtl_proc.stdout = MagicMock()
rtl_proc.stderr = MagicMock()
builder = MagicMock()
builder.build_fm_demod_command.return_value = ['rtl_fm', '-f', '462.5625M']
with patch('routes.dmr.find_dsd', return_value=('/usr/bin/dsd', False)), \
patch('routes.dmr.find_rtl_fm', return_value='/usr/bin/rtl_fm'), \
patch('routes.dmr.find_ffmpeg', return_value=None), \
patch('routes.dmr.SDRFactory.create_default_device', return_value=MagicMock()), \
patch('routes.dmr.SDRFactory.get_builder', return_value=builder), \
patch('routes.dmr.app_module.claim_sdr_device', return_value=None), \
patch('routes.dmr.app_module.release_sdr_device') as release_mock, \
patch('routes.dmr.register_process') as register_mock, \
patch('routes.dmr.unregister_process') as unregister_mock, \
patch('routes.dmr.subprocess.Popen', side_effect=[rtl_proc, RuntimeError('dsd launch failed')]):
resp = auth_client.post('/dmr/start', json={
'frequency': 462.5625,
'protocol': 'auto',
'device': 0,
})
assert resp.status_code == 500
assert 'dsd launch failed' in resp.get_json()['message']
register_mock.assert_called_once_with(rtl_proc)
rtl_proc.terminate.assert_called_once()
unregister_mock.assert_called_once_with(rtl_proc)
release_mock.assert_called_once_with(0)
assert dmr_module.dmr_running is False
assert dmr_module.dmr_rtl_process is None
assert dmr_module.dmr_dsd_process is None

View File

@@ -46,20 +46,30 @@ class TestHealthEndpoint:
assert 'processes' in data
assert 'data' in data
def test_health_process_status(self, client):
"""Test health endpoint reports process status."""
response = client.get('/health')
data = json.loads(response.data)
def test_health_process_status(self, client):
"""Test health endpoint reports process status."""
response = client.get('/health')
data = json.loads(response.data)
processes = data['processes']
assert 'pager' in processes
assert 'sensor' in processes
assert 'adsb' in processes
assert 'wifi' in processes
assert 'bluetooth' in processes
class TestDevicesEndpoint:
assert 'adsb' in processes
assert 'wifi' in processes
assert 'bluetooth' in processes
def test_health_reports_dmr_route_process(self, client):
"""Health should reflect DMR route module state (not stale app globals)."""
mock_proc = MagicMock()
mock_proc.poll.return_value = None
with patch('routes.dmr.dmr_running', True), \
patch('routes.dmr.dmr_dsd_process', mock_proc):
response = client.get('/health')
data = json.loads(response.data)
assert data['processes']['dmr'] is True
class TestDevicesEndpoint:
"""Tests for devices endpoint."""
def test_get_devices(self, client):

View File

@@ -0,0 +1,38 @@
"""Tests for rtl_fm modulation token mapping."""
from routes.listening_post import _rtl_fm_demod_mode as listening_post_rtl_mode
from utils.sdr.base import SDRDevice, SDRType
from utils.sdr.rtlsdr import RTLSDRCommandBuilder, _rtl_fm_demod_mode as builder_rtl_mode
def _dummy_rtlsdr_device() -> SDRDevice:
return SDRDevice(
sdr_type=SDRType.RTL_SDR,
index=0,
name='RTL-SDR',
serial='00000001',
driver='rtlsdr',
capabilities=RTLSDRCommandBuilder.CAPABILITIES,
)
def test_rtl_fm_modulation_maps_wfm_to_wbfm() -> None:
assert listening_post_rtl_mode('wfm') == 'wbfm'
assert builder_rtl_mode('wfm') == 'wbfm'
def test_rtl_fm_modulation_keeps_other_modes() -> None:
assert listening_post_rtl_mode('fm') == 'fm'
assert builder_rtl_mode('am') == 'am'
def test_rtlsdr_builder_uses_wbfm_token_for_wfm() -> None:
builder = RTLSDRCommandBuilder()
cmd = builder.build_fm_demod_command(
device=_dummy_rtlsdr_device(),
frequency_mhz=98.1,
modulation='wfm',
)
mode_index = cmd.index('-M')
assert cmd[mode_index + 1] == 'wbfm'

608
tests/test_subghz.py Normal file
View File

@@ -0,0 +1,608 @@
"""Tests for SubGhzManager utility module."""
from __future__ import annotations
import json
import os
import subprocess
import tempfile
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
from utils.subghz import SubGhzManager, SubGhzCapture
@pytest.fixture
def tmp_data_dir(tmp_path):
"""Create a temporary data directory for SubGhz captures."""
data_dir = tmp_path / 'subghz'
data_dir.mkdir()
(data_dir / 'captures').mkdir()
return data_dir
@pytest.fixture
def manager(tmp_data_dir):
"""Create a SubGhzManager with temp directory."""
return SubGhzManager(data_dir=tmp_data_dir)
class TestSubGhzManagerInit:
def test_creates_data_dirs(self, tmp_path):
data_dir = tmp_path / 'new_subghz'
mgr = SubGhzManager(data_dir=data_dir)
assert (data_dir / 'captures').is_dir()
def test_active_mode_idle(self, manager):
assert manager.active_mode == 'idle'
def test_get_status_idle(self, manager):
status = manager.get_status()
assert status['mode'] == 'idle'
class TestToolDetection:
def test_check_hackrf_found(self, manager):
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'):
assert manager.check_hackrf() is True
def test_check_hackrf_not_found(self, manager):
with patch('shutil.which', return_value=None):
manager._hackrf_available = None # reset cache
assert manager.check_hackrf() is False
def test_check_rtl433_found(self, manager):
with patch('shutil.which', return_value='/usr/bin/rtl_433'):
assert manager.check_rtl433() is True
def test_check_sweep_found(self, manager):
with patch('shutil.which', return_value='/usr/bin/hackrf_sweep'):
assert manager.check_sweep() is True
class TestReceive:
def test_start_receive_no_hackrf(self, manager):
with patch('shutil.which', return_value=None):
manager._hackrf_available = None
result = manager.start_receive(frequency_hz=433920000)
assert result['status'] == 'error'
assert 'not found' in result['message']
def test_start_receive_success(self, manager):
mock_proc = MagicMock()
mock_proc.poll.return_value = None
mock_proc.stderr = MagicMock()
mock_proc.stderr.readline = MagicMock(return_value=b'')
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
patch('subprocess.Popen', return_value=mock_proc), \
patch.object(manager, 'check_hackrf_device', return_value=True), \
patch('utils.subghz.register_process'):
manager._hackrf_available = None
result = manager.start_receive(
frequency_hz=433920000,
sample_rate=2000000,
lna_gain=32,
vga_gain=20,
)
assert result['status'] == 'started'
assert result['frequency_hz'] == 433920000
assert manager.active_mode == 'rx'
def test_start_receive_already_running(self, manager):
mock_proc = MagicMock()
mock_proc.poll.return_value = None
manager._rx_process = mock_proc
result = manager.start_receive(frequency_hz=433920000)
assert result['status'] == 'error'
assert 'Already running' in result['message']
def test_stop_receive_not_running(self, manager):
result = manager.stop_receive()
assert result['status'] == 'not_running'
def test_stop_receive_creates_metadata(self, manager, tmp_data_dir):
# Create a fake IQ file
iq_file = tmp_data_dir / 'captures' / 'test.iq'
iq_file.write_bytes(b'\x00' * 1024)
mock_proc = MagicMock()
mock_proc.poll.return_value = None
manager._rx_process = mock_proc
manager._rx_file = iq_file
manager._rx_frequency_hz = 433920000
manager._rx_sample_rate = 2000000
manager._rx_lna_gain = 32
manager._rx_vga_gain = 20
manager._rx_start_time = 1000.0
manager._rx_bursts = [{'start_seconds': 1.23, 'duration_seconds': 0.15, 'peak_level': 42}]
with patch('utils.subghz.safe_terminate'), \
patch('time.time', return_value=1005.0):
result = manager.stop_receive()
assert result['status'] == 'stopped'
assert 'capture' in result
assert result['capture']['frequency_hz'] == 433920000
# Verify JSON sidecar was written
meta_path = iq_file.with_suffix('.json')
assert meta_path.exists()
meta = json.loads(meta_path.read_text())
assert meta['frequency_hz'] == 433920000
assert isinstance(meta.get('bursts'), list)
assert meta['bursts'][0]['peak_level'] == 42
class TestTxSafety:
def test_validate_tx_frequency_ism_433(self):
result = SubGhzManager.validate_tx_frequency(433920000)
assert result is None # Valid
def test_validate_tx_frequency_ism_315(self):
result = SubGhzManager.validate_tx_frequency(315000000)
assert result is None
def test_validate_tx_frequency_ism_915(self):
result = SubGhzManager.validate_tx_frequency(915000000)
assert result is None
def test_validate_tx_frequency_out_of_band(self):
result = SubGhzManager.validate_tx_frequency(100000000) # 100 MHz
assert result is not None
assert 'outside allowed TX bands' in result
def test_validate_tx_frequency_between_bands(self):
result = SubGhzManager.validate_tx_frequency(500000000) # 500 MHz
assert result is not None
def test_transmit_no_hackrf(self, manager):
with patch('shutil.which', return_value=None):
manager._hackrf_available = None
result = manager.transmit(capture_id='abc123')
assert result['status'] == 'error'
def test_transmit_capture_not_found(self, manager):
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
patch.object(manager, 'check_hackrf_device', return_value=True):
manager._hackrf_available = None
result = manager.transmit(capture_id='nonexistent')
assert result['status'] == 'error'
assert 'not found' in result['message']
def test_transmit_out_of_band_rejected(self, manager, tmp_data_dir):
# Create a capture with out-of-band frequency
meta = {
'id': 'test123',
'filename': 'test.iq',
'frequency_hz': 100000000, # 100 MHz - out of ISM
'sample_rate': 2000000,
'lna_gain': 32,
'vga_gain': 20,
'timestamp': '2026-01-01T00:00:00Z',
}
meta_path = tmp_data_dir / 'captures' / 'test.json'
meta_path.write_text(json.dumps(meta))
(tmp_data_dir / 'captures' / 'test.iq').write_bytes(b'\x00' * 100)
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
patch.object(manager, 'check_hackrf_device', return_value=True):
manager._hackrf_available = None
result = manager.transmit(capture_id='test123')
assert result['status'] == 'error'
assert 'outside allowed TX bands' in result['message']
def test_transmit_already_running(self, manager):
mock_proc = MagicMock()
mock_proc.poll.return_value = None
manager._rx_process = mock_proc
result = manager.transmit(capture_id='test123')
assert result['status'] == 'error'
assert 'Already running' in result['message']
def test_transmit_segment_extracts_range(self, manager, tmp_data_dir):
meta = {
'id': 'seg001',
'filename': 'seg.iq',
'frequency_hz': 433920000,
'sample_rate': 1000,
'lna_gain': 24,
'vga_gain': 20,
'timestamp': '2026-01-01T00:00:00Z',
'duration_seconds': 1.0,
'size_bytes': 2000,
}
(tmp_data_dir / 'captures' / 'seg.json').write_text(json.dumps(meta))
(tmp_data_dir / 'captures' / 'seg.iq').write_bytes(bytes(range(200)) * 10)
mock_proc = MagicMock()
mock_proc.poll.return_value = None
mock_timer = MagicMock()
mock_timer.start = MagicMock()
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
patch.object(manager, 'check_hackrf_device', return_value=True), \
patch('subprocess.Popen', return_value=mock_proc), \
patch('utils.subghz.register_process'), \
patch('threading.Timer', return_value=mock_timer), \
patch('threading.Thread') as mock_thread_cls:
mock_thread = MagicMock()
mock_thread.start = MagicMock()
mock_thread_cls.return_value = mock_thread
manager._hackrf_available = None
result = manager.transmit(
capture_id='seg001',
start_seconds=0.2,
duration_seconds=0.3,
)
assert result['status'] == 'transmitting'
assert result['segment'] is not None
assert result['segment']['duration_seconds'] == pytest.approx(0.3, abs=0.01)
assert manager._tx_temp_file is not None
assert manager._tx_temp_file.exists()
class TestCaptureLibrary:
def test_list_captures_empty(self, manager):
captures = manager.list_captures()
assert captures == []
def test_list_captures_with_data(self, manager, tmp_data_dir):
meta = {
'id': 'cap001',
'filename': 'test.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
'lna_gain': 32,
'vga_gain': 20,
'timestamp': '2026-01-01T00:00:00Z',
'duration_seconds': 5.0,
'size_bytes': 1024,
'label': 'test capture',
}
(tmp_data_dir / 'captures' / 'test.json').write_text(json.dumps(meta))
captures = manager.list_captures()
assert len(captures) == 1
assert captures[0].capture_id == 'cap001'
assert captures[0].label == 'test capture'
def test_get_capture(self, manager, tmp_data_dir):
meta = {
'id': 'cap002',
'filename': 'test2.iq',
'frequency_hz': 315000000,
'sample_rate': 2000000,
'timestamp': '2026-01-01T00:00:00Z',
}
(tmp_data_dir / 'captures' / 'test2.json').write_text(json.dumps(meta))
cap = manager.get_capture('cap002')
assert cap is not None
assert cap.frequency_hz == 315000000
def test_get_capture_not_found(self, manager):
cap = manager.get_capture('nonexistent')
assert cap is None
def test_delete_capture(self, manager, tmp_data_dir):
captures_dir = tmp_data_dir / 'captures'
iq_path = captures_dir / 'delete_me.iq'
meta_path = captures_dir / 'delete_me.json'
iq_path.write_bytes(b'\x00' * 100)
meta_path.write_text(json.dumps({
'id': 'del001',
'filename': 'delete_me.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
'timestamp': '2026-01-01T00:00:00Z',
}))
assert manager.delete_capture('del001') is True
assert not iq_path.exists()
assert not meta_path.exists()
def test_delete_capture_not_found(self, manager):
assert manager.delete_capture('nonexistent') is False
def test_update_label(self, manager, tmp_data_dir):
meta = {
'id': 'lbl001',
'filename': 'label_test.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
'timestamp': '2026-01-01T00:00:00Z',
'label': '',
}
meta_path = tmp_data_dir / 'captures' / 'label_test.json'
meta_path.write_text(json.dumps(meta))
assert manager.update_capture_label('lbl001', 'Garage Remote') is True
updated = json.loads(meta_path.read_text())
assert updated['label'] == 'Garage Remote'
assert updated['label_source'] == 'manual'
def test_update_label_not_found(self, manager):
assert manager.update_capture_label('nonexistent', 'test') is False
def test_get_capture_path(self, manager, tmp_data_dir):
captures_dir = tmp_data_dir / 'captures'
iq_path = captures_dir / 'path_test.iq'
iq_path.write_bytes(b'\x00' * 100)
(captures_dir / 'path_test.json').write_text(json.dumps({
'id': 'pth001',
'filename': 'path_test.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
'timestamp': '2026-01-01T00:00:00Z',
}))
path = manager.get_capture_path('pth001')
assert path is not None
assert path.name == 'path_test.iq'
def test_get_capture_path_not_found(self, manager):
assert manager.get_capture_path('nonexistent') is None
def test_trim_capture_manual_segment(self, manager, tmp_data_dir):
captures_dir = tmp_data_dir / 'captures'
iq_path = captures_dir / 'trim_src.iq'
iq_path.write_bytes(bytes(range(200)) * 20) # 4000 bytes at 1000 sps => 2.0s
(captures_dir / 'trim_src.json').write_text(json.dumps({
'id': 'trim001',
'filename': 'trim_src.iq',
'frequency_hz': 433920000,
'sample_rate': 1000,
'lna_gain': 24,
'vga_gain': 20,
'timestamp': '2026-01-01T00:00:00Z',
'duration_seconds': 2.0,
'size_bytes': 4000,
'label': 'Weather Burst',
'bursts': [
{
'start_seconds': 0.55,
'duration_seconds': 0.2,
'peak_level': 67,
'fingerprint': 'abc123',
'modulation_hint': 'OOK/ASK',
'modulation_confidence': 0.9,
}
],
}))
result = manager.trim_capture(
capture_id='trim001',
start_seconds=0.5,
duration_seconds=0.4,
)
assert result['status'] == 'ok'
assert result['capture']['id'] != 'trim001'
assert result['capture']['size_bytes'] == 800
assert result['capture']['label'].endswith('(Trim)')
trimmed_iq = captures_dir / result['capture']['filename']
assert trimmed_iq.exists()
trimmed_meta = trimmed_iq.with_suffix('.json')
assert trimmed_meta.exists()
def test_trim_capture_auto_burst(self, manager, tmp_data_dir):
captures_dir = tmp_data_dir / 'captures'
iq_path = captures_dir / 'auto_src.iq'
iq_path.write_bytes(bytes(range(100)) * 40) # 4000 bytes
(captures_dir / 'auto_src.json').write_text(json.dumps({
'id': 'trim002',
'filename': 'auto_src.iq',
'frequency_hz': 433920000,
'sample_rate': 1000,
'lna_gain': 24,
'vga_gain': 20,
'timestamp': '2026-01-01T00:00:00Z',
'duration_seconds': 2.0,
'size_bytes': 4000,
'bursts': [
{'start_seconds': 0.2, 'duration_seconds': 0.1, 'peak_level': 12},
{'start_seconds': 1.2, 'duration_seconds': 0.25, 'peak_level': 88},
],
}))
result = manager.trim_capture(capture_id='trim002')
assert result['status'] == 'ok'
assert result['segment']['auto_selected'] is True
assert result['capture']['duration_seconds'] > 0.25
def test_list_captures_groups_same_fingerprint(self, manager, tmp_data_dir):
cap_a = {
'id': 'grp001',
'filename': 'a.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
'timestamp': '2026-01-01T00:00:00Z',
'dominant_fingerprint': 'deadbeefcafebabe',
}
cap_b = {
'id': 'grp002',
'filename': 'b.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
'timestamp': '2026-01-01T00:01:00Z',
'dominant_fingerprint': 'deadbeefcafebabe',
}
(tmp_data_dir / 'captures' / 'a.json').write_text(json.dumps(cap_a))
(tmp_data_dir / 'captures' / 'b.json').write_text(json.dumps(cap_b))
captures = manager.list_captures()
assert len(captures) == 2
assert all(c.fingerprint_group.startswith('SIG-') for c in captures)
assert all(c.fingerprint_group_size == 2 for c in captures)
class TestSweep:
def test_start_sweep_no_tool(self, manager):
with patch('shutil.which', return_value=None):
manager._sweep_available = None
result = manager.start_sweep()
assert result['status'] == 'error'
def test_start_sweep_success(self, manager):
mock_proc = MagicMock()
mock_proc.poll.return_value = None
mock_proc.stdout = MagicMock()
with patch('shutil.which', return_value='/usr/bin/hackrf_sweep'), \
patch('subprocess.Popen', return_value=mock_proc), \
patch('utils.subghz.register_process'):
manager._sweep_available = None
result = manager.start_sweep(freq_start_mhz=300, freq_end_mhz=928)
assert result['status'] == 'started'
# Signal daemon threads to stop so they don't outlive the test
manager._sweep_running = False
def test_stop_sweep_not_running(self, manager):
result = manager.stop_sweep()
assert result['status'] == 'not_running'
class TestDecode:
def test_start_decode_no_hackrf(self, manager):
with patch('shutil.which', return_value=None):
manager._hackrf_available = None
manager._rtl433_available = None
result = manager.start_decode(frequency_hz=433920000)
assert result['status'] == 'error'
assert 'hackrf_transfer' in result['message']
def test_start_decode_no_rtl433(self, manager):
def which_side_effect(name):
if name == 'hackrf_transfer':
return '/usr/bin/hackrf_transfer'
return None
with patch('shutil.which', side_effect=which_side_effect):
manager._hackrf_available = None
manager._rtl433_available = None
result = manager.start_decode(frequency_hz=433920000)
assert result['status'] == 'error'
assert 'rtl_433' in result['message']
def test_start_decode_success(self, manager):
mock_hackrf_proc = MagicMock()
mock_hackrf_proc.poll.return_value = None
mock_hackrf_proc.stdout = MagicMock()
mock_hackrf_proc.stderr = MagicMock()
mock_hackrf_proc.stderr.readline = MagicMock(return_value=b'')
mock_rtl433_proc = MagicMock()
mock_rtl433_proc.poll.return_value = None
mock_rtl433_proc.stdout = MagicMock()
mock_rtl433_proc.stderr = MagicMock()
mock_rtl433_proc.stderr.readline = MagicMock(return_value=b'')
call_count = [0]
def popen_side_effect(*args, **kwargs):
call_count[0] += 1
if call_count[0] == 1:
return mock_hackrf_proc
return mock_rtl433_proc
with patch('shutil.which', return_value='/usr/bin/tool'), \
patch('subprocess.Popen', side_effect=popen_side_effect) as mock_popen, \
patch('utils.subghz.register_process'):
manager._hackrf_available = None
manager._rtl433_available = None
result = manager.start_decode(
frequency_hz=433920000,
sample_rate=2000000,
)
assert result['status'] == 'started'
assert result['frequency_hz'] == 433920000
assert manager.active_mode == 'decode'
# Two processes: hackrf_transfer + rtl_433
assert mock_popen.call_count == 2
# Verify hackrf_transfer command
hackrf_cmd = mock_popen.call_args_list[0][0][0]
assert hackrf_cmd[0] == 'hackrf_transfer'
assert '-r' in hackrf_cmd
# Verify rtl_433 command
rtl433_cmd = mock_popen.call_args_list[1][0][0]
assert rtl433_cmd[0] == 'rtl_433'
assert '-r' in rtl433_cmd
assert 'cs8:-' in rtl433_cmd
# Both processes tracked
assert manager._decode_hackrf_process is mock_hackrf_proc
assert manager._decode_process is mock_rtl433_proc
# Signal daemon threads to stop so they don't outlive the test
manager._decode_stop = True
def test_stop_decode_not_running(self, manager):
result = manager.stop_decode()
assert result['status'] == 'not_running'
def test_stop_decode_terminates_both(self, manager):
mock_hackrf = MagicMock()
mock_hackrf.poll.return_value = None
mock_rtl433 = MagicMock()
mock_rtl433.poll.return_value = None
manager._decode_hackrf_process = mock_hackrf
manager._decode_process = mock_rtl433
manager._decode_frequency_hz = 433920000
with patch('utils.subghz.safe_terminate') as mock_term, \
patch('utils.subghz.unregister_process'):
result = manager.stop_decode()
assert result['status'] == 'stopped'
assert manager._decode_hackrf_process is None
assert manager._decode_process is None
assert mock_term.call_count == 2
class TestStopAll:
def test_stop_all_clears_processes(self, manager):
mock_proc = MagicMock()
mock_proc.poll.return_value = None
manager._rx_process = mock_proc
with patch('utils.subghz.safe_terminate'):
manager.stop_all()
assert manager._rx_process is None
assert manager._decode_hackrf_process is None
assert manager._decode_process is None
assert manager._tx_process is None
assert manager._sweep_process is None
class TestSubGhzCapture:
def test_to_dict(self):
cap = SubGhzCapture(
capture_id='abc123',
filename='test.iq',
frequency_hz=433920000,
sample_rate=2000000,
lna_gain=32,
vga_gain=20,
timestamp='2026-01-01T00:00:00Z',
duration_seconds=5.0,
size_bytes=1024,
label='Test',
)
d = cap.to_dict()
assert d['id'] == 'abc123'
assert d['frequency_hz'] == 433920000
assert d['label'] == 'Test'

433
tests/test_subghz_routes.py Normal file
View File

@@ -0,0 +1,433 @@
"""Tests for SubGHz transceiver routes."""
from __future__ import annotations
import json
from unittest.mock import patch, MagicMock
import pytest
from utils.subghz import SubGhzCapture
@pytest.fixture
def auth_client(client):
"""Client with logged-in session."""
with client.session_transaction() as sess:
sess['logged_in'] = True
return client
class TestSubGhzRoutes:
"""Tests for /subghz/ endpoints."""
def test_get_status(self, client, auth_client):
"""GET /subghz/status returns manager status."""
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.get_status.return_value = {
'mode': 'idle',
'hackrf_available': True,
'rtl433_available': True,
'sweep_available': True,
}
mock_get.return_value = mock_mgr
response = auth_client.get('/subghz/status')
assert response.status_code == 200
data = response.get_json()
assert data['mode'] == 'idle'
assert data['hackrf_available'] is True
def test_get_presets(self, client, auth_client):
"""GET /subghz/presets returns frequency presets."""
response = auth_client.get('/subghz/presets')
assert response.status_code == 200
data = response.get_json()
assert 'presets' in data
assert '433.92 MHz' in data['presets']
assert 'sample_rates' in data
# ------ RECEIVE ------
def test_start_receive_success(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.start_receive.return_value = {
'status': 'started',
'frequency_hz': 433920000,
'sample_rate': 2000000,
}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/receive/start', json={
'frequency_hz': 433920000,
'sample_rate': 2000000,
'lna_gain': 32,
'vga_gain': 20,
})
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'started'
def test_start_receive_missing_frequency(self, client, auth_client):
response = auth_client.post('/subghz/receive/start', json={})
assert response.status_code == 400
data = response.get_json()
assert data['status'] == 'error'
def test_start_receive_invalid_frequency(self, client, auth_client):
response = auth_client.post('/subghz/receive/start', json={
'frequency_hz': 'not_a_number',
})
assert response.status_code == 400
def test_stop_receive(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.stop_receive.return_value = {'status': 'stopped'}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/receive/stop')
assert response.status_code == 200
def test_start_receive_trigger_params(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.start_receive.return_value = {'status': 'started'}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/receive/start', json={
'frequency_hz': 433920000,
'trigger_enabled': True,
'trigger_pre_ms': 400,
'trigger_post_ms': 900,
})
assert response.status_code == 200
kwargs = mock_mgr.start_receive.call_args.kwargs
assert kwargs['trigger_enabled'] is True
assert kwargs['trigger_pre_ms'] == 400
assert kwargs['trigger_post_ms'] == 900
# ------ DECODE ------
def test_start_decode_success(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.start_decode.return_value = {
'status': 'started',
'frequency_hz': 433920000,
}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/decode/start', json={
'frequency_hz': 433920000,
})
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'started'
mock_mgr.start_decode.assert_called_once()
kwargs = mock_mgr.start_decode.call_args.kwargs
assert kwargs['decode_profile'] == 'weather'
def test_start_decode_profile_all(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.start_decode.return_value = {
'status': 'started',
'frequency_hz': 433920000,
}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/decode/start', json={
'frequency_hz': 433920000,
'decode_profile': 'all',
})
assert response.status_code == 200
kwargs = mock_mgr.start_decode.call_args.kwargs
assert kwargs['decode_profile'] == 'all'
def test_start_decode_missing_freq(self, client, auth_client):
response = auth_client.post('/subghz/decode/start', json={})
assert response.status_code == 400
def test_stop_decode(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.stop_decode.return_value = {'status': 'stopped'}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/decode/stop')
assert response.status_code == 200
# ------ TRANSMIT ------
def test_transmit_missing_capture_id(self, client, auth_client):
response = auth_client.post('/subghz/transmit', json={})
assert response.status_code == 400
data = response.get_json()
assert 'capture_id is required' in data['message']
def test_transmit_invalid_capture_id(self, client, auth_client):
response = auth_client.post('/subghz/transmit', json={
'capture_id': '../../../etc/passwd',
})
assert response.status_code == 400
data = response.get_json()
assert 'Invalid' in data['message']
def test_transmit_success(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.transmit.return_value = {
'status': 'transmitting',
'capture_id': 'abc123',
'frequency_hz': 433920000,
'max_duration': 10,
}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/transmit', json={
'capture_id': 'abc123',
'tx_gain': 20,
'max_duration': 10,
})
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'transmitting'
kwargs = mock_mgr.transmit.call_args.kwargs
assert kwargs['start_seconds'] is None
assert kwargs['duration_seconds'] is None
def test_transmit_segment_params(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.transmit.return_value = {
'status': 'transmitting',
'capture_id': 'abc123',
'frequency_hz': 433920000,
'max_duration': 10,
'segment': {'start_seconds': 0.1, 'duration_seconds': 0.4},
}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/transmit', json={
'capture_id': 'abc123',
'tx_gain': 20,
'max_duration': 10,
'start_seconds': 0.1,
'duration_seconds': 0.4,
})
assert response.status_code == 200
kwargs = mock_mgr.transmit.call_args.kwargs
assert kwargs['start_seconds'] == 0.1
assert kwargs['duration_seconds'] == 0.4
def test_transmit_invalid_segment_param(self, client, auth_client):
response = auth_client.post('/subghz/transmit', json={
'capture_id': 'abc123',
'start_seconds': 'not-a-number',
})
assert response.status_code == 400
def test_stop_transmit(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.stop_transmit.return_value = {'status': 'stopped'}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/transmit/stop')
assert response.status_code == 200
# ------ SWEEP ------
def test_start_sweep_success(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.start_sweep.return_value = {
'status': 'started',
'freq_start_mhz': 300,
'freq_end_mhz': 928,
}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/sweep/start', json={
'freq_start_mhz': 300,
'freq_end_mhz': 928,
})
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'started'
def test_start_sweep_invalid_range(self, client, auth_client):
response = auth_client.post('/subghz/sweep/start', json={
'freq_start_mhz': 928,
'freq_end_mhz': 300, # start > end
})
assert response.status_code == 400
def test_stop_sweep(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.stop_sweep.return_value = {'status': 'stopped'}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/sweep/stop')
assert response.status_code == 200
# ------ CAPTURES ------
def test_list_captures_empty(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.list_captures.return_value = []
mock_get.return_value = mock_mgr
response = auth_client.get('/subghz/captures')
assert response.status_code == 200
data = response.get_json()
assert data['count'] == 0
assert data['captures'] == []
def test_list_captures_with_data(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
cap = SubGhzCapture(
capture_id='cap1',
filename='test.iq',
frequency_hz=433920000,
sample_rate=2000000,
lna_gain=32,
vga_gain=20,
timestamp='2026-01-01T00:00:00Z',
)
mock_mgr.list_captures.return_value = [cap]
mock_get.return_value = mock_mgr
response = auth_client.get('/subghz/captures')
assert response.status_code == 200
data = response.get_json()
assert data['count'] == 1
assert data['captures'][0]['id'] == 'cap1'
def test_get_capture(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
cap = SubGhzCapture(
capture_id='cap2',
filename='test2.iq',
frequency_hz=315000000,
sample_rate=2000000,
lna_gain=32,
vga_gain=20,
timestamp='2026-01-01T00:00:00Z',
)
mock_mgr.get_capture.return_value = cap
mock_get.return_value = mock_mgr
response = auth_client.get('/subghz/captures/cap2')
assert response.status_code == 200
data = response.get_json()
assert data['capture']['frequency_hz'] == 315000000
def test_get_capture_not_found(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.get_capture.return_value = None
mock_get.return_value = mock_mgr
response = auth_client.get('/subghz/captures/nonexistent')
assert response.status_code == 404
def test_get_capture_invalid_id(self, client, auth_client):
response = auth_client.get('/subghz/captures/bad-id!')
assert response.status_code == 400
def test_delete_capture(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.delete_capture.return_value = True
mock_get.return_value = mock_mgr
response = auth_client.delete('/subghz/captures/cap1')
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'deleted'
def test_trim_capture_success(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.trim_capture.return_value = {
'status': 'ok',
'capture': {
'id': 'trim_new',
'filename': 'trimmed.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
},
}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/captures/cap1/trim', json={
'start_seconds': 0.1,
'duration_seconds': 0.3,
})
assert response.status_code == 200
kwargs = mock_mgr.trim_capture.call_args.kwargs
assert kwargs['capture_id'] == 'cap1'
assert kwargs['start_seconds'] == 0.1
assert kwargs['duration_seconds'] == 0.3
def test_trim_capture_invalid_param(self, client, auth_client):
response = auth_client.post('/subghz/captures/cap1/trim', json={
'start_seconds': 'bad',
})
assert response.status_code == 400
def test_delete_capture_not_found(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.delete_capture.return_value = False
mock_get.return_value = mock_mgr
response = auth_client.delete('/subghz/captures/nonexistent')
assert response.status_code == 404
def test_update_capture_label(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.update_capture_label.return_value = True
mock_get.return_value = mock_mgr
response = auth_client.patch('/subghz/captures/cap1', json={
'label': 'Garage Remote',
})
assert response.status_code == 200
data = response.get_json()
assert data['label'] == 'Garage Remote'
def test_update_capture_label_too_long(self, client, auth_client):
response = auth_client.patch('/subghz/captures/cap1', json={
'label': 'x' * 200,
})
assert response.status_code == 400
def test_update_capture_not_found(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.update_capture_label.return_value = False
mock_get.return_value = mock_mgr
response = auth_client.patch('/subghz/captures/nonexistent', json={
'label': 'test',
})
assert response.status_code == 404
# ------ SSE STREAM ------
def test_stream_endpoint(self, client, auth_client):
"""GET /subghz/stream returns SSE response."""
with patch('routes.subghz.sse_stream', return_value=iter([])):
response = auth_client.get('/subghz/stream')
assert response.status_code == 200
assert response.content_type.startswith('text/event-stream')

View File

@@ -256,6 +256,50 @@ MAX_DSC_MESSAGE_AGE_SECONDS = 3600 # 1 hour
DSC_TERMINATE_TIMEOUT = 3
# =============================================================================
# SUBGHZ TRANSCEIVER (HackRF)
# =============================================================================
# Allowed ISM TX frequency bands (MHz) - transmit only within these ranges
SUBGHZ_TX_ALLOWED_BANDS = [
(300.0, 348.0), # 315 MHz ISM band
(387.0, 464.0), # 433 MHz ISM band
(779.0, 928.0), # 868/915 MHz ISM band
]
# HackRF frequency limits (MHz)
SUBGHZ_FREQ_MIN_MHZ = 1.0
SUBGHZ_FREQ_MAX_MHZ = 6000.0
# HackRF gain ranges
SUBGHZ_LNA_GAIN_MIN = 0
SUBGHZ_LNA_GAIN_MAX = 40
SUBGHZ_VGA_GAIN_MIN = 0
SUBGHZ_VGA_GAIN_MAX = 62
SUBGHZ_TX_VGA_GAIN_MIN = 0
SUBGHZ_TX_VGA_GAIN_MAX = 47
# Default sample rates available (Hz)
SUBGHZ_SAMPLE_RATES = [2000000, 4000000, 8000000, 10000000, 20000000]
# Maximum TX duration watchdog (seconds)
SUBGHZ_TX_MAX_DURATION = 30
# Sweep defaults
SUBGHZ_SWEEP_BIN_WIDTH = 100000 # 100 kHz bins
# SubGHz process termination timeout
SUBGHZ_TERMINATE_TIMEOUT = 3
# Common SubGHz preset frequencies (MHz)
SUBGHZ_PRESETS = {
'315 MHz': 315.0,
'433.92 MHz': 433.92,
'868 MHz': 868.0,
'915 MHz': 915.0,
}
# =============================================================================
# DEAUTH ATTACK DETECTION
# =============================================================================

View File

@@ -394,6 +394,38 @@ TOOL_DEPENDENCIES = {
}
}
},
'subghz': {
'name': 'SubGHz Transceiver',
'tools': {
'hackrf_transfer': {
'required': True,
'description': 'HackRF IQ capture and replay',
'install': {
'apt': 'sudo apt install hackrf',
'brew': 'brew install hackrf',
'manual': 'https://github.com/greatscottgadgets/hackrf'
}
},
'hackrf_sweep': {
'required': False,
'description': 'HackRF wideband spectrum sweep',
'install': {
'apt': 'sudo apt install hackrf',
'brew': 'brew install hackrf',
'manual': 'https://github.com/greatscottgadgets/hackrf'
}
},
'rtl_433': {
'required': False,
'description': 'Protocol decoder for SubGHz signals',
'install': {
'apt': 'sudo apt install rtl-433',
'brew': 'brew install rtl_433',
'manual': 'https://github.com/merbanan/rtl_433'
}
}
}
},
'tscm': {
'name': 'TSCM Counter-Surveillance',
'tools': {

View File

@@ -6,15 +6,31 @@ Detects RTL-SDR devices via rtl_test and other SDR hardware via SoapySDR.
from __future__ import annotations
import logging
import re
import shutil
import subprocess
from typing import Optional
import logging
import re
import shutil
import subprocess
import time
from typing import Optional
from .base import SDRCapabilities, SDRDevice, SDRType
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
# Cache HackRF detection results so polling endpoints don't repeatedly run
# hackrf_info while the device is actively streaming in SubGHz mode.
_hackrf_cache: list[SDRDevice] = []
_hackrf_cache_ts: float = 0.0
_HACKRF_CACHE_TTL_SECONDS = 3.0
def _hackrf_probe_blocked() -> bool:
"""Return True when probing HackRF would interfere with an active stream."""
try:
from utils.subghz import get_subghz_manager
return get_subghz_manager().active_mode in {'rx', 'decode', 'tx', 'sweep'}
except Exception:
return False
def _check_tool(name: str) -> bool:
@@ -295,16 +311,29 @@ def _add_soapy_device(
))
def detect_hackrf_devices() -> list[SDRDevice]:
"""
Detect HackRF devices using native hackrf_info tool.
Fallback for when SoapySDR is not available.
"""
devices: list[SDRDevice] = []
if not _check_tool('hackrf_info'):
return devices
def detect_hackrf_devices() -> list[SDRDevice]:
"""
Detect HackRF devices using native hackrf_info tool.
Fallback for when SoapySDR is not available.
"""
global _hackrf_cache, _hackrf_cache_ts
now = time.time()
# While HackRF is actively streaming in SubGHz mode, skip probe calls.
# Re-running hackrf_info during active RX/TX can disrupt the USB stream.
if _hackrf_probe_blocked():
return list(_hackrf_cache)
if _hackrf_cache and (now - _hackrf_cache_ts) < _HACKRF_CACHE_TTL_SECONDS:
return list(_hackrf_cache)
devices: list[SDRDevice] = []
if not _check_tool('hackrf_info'):
_hackrf_cache = devices
_hackrf_cache_ts = now
return devices
try:
result = subprocess.run(
@@ -342,10 +371,12 @@ def detect_hackrf_devices() -> list[SDRDevice]:
capabilities=HackRFCommandBuilder.CAPABILITIES
))
except Exception as e:
logger.debug(f"HackRF detection error: {e}")
return devices
except Exception as e:
logger.debug(f"HackRF detection error: {e}")
_hackrf_cache = list(devices)
_hackrf_cache_ts = now
return devices
def probe_rtlsdr_device(device_index: int) -> str | None:

View File

@@ -14,10 +14,16 @@ from typing import Optional
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
from utils.dependencies import get_tool_path
logger = logging.getLogger('intercept.sdr.rtlsdr')
def _get_dump1090_bias_t_flag(dump1090_path: str) -> Optional[str]:
logger = logging.getLogger('intercept.sdr.rtlsdr')
def _rtl_fm_demod_mode(modulation: str) -> str:
"""Map app/UI modulation names to rtl_fm demod tokens."""
mod = str(modulation or '').lower().strip()
return 'wbfm' if mod == 'wfm' else mod
def _get_dump1090_bias_t_flag(dump1090_path: str) -> Optional[str]:
"""Detect the correct bias-t flag for the installed dump1090 variant.
Different dump1090 forks use different flags:
@@ -87,14 +93,15 @@ class RTLSDRCommandBuilder(CommandBuilder):
Used for pager decoding. Supports local devices and rtl_tcp connections.
"""
rtl_fm_path = get_tool_path('rtl_fm') or 'rtl_fm'
cmd = [
rtl_fm_path,
'-d', self._get_device_arg(device),
'-f', f'{frequency_mhz}M',
'-M', modulation,
'-s', str(sample_rate),
]
rtl_fm_path = get_tool_path('rtl_fm') or 'rtl_fm'
demod_mode = _rtl_fm_demod_mode(modulation)
cmd = [
rtl_fm_path,
'-d', self._get_device_arg(device),
'-f', f'{frequency_mhz}M',
'-M', demod_mode,
'-s', str(sample_rate),
]
if gain is not None and gain > 0:
cmd.extend(['-g', str(gain)])

View File

@@ -311,6 +311,10 @@ class VISDetector:
if len(self._data_bits) != 8:
return None
# VIS uses even parity across 8 data bits + parity bit.
if (sum(self._data_bits) + self._parity_bit) % 2 != 0:
return None
# Decode VIS code (LSB first)
vis_code = 0
for i, bit in enumerate(self._data_bits):

2809
utils/subghz.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -3,10 +3,11 @@
Provides automated capture and decoding of weather satellite images using SatDump.
Supported satellites:
- NOAA-15: 137.620 MHz (APT)
- NOAA-18: 137.9125 MHz (APT)
- NOAA-19: 137.100 MHz (APT)
- NOAA-15: 137.620 MHz (APT) [DEFUNCT - decommissioned Aug 2025]
- NOAA-18: 137.9125 MHz (APT) [DEFUNCT - decommissioned Jun 2025]
- NOAA-19: 137.100 MHz (APT) [DEFUNCT - decommissioned Aug 2025]
- Meteor-M2-3: 137.900 MHz (LRPT)
- Meteor-M2-4: 137.900 MHz (LRPT)
Uses SatDump CLI for live SDR capture and decoding, with fallback to
rtl_fm capture for manual decoding when SatDump is unavailable.
@@ -42,8 +43,8 @@ WEATHER_SATELLITES = {
'mode': 'APT',
'pipeline': 'noaa_apt',
'tle_key': 'NOAA-15',
'description': 'NOAA-15 APT (analog weather imagery)',
'active': True,
'description': 'NOAA-15 APT (decommissioned Aug 2025)',
'active': False,
},
'NOAA-18': {
'name': 'NOAA 18',
@@ -51,8 +52,8 @@ WEATHER_SATELLITES = {
'mode': 'APT',
'pipeline': 'noaa_apt',
'tle_key': 'NOAA-18',
'description': 'NOAA-18 APT (analog weather imagery)',
'active': True,
'description': 'NOAA-18 APT (decommissioned Jun 2025)',
'active': False,
},
'NOAA-19': {
'name': 'NOAA 19',
@@ -60,8 +61,8 @@ WEATHER_SATELLITES = {
'mode': 'APT',
'pipeline': 'noaa_apt',
'tle_key': 'NOAA-19',
'description': 'NOAA-19 APT (analog weather imagery)',
'active': True,
'description': 'NOAA-19 APT (decommissioned Aug 2025)',
'active': False,
},
'METEOR-M2-3': {
'name': 'Meteor-M2-3',
@@ -72,6 +73,15 @@ WEATHER_SATELLITES = {
'description': 'Meteor-M2-3 LRPT (digital color imagery)',
'active': True,
},
'METEOR-M2-4': {
'name': 'Meteor-M2-4',
'frequency': 137.900,
'mode': 'LRPT',
'pipeline': 'meteor_m2-x_lrpt',
'tle_key': 'METEOR-M2-4',
'description': 'Meteor-M2-4 LRPT (digital color imagery)',
'active': True,
},
}
# Default sample rate for weather satellite reception