mirror of
https://github.com/smittix/intercept.git
synced 2026-04-23 22:30:00 -07:00
chore: commit all changes and remove large IQ captures from tracking
Add .gitignore entry for data/subghz/captures/ to prevent large IQ recording files from being committed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
48
app.py
@@ -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()
|
||||
|
||||
10
config.py
10
config.py
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
180
routes/aprs.py
180
routes/aprs.py
@@ -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}")
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
|
||||
244
routes/dmr.py
244
routes/dmr.py
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
424
routes/subghz.py
Normal 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
|
||||
8
setup.sh
8
setup.sh
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -340,7 +340,9 @@
|
||||
MODE VISIBILITY - Ensure sidebar shows when active
|
||||
============================================ */
|
||||
#spystationsMode.active {
|
||||
display: block !important;
|
||||
display: flex !important;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
MODE VISIBILITY
|
||||
============================================ */
|
||||
#sstvGeneralMode.active {
|
||||
display: block !important;
|
||||
display: flex !important;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
|
||||
@@ -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
2086
static/css/modes/subghz.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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;">×</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 ==============
|
||||
|
||||
@@ -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
2746
static/js/modes/subghz.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
|
||||
@@ -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">▸</span>
|
||||
<span class="subghz-phase-step" id="subghzPhaseListening">LISTENING</span>
|
||||
<span class="subghz-phase-arrow">▸</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">▼</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 — 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">◀ 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">◀ 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">◀ 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">◀ 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>
|
||||
|
||||
@@ -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;">
|
||||
|
||||
@@ -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>
|
||||
|
||||
175
templates/partials/modes/subghz.html
Normal file
175
templates/partials/modes/subghz.html
Normal 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);">▼</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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 #}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
38
tests/test_rtl_fm_modulation.py
Normal file
38
tests/test_rtl_fm_modulation.py
Normal 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
608
tests/test_subghz.py
Normal 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
433
tests/test_subghz_routes.py
Normal 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')
|
||||
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)])
|
||||
|
||||
@@ -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
2809
utils/subghz.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user