From 7c3ec9e920547e65efea8371d5e43028043db3ee Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 12 Feb 2026 23:30:37 +0000 Subject: [PATCH] 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 --- .gitignore | 3 + app.py | 48 +- config.py | 10 + routes/__init__.py | 2 + routes/aprs.py | 180 +- routes/audio_websocket.py | 32 +- routes/dmr.py | 244 +- routes/listening_post.py | 56 +- routes/satellite.py | 46 +- routes/subghz.py | 424 +++ setup.sh | 8 + static/css/index.css | 118 +- static/css/modes/aprs.css | 47 + static/css/modes/spy-stations.css | 4 +- static/css/modes/sstv-general.css | 4 +- static/css/modes/sstv.css | 4 +- static/css/modes/subghz.css | 2086 ++++++++++++ static/css/responsive.css | 29 +- static/css/settings.css | 32 +- static/js/core/app.js | 8 +- static/js/modes/dmr.js | 108 +- static/js/modes/listening-post.js | 6 +- static/js/modes/subghz.js | 2746 ++++++++++++++++ static/js/modes/weather-satellite.js | 4 +- templates/index.html | 443 ++- templates/partials/modes/bluetooth.html | 26 +- templates/partials/modes/dmr.html | 25 +- templates/partials/modes/subghz.html | 175 + templates/partials/modes/tscm.html | 27 +- .../partials/modes/weather-satellite.html | 33 +- templates/partials/modes/wifi.html | 49 +- templates/partials/nav.html | 4 +- templates/partials/settings-modal.html | 1 - templates/satellite_dashboard.html | 12 +- tests/test_dmr.py | 82 +- tests/test_routes.py | 30 +- tests/test_rtl_fm_modulation.py | 38 + tests/test_subghz.py | 608 ++++ tests/test_subghz_routes.py | 433 +++ utils/constants.py | 44 + utils/dependencies.py | 32 + utils/sdr/detection.py | 71 +- utils/sdr/rtlsdr.py | 31 +- utils/sstv/vis.py | 4 + utils/subghz.py | 2809 +++++++++++++++++ utils/weather_sat.py | 28 +- 46 files changed, 10792 insertions(+), 462 deletions(-) create mode 100644 routes/subghz.py create mode 100644 static/css/modes/subghz.css create mode 100644 static/js/modes/subghz.js create mode 100644 templates/partials/modes/subghz.html create mode 100644 tests/test_rtl_fm_modulation.py create mode 100644 tests/test_subghz.py create mode 100644 tests/test_subghz_routes.py create mode 100644 utils/subghz.py diff --git a/.gitignore b/.gitignore index bc822b4..61e1b26 100644 --- a/.gitignore +++ b/.gitignore @@ -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.* diff --git a/app.py b/app.py index 7a82bd5..7b481f1 100644 --- a/app.py +++ b/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() diff --git a/config.py b/config.py index 973a87f..f0aa71c 100644 --- a/config.py +++ b/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) diff --git a/routes/__init__.py b/routes/__init__.py index 0cb7e1a..a10c0a4 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -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 diff --git a/routes/aprs.py b/routes/aprs.py index cfd346a..39f2516 100644 --- a/routes/aprs.py +++ b/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}") diff --git a/routes/audio_websocket.py b/routes/audio_websocket.py index 4e2acf5..bbe17cb 100644 --- a/routes/audio_websocket.py +++ b/routes/audio_websocket.py @@ -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), ] diff --git a/routes/dmr.py b/routes/dmr.py index ec4db2d..9465657 100644 --- a/routes/dmr.py +++ b/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: diff --git a/routes/listening_post.py b/routes/listening_post.py index 2907f88..ec90bb7 100644 --- a/routes/listening_post.py +++ b/routes/listening_post.py @@ -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): diff --git a/routes/satellite.py b/routes/satellite.py index e3dbefa..c6e3fdf 100644 --- a/routes/satellite.py +++ b/routes/satellite.py @@ -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: diff --git a/routes/subghz.py b/routes/subghz.py new file mode 100644 index 0000000..823e09d --- /dev/null +++ b/routes/subghz.py @@ -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/') +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//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//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/', 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/', 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 diff --git a/setup.sh b/setup.sh index 62c8228..3a81d9a 100755 --- a/setup.sh +++ b/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 diff --git a/static/css/index.css b/static/css/index.css index ab517b2..8e90f7a 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -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; diff --git a/static/css/modes/aprs.css b/static/css/modes/aprs.css index f28ccf7..2ef4cc2 100644 --- a/static/css/modes/aprs.css +++ b/static/css/modes/aprs.css @@ -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); +} diff --git a/static/css/modes/spy-stations.css b/static/css/modes/spy-stations.css index 604ac39..4e3108c 100644 --- a/static/css/modes/spy-stations.css +++ b/static/css/modes/spy-stations.css @@ -340,7 +340,9 @@ MODE VISIBILITY - Ensure sidebar shows when active ============================================ */ #spystationsMode.active { - display: block !important; + display: flex !important; + flex-direction: column; + gap: 10px; } /* ============================================ diff --git a/static/css/modes/sstv-general.css b/static/css/modes/sstv-general.css index 34f5bbc..235d386 100644 --- a/static/css/modes/sstv-general.css +++ b/static/css/modes/sstv-general.css @@ -7,7 +7,9 @@ MODE VISIBILITY ============================================ */ #sstvGeneralMode.active { - display: block !important; + display: flex !important; + flex-direction: column; + gap: 10px; } /* ============================================ diff --git a/static/css/modes/sstv.css b/static/css/modes/sstv.css index 9c987ce..14601aa 100644 --- a/static/css/modes/sstv.css +++ b/static/css/modes/sstv.css @@ -7,7 +7,9 @@ MODE VISIBILITY ============================================ */ #sstvMode.active { - display: block !important; + display: flex !important; + flex-direction: column; + gap: 10px; } /* ============================================ diff --git a/static/css/modes/subghz.css b/static/css/modes/subghz.css new file mode 100644 index 0000000..157e9fa --- /dev/null +++ b/static/css/modes/subghz.css @@ -0,0 +1,2086 @@ +/* SubGHz Transceiver Mode Styles */ + +/* ===== Device Status ===== */ +.subghz-device-status { + padding: 8px 10px; + background: var(--bg-tertiary, #1a1f2e); + border-radius: 4px; + font-family: 'JetBrains Mono', monospace; + font-size: 11px; +} + +.subghz-device-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} + +.subghz-device-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + background: var(--text-dim, #666); +} + +.subghz-device-dot.connected { + background: #00ff88; + box-shadow: 0 0 6px rgba(0, 255, 136, 0.4); +} + +.subghz-device-dot.disconnected { + background: var(--accent-red, #ff4444); +} + +.subghz-device-dot.unknown { + background: #ffaa00; +} + +.subghz-device-label { + color: var(--text-secondary, #999); +} + +.subghz-device-label.error { + color: var(--accent-red, #ff4444); +} + +.subghz-device-tools { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.subghz-tool-badge { + padding: 2px 8px; + border-radius: 3px; + font-size: 9px; + letter-spacing: 0.3px; + border: 1px solid var(--border-color, #2a3040); + color: var(--text-dim, #666); +} + +.subghz-tool-badge.available { + border-color: rgba(0, 255, 136, 0.3); + color: #00ff88; + background: rgba(0, 255, 136, 0.05); +} + +.subghz-tool-badge.missing { + border-color: rgba(255, 68, 68, 0.3); + color: var(--accent-red, #ff4444); + background: rgba(255, 68, 68, 0.05); +} + +/* ===== Sidebar Sections ===== */ +.subghz-preset-btns { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 6px; +} + +.subghz-preset-btn { + padding: 4px 10px; + border: 1px solid var(--border-color, #2a3040); + border-radius: 4px; + background: var(--bg-tertiary, #1a1f2e); + color: var(--text-primary, #e0e0e0); + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} + +.subghz-preset-btn:hover { + background: var(--accent-cyan, #00d4ff); + color: #000; + border-color: var(--accent-cyan, #00d4ff); +} + +/* Tab navigation for RX / Decode / Sweep */ +.subghz-tabs { + display: flex; + gap: 0; + border-bottom: 1px solid var(--border-color, #2a3040); + margin-bottom: 12px; +} + +.subghz-tab { + flex: 1; + padding: 8px 4px; + border: none; + border-bottom: 2px solid transparent; + background: transparent; + color: var(--text-dim, #666); + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + text-transform: uppercase; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; + text-align: center; +} + +.subghz-tab:hover { + color: var(--text-primary, #e0e0e0); +} + +.subghz-tab.active { + color: var(--accent-cyan, #00d4ff); + border-bottom-color: var(--accent-cyan, #00d4ff); +} + +.subghz-tab-content { + display: none; +} + +.subghz-tab-content.active { + display: block; +} + +.subghz-trigger-box { + margin-bottom: 10px; + padding: 8px 9px; + border: 1px solid var(--border-color, #2a3040); + border-radius: 4px; + background: rgba(0, 0, 0, 0.18); + display: flex; + flex-direction: column; + gap: 7px; +} + +.subghz-trigger-toggle { + display: inline-flex; + align-items: center; + gap: 7px; + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + color: var(--text-secondary, #999); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.subghz-trigger-grid { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 5px 8px; + align-items: center; +} + +.subghz-trigger-grid label { + font-family: 'JetBrains Mono', monospace; + font-size: 9px; + color: var(--text-dim, #666); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.subghz-trigger-grid input { + width: 100%; + padding: 4px 6px; + border: 1px solid var(--border-color, #2a3040); + border-radius: 4px; + background: var(--bg-primary, #0d1117); + color: var(--text-primary, #e0e0e0); + font-family: 'JetBrains Mono', monospace; + font-size: 10px; +} + +.subghz-trigger-grid input:disabled { + opacity: 0.45; +} + +.subghz-trigger-help { + margin: 0; + font-family: 'JetBrains Mono', monospace; + font-size: 9px; + color: var(--text-dim, #666); + line-height: 1.4; +} + +/* Status indicator */ +.subghz-status-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + background: var(--bg-tertiary, #1a1f2e); + border-radius: 4px; + margin-bottom: 10px; + font-family: 'JetBrains Mono', monospace; + font-size: 11px; +} + +.subghz-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-dim, #666); + flex-shrink: 0; +} + +.subghz-status-dot.rx { + background: #00ff88; + animation: subghz-pulse 1.5s ease-in-out infinite; +} + +.subghz-status-dot.decode { + background: #00d4ff; + animation: subghz-pulse 0.8s ease-in-out infinite; +} + +.subghz-status-dot.tx { + background: #ff4444; + animation: subghz-pulse 0.5s ease-in-out infinite; +} + +.subghz-status-dot.sweep { + background: #ffaa00; + animation: subghz-pulse 1s ease-in-out infinite; +} + +@keyframes subghz-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +.subghz-status-text { + color: var(--text-secondary, #999); + flex: 1; +} + +.subghz-status-timer { + color: var(--accent-cyan, #00d4ff); +} + +/* Control buttons */ +.subghz-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 16px; + border: 1px solid var(--border-color, #2a3040); + border-radius: 4px; + background: transparent; + color: var(--text-primary, #e0e0e0); + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; + width: 100%; +} + +.subghz-btn-row { + display: flex; + gap: 8px; +} + +.subghz-btn-row .subghz-btn { + width: auto; + flex: 1; +} + +.subghz-btn:disabled, +.subghz-btn.disabled { + opacity: 0.5; + cursor: not-allowed; + box-shadow: none; +} + +.subghz-btn:disabled:hover, +.subghz-btn.disabled:hover { + background: transparent; + border-color: var(--border-color, #2a3040); +} + +.subghz-btn:hover { + background: var(--bg-tertiary, #1a1f2e); + border-color: var(--accent-cyan, #00d4ff); +} + +.subghz-btn.active { + background: rgba(0, 212, 255, 0.1); + border-color: var(--accent-cyan, #00d4ff); + color: var(--accent-cyan, #00d4ff); +} + +.subghz-btn.start { + background: var(--accent-green, #22c55e); + border-color: var(--accent-green, #22c55e); + color: #fff; + font-weight: 600; +} + +.subghz-btn.start:hover { + background: #1db954; + border-color: #1db954; + box-shadow: 0 2px 8px rgba(34, 197, 94, 0.3); +} + +.subghz-btn.stop { + background: var(--accent-red, #ff4444); + border-color: var(--accent-red, #ff4444); + color: #fff; + font-weight: 600; +} + +.subghz-btn.stop:hover { + background: #e03c3c; + border-color: #e03c3c; + box-shadow: 0 2px 8px rgba(255, 68, 68, 0.3); +} + +.subghz-btn.tx-btn { + border-color: var(--accent-red, #ff4444); + color: var(--accent-red, #ff4444); +} + +.subghz-btn.tx-btn:hover { + background: rgba(255, 68, 68, 0.15); +} + +/* Capture library */ +.subghz-captures-list { + max-height: 300px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 6px; +} + +/* Full-height list for main Saved/Transmit selection panel */ +.subghz-captures-list-main { + max-height: none; + flex: 1; + min-height: 0; + padding-right: 4px; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + align-content: start; + gap: 10px; +} + +.subghz-capture-card { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px 10px; + background: var(--bg-tertiary, #1a1f2e); + border: 1px solid var(--border-color, #2a3040); + border-radius: 4px; + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + min-width: 0; + transition: border-color 0.15s, box-shadow 0.15s, background 0.15s; +} + +.subghz-capture-card.has-bursts { + border-color: rgba(255, 170, 0, 0.5); +} + +.subghz-capture-card.select-mode { + cursor: pointer; +} + +.subghz-capture-card.selected { + border-color: rgba(0, 212, 255, 0.85); + box-shadow: 0 0 0 1px rgba(0, 212, 255, 0.3); + background: rgba(0, 212, 255, 0.06); +} + +.subghz-capture-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.subghz-capture-header-right { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.subghz-capture-burst-badge { + padding: 1px 6px; + border-radius: 999px; + border: 1px solid rgba(255, 170, 0, 0.55); + color: #ffaa00; + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.4px; + background: rgba(255, 170, 0, 0.12); +} + +.subghz-capture-burst-line { + display: inline-flex; + align-items: center; + gap: 8px; + font-family: 'JetBrains Mono', monospace; + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.subghz-capture-burst-flag { + color: #ffaa00; +} + +.subghz-capture-burst-count { + padding: 1px 6px; + border-radius: 999px; + border: 1px solid rgba(255, 170, 0, 0.55); + color: #ffaa00; + background: rgba(255, 170, 0, 0.12); +} + +.subghz-capture-tag-row { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.subghz-capture-tag { + display: inline-flex; + align-items: center; + padding: 1px 6px; + border-radius: 999px; + border: 1px solid var(--border-color, #2a3040); + font-family: 'JetBrains Mono', monospace; + font-size: 9px; + letter-spacing: 0.35px; + color: var(--text-dim, #666); + text-transform: uppercase; + background: rgba(0, 0, 0, 0.2); +} + +.subghz-capture-tag.auto { + border-color: rgba(0, 212, 255, 0.55); + color: #00d4ff; + background: rgba(0, 212, 255, 0.12); +} + +.subghz-capture-tag.hint { + border-color: rgba(0, 255, 136, 0.5); + color: #00ff88; + background: rgba(0, 255, 136, 0.12); +} + +.subghz-capture-tag.fingerprint { + border-color: rgba(180, 120, 255, 0.55); + color: #b478ff; + background: rgba(180, 120, 255, 0.12); +} + +.subghz-capture-freq { + color: var(--accent-cyan, #00d4ff); + font-weight: 600; +} + +.subghz-capture-time { + color: var(--text-dim, #666); + font-size: 10px; +} + +.subghz-capture-meta { + display: flex; + gap: 12px; + color: var(--text-dim, #666); + font-size: 10px; +} + +.subghz-capture-label { + color: var(--text-secondary, #999); + font-style: italic; +} + +.subghz-capture-actions { + display: flex; + gap: 6px; + margin-top: 4px; +} + +.subghz-capture-actions.select-mode { + justify-content: flex-end; +} + +.subghz-capture-actions button { + padding: 3px 8px; + border: 1px solid var(--border-color, #2a3040); + border-radius: 3px; + background: transparent; + color: var(--text-dim, #666); + font-size: 10px; + cursor: pointer; + font-family: 'JetBrains Mono', monospace; +} + +.subghz-capture-actions button:hover { + color: var(--text-primary, #e0e0e0); + border-color: var(--text-dim, #666); +} + +.subghz-capture-actions button.replay-btn:hover { + color: var(--accent-red, #ff4444); + border-color: var(--accent-red, #ff4444); +} + +.subghz-capture-actions button.trim-btn:hover { + color: #00d4ff; + border-color: #00d4ff; +} + +.subghz-capture-actions button.delete-btn:hover { + color: var(--accent-red, #ff4444); + border-color: var(--accent-red, #ff4444); +} + +.subghz-capture-actions button.select-btn { + border-color: rgba(0, 212, 255, 0.5); + color: #00d4ff; +} + +.subghz-capture-actions button.select-btn.selected { + border-color: rgba(0, 212, 255, 0.9); + background: rgba(0, 212, 255, 0.18); + color: #7beeff; +} + +/* TX warning */ +.subghz-tx-warning { + padding: 8px 10px; + background: rgba(255, 68, 68, 0.08); + border: 1px solid rgba(255, 68, 68, 0.3); + border-radius: 4px; + color: var(--accent-red, #ff4444); + font-size: 10px; + font-family: 'JetBrains Mono', monospace; + line-height: 1.4; + margin-bottom: 8px; +} + +/* Sweep range inputs */ +.subghz-sweep-range { + display: flex; + gap: 8px; + align-items: center; +} + +.subghz-sweep-range input { + flex: 1; +} + +.subghz-sweep-range span { + color: var(--text-dim, #666); + font-size: 11px; +} + +/* ===== Visuals Area ===== */ +.subghz-visuals-container { + display: flex; + flex-direction: column; + gap: 12px; + height: 100%; +} + +/* Decode output area */ +.subghz-decode-output { + flex: 1; + background: var(--bg-primary, #0d1117); + border: 1px solid var(--border-color, #2a3040); + border-radius: 6px; + padding: 12px; + overflow-y: auto; + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + color: var(--text-primary, #e0e0e0); + min-height: 200px; +} + +.subghz-decode-entry { + padding: 6px 0; + border-bottom: 1px solid var(--border-color, #2a3040); + animation: subghz-fade-in 0.3s ease; +} + +.subghz-decode-entry.is-raw { + color: var(--text-secondary, #b8c3cf); +} + +.subghz-decode-rawtext { + color: var(--text-secondary, #b8c3cf); + margin-left: 10px; +} + +.subghz-decode-entry:last-child { + border-bottom: none; +} + +@keyframes subghz-fade-in { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +.subghz-decode-model { + color: var(--accent-cyan, #00d4ff); + font-weight: 600; +} + +.subghz-decode-field { + color: var(--text-dim, #666); + margin-left: 12px; +} + +.subghz-decode-field strong { + color: var(--text-secondary, #999); +} + +/* Sweep chart */ +.subghz-sweep-chart-wrapper { + flex: 1; + background: var(--bg-primary, #0d1117); + border: 1px solid var(--border-color, #2a3040); + border-radius: 6px; + padding: 12px; + min-height: 250px; + position: relative; +} + +.subghz-sweep-chart-wrapper canvas { + width: 100%; + height: 100%; +} + +/* TX confirmation modal */ +.subghz-tx-modal-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.7); + z-index: 10000; + justify-content: center; + align-items: center; +} + +.subghz-tx-modal-overlay.active { + display: flex; +} + +.subghz-tx-modal { + background: var(--bg-card, #161b22); + border: 2px solid var(--accent-red, #ff4444); + border-radius: 8px; + padding: 24px; + max-width: 480px; + width: 90%; + text-align: center; +} + +.subghz-tx-modal h3 { + color: var(--accent-red, #ff4444); + margin: 0 0 12px 0; + font-size: 16px; +} + +.subghz-tx-modal p { + color: var(--text-secondary, #999); + font-size: 12px; + line-height: 1.5; + margin: 0 0 8px 0; +} + +.subghz-tx-modal .tx-freq { + color: var(--accent-cyan, #00d4ff); + font-weight: 600; + font-family: 'JetBrains Mono', monospace; +} + +.subghz-tx-modal .tx-duration { + color: var(--text-dim, #666); + font-family: 'JetBrains Mono', monospace; +} + +.subghz-tx-segment-box { + margin: 10px 0 6px 0; + padding: 10px; + border: 1px solid var(--border-color, #2a3040); + border-radius: 6px; + background: rgba(13, 17, 23, 0.45); + text-align: left; +} + +.subghz-tx-segment-toggle { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + color: var(--text-secondary, #999); + margin-bottom: 10px; +} + +.subghz-tx-segment-grid { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 6px 8px; + align-items: center; +} + +.subghz-tx-segment-grid label { + font-size: 10px; + color: var(--text-dim, #666); + text-transform: uppercase; + letter-spacing: 0.4px; +} + +.subghz-tx-segment-grid input { + width: 100%; + padding: 5px 7px; + border: 1px solid var(--border-color, #2a3040); + border-radius: 4px; + background: var(--bg-primary, #0d1117); + color: var(--text-primary, #e0e0e0); + font-family: 'JetBrains Mono', monospace; + font-size: 11px; +} + +.subghz-tx-segment-grid input:disabled { + opacity: 0.5; +} + +.subghz-tx-segment-summary { + margin-top: 8px !important; + margin-bottom: 0 !important; + font-size: 11px !important; + color: var(--accent-cyan, #00d4ff) !important; + font-family: 'JetBrains Mono', monospace; +} + +.subghz-tx-burst-assist { + margin: 10px 0 6px 0; + padding: 10px; + border: 1px solid var(--border-color, #2a3040); + border-radius: 6px; + background: rgba(13, 17, 23, 0.45); + text-align: left; +} + +.subghz-tx-burst-title { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + color: var(--text-dim, #666); + text-transform: uppercase; + letter-spacing: 0.6px; + margin-bottom: 8px; +} + +.subghz-tx-burst-timeline { + position: relative; + height: 26px; + border: 1px solid var(--border-color, #2a3040); + border-radius: 4px; + background: linear-gradient(90deg, rgba(0, 212, 255, 0.07), rgba(255, 170, 0, 0.07)); + margin-bottom: 8px; + overflow: hidden; +} + +.subghz-tx-burst-timeline.dragging { + border-color: rgba(0, 212, 255, 0.65); + box-shadow: 0 0 0 1px rgba(0, 212, 255, 0.25) inset; +} + +.subghz-tx-burst-selection { + position: absolute; + top: 3px; + bottom: 3px; + border-radius: 3px; + border: 1px solid rgba(0, 212, 255, 0.95); + background: rgba(0, 212, 255, 0.22); + pointer-events: none; + display: none; + z-index: 2; +} + +.subghz-tx-burst-range { + margin: 0 0 8px 0; + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + color: var(--accent-cyan, #00d4ff); +} + +.subghz-tx-burst-marker { + position: absolute; + top: 4px; + bottom: 4px; + padding: 0; + border-radius: 2px; + background: rgba(255, 170, 0, 0.8); + border: 1px solid rgba(255, 170, 0, 1); + cursor: pointer; + z-index: 3; +} + +.subghz-tx-burst-marker:hover { + background: rgba(0, 212, 255, 0.85); + border-color: rgba(0, 212, 255, 1); +} + +.subghz-tx-burst-list { + max-height: 124px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 6px; +} + +.subghz-tx-burst-empty { + padding: 6px; + border: 1px dashed var(--border-color, #2a3040); + border-radius: 4px; + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + color: var(--text-dim, #666); + line-height: 1.4; +} + +.subghz-tx-burst-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 5px 6px; + border: 1px solid var(--border-color, #2a3040); + border-radius: 4px; + background: rgba(0, 0, 0, 0.15); + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + color: var(--text-secondary, #999); +} + +.subghz-tx-burst-item button { + padding: 2px 8px; + border: 1px solid rgba(0, 212, 255, 0.5); + border-radius: 3px; + background: transparent; + color: #00d4ff; + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + cursor: pointer; +} + +.subghz-tx-burst-item button:hover { + background: rgba(0, 212, 255, 0.12); +} + +.subghz-tx-modal-actions { + display: flex; + gap: 12px; + margin-top: 16px; + justify-content: center; +} + +.subghz-tx-modal-actions button { + padding: 8px 20px; + border-radius: 4px; + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + cursor: pointer; + border: 1px solid; +} + +.subghz-tx-confirm-btn { + background: var(--accent-red, #ff4444); + color: #fff; + border-color: var(--accent-red, #ff4444) !important; +} + +.subghz-tx-confirm-btn:hover { + background: #cc3333; +} + +.subghz-tx-trim-btn { + background: rgba(0, 212, 255, 0.14); + color: #00d4ff; + border-color: rgba(0, 212, 255, 0.55) !important; +} + +.subghz-tx-trim-btn:hover { + background: rgba(0, 212, 255, 0.26); +} + +.subghz-tx-cancel-btn { + background: transparent; + color: var(--text-primary, #e0e0e0); + border-color: var(--border-color, #2a3040) !important; +} + +.subghz-tx-cancel-btn:hover { + border-color: var(--text-dim, #666) !important; +} + +/* Empty state */ +.subghz-empty { + text-align: center; + color: var(--text-dim, #666); + font-size: 12px; + padding: 24px 12px; + font-family: 'JetBrains Mono', monospace; +} + +.subghz-captures-list-main .subghz-empty { + grid-column: 1 / -1; +} + +/* ===== Interactive Sweep Components ===== */ + +/* Hover tooltip */ +.subghz-sweep-tooltip { + position: fixed; + pointer-events: none; + background: rgba(13, 17, 23, 0.92); + border: 1px solid #2a3040; + border-radius: 4px; + padding: 5px 9px; + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + z-index: 9999; + display: none; + white-space: nowrap; + line-height: 1.5; +} + +.subghz-sweep-tooltip .tip-freq { + color: var(--accent-cyan, #00d4ff); +} + +.subghz-sweep-tooltip .tip-power { + color: #ffaa00; +} + +/* Right-click context menu */ +.subghz-sweep-ctx-menu { + position: fixed; + display: none; + background: var(--bg-card, #161b22); + border: 1px solid var(--border-color, #2a3040); + border-radius: 5px; + z-index: 10000; + min-width: 180px; + padding: 4px 0; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.6); + font-family: 'JetBrains Mono', monospace; + font-size: 11px; +} + +.subghz-ctx-header { + padding: 5px 12px; + color: var(--text-dim, #666); + font-size: 10px; + border-bottom: 1px solid var(--border-color, #2a3040); + margin-bottom: 2px; +} + +.subghz-ctx-item { + padding: 6px 12px; + cursor: pointer; + color: var(--text-primary, #e0e0e0); + transition: background 0.1s; +} + +.subghz-ctx-item:hover { + background: var(--bg-tertiary, #1a1f2e); +} + +.subghz-ctx-item .ctx-icon { + display: inline-block; + width: 18px; + text-align: center; + margin-right: 4px; +} + +/* Floating action bar */ +.subghz-sweep-action-bar { + position: fixed; + display: flex; + gap: 4px; + background: rgba(13, 17, 23, 0.95); + border: 1px solid var(--border-color, #2a3040); + border-radius: 5px; + padding: 4px; + z-index: 9998; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5); + opacity: 0; + transform: translateY(4px); + transition: opacity 0.15s, transform 0.15s; + pointer-events: none; +} + +.subghz-sweep-action-bar.visible { + opacity: 1; + transform: translateY(0); + pointer-events: auto; +} + +.subghz-action-btn { + padding: 4px 10px; + border: 1px solid var(--border-color, #2a3040); + border-radius: 3px; + background: transparent; + color: var(--text-primary, #e0e0e0); + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + cursor: pointer; + transition: background 0.12s, border-color 0.12s, color 0.12s; + white-space: nowrap; +} + +.subghz-action-btn.tune:hover { + background: rgba(0, 255, 136, 0.12); + border-color: #00ff88; + color: #00ff88; +} + +.subghz-action-btn.decode:hover { + background: rgba(0, 212, 255, 0.12); + border-color: var(--accent-cyan, #00d4ff); + color: var(--accent-cyan, #00d4ff); +} + +.subghz-action-btn.capture:hover { + background: rgba(255, 170, 0, 0.12); + border-color: #ffaa00; + color: #ffaa00; +} + +/* Peak list in sidebar */ +.subghz-peak-list { + max-height: 160px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 3px; + margin-top: 8px; +} + +.subghz-peak-list:empty::after { + content: 'No peaks detected'; + color: var(--text-dim, #666); + font-size: 10px; + font-family: 'JetBrains Mono', monospace; + padding: 6px 0; + text-align: center; +} + +.subghz-peak-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 4px 8px; + background: var(--bg-tertiary, #1a1f2e); + border: 1px solid var(--border-color, #2a3040); + border-radius: 3px; + cursor: pointer; + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + transition: border-color 0.12s; +} + +.subghz-peak-item:hover { + border-color: #ffaa00; +} + +.subghz-peak-item .peak-freq { + color: var(--accent-cyan, #00d4ff); +} + +.subghz-peak-item .peak-power { + color: #ffaa00; +} + +/* ===== Stats Strip ===== */ +.subghz-stats-strip { + display: flex; + align-items: center; + gap: 0; + background: var(--bg-card, #161b22); + border: 1px solid var(--border-color, #2a3040); + border-radius: 6px; + padding: 6px 12px; + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + flex-shrink: 0; + flex-wrap: wrap; +} + +.subghz-strip-group { + display: flex; + align-items: center; + gap: 10px; + padding: 0 10px; +} + +.subghz-strip-divider { + width: 1px; + height: 20px; + background: var(--border-color, #2a3040); + flex-shrink: 0; +} + +.subghz-strip-status { + display: flex; + align-items: center; + gap: 6px; +} + +.subghz-strip-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-dim, #666); + flex-shrink: 0; + transition: background 0.2s; +} + +.subghz-strip-dot.active { + animation: subghz-pulse 1.5s ease-in-out infinite; +} + +.subghz-strip-dot.rx { background: #00ff88; } +.subghz-strip-dot.decode { background: #00d4ff; } +.subghz-strip-dot.tx { background: #ff4444; } +.subghz-strip-dot.sweep { background: #ffaa00; } + +.subghz-strip-status-text { + color: var(--text-secondary, #999); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.subghz-strip-stat { + display: flex; + align-items: baseline; + gap: 4px; +} + +.subghz-strip-value { + font-weight: 600; + color: var(--text-primary, #e0e0e0); +} + +.subghz-strip-value.accent-cyan { color: var(--accent-cyan, #00d4ff); } +.subghz-strip-value.accent-green { color: #00ff88; } +.subghz-strip-value.accent-orange { color: #ffaa00; } + +.subghz-strip-label { + color: var(--text-dim, #666); + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.subghz-strip-timer { + color: var(--accent-cyan, #00d4ff); + font-weight: 600; + min-width: 40px; +} + +.subghz-strip-start-btn { + padding: 2px 10px; + border: 1px solid #22c55e; + border-radius: 3px; + background: transparent; + color: #22c55e; + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + cursor: pointer; + text-transform: uppercase; + letter-spacing: 0.5px; + transition: background 0.15s; +} + +.subghz-strip-start-btn:hover { + background: rgba(34, 197, 94, 0.15); +} + +.subghz-strip-device-badge { + display: flex; + align-items: center; + gap: 5px; + padding: 2px 8px; + border: 1px solid var(--border-color, #2a3040); + border-radius: 3px; + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + color: var(--text-secondary, #999); + letter-spacing: 0.3px; +} + +.subghz-strip-device-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text-dim, #666); +} + +.subghz-strip-device-dot.connected { + background: #00ff88; +} + +.subghz-strip-device-dot.disconnected { + background: var(--accent-red, #ff4444); +} + +.subghz-strip-device-dot.unknown { + background: #ffaa00; +} + +/* ===== Signal Console ===== */ +.subghz-signal-console { + background: var(--bg-card, #161b22); + border: 1px solid var(--border-color, #2a3040); + border-radius: 6px; + overflow: hidden; + flex-shrink: 0; +} + +.subghz-console-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 12px; + cursor: pointer; + user-select: none; + border-bottom: 1px solid var(--border-color, #2a3040); +} + +.subghz-console-header:hover { + background: rgba(255, 255, 255, 0.02); +} + +.subghz-phase-strip { + display: flex; + align-items: center; + gap: 6px; + font-family: 'JetBrains Mono', monospace; + font-size: 10px; +} + +.subghz-phase-step { + color: var(--text-dim, #666); + letter-spacing: 0.5px; + transition: color 0.3s; +} + +.subghz-phase-step.active { + color: var(--accent-cyan, #00d4ff); + text-shadow: 0 0 6px rgba(0, 212, 255, 0.3); +} + +.subghz-phase-step.completed { + color: #00ff88; +} + +.subghz-phase-step.error { + color: var(--accent-red, #ff4444); +} + +.subghz-phase-arrow { + color: var(--text-dim, #666); + font-size: 8px; +} + +.subghz-burst-indicator { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 8px; + border: 1px solid var(--border-color, #2a3040); + border-radius: 999px; + margin-left: auto; + margin-right: 8px; + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + color: var(--text-dim, #666); + background: rgba(0, 0, 0, 0.15); + transition: border-color 0.15s, color 0.15s, background 0.15s; +} + +.subghz-burst-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: #445066; + flex-shrink: 0; +} + +.subghz-burst-indicator.active { + border-color: rgba(255, 170, 0, 0.6); + color: #ffaa00; + background: rgba(255, 170, 0, 0.12); +} + +.subghz-burst-indicator.active .subghz-burst-dot { + background: #ffaa00; + box-shadow: 0 0 10px rgba(255, 170, 0, 0.7); + animation: subghz-pulse 0.45s ease-in-out infinite; +} + +.subghz-burst-indicator.recent { + border-color: rgba(0, 212, 255, 0.45); + color: #00d4ff; + background: rgba(0, 212, 255, 0.1); +} + +.subghz-burst-indicator.recent .subghz-burst-dot { + background: #00d4ff; +} + +.subghz-console-toggle { + background: none; + border: none; + color: var(--text-dim, #666); + cursor: pointer; + font-size: 10px; + padding: 2px 4px; + transition: transform 0.2s; +} + +.subghz-console-toggle.collapsed { + transform: rotate(-90deg); +} + +.subghz-console-body { + max-height: 120px; + overflow: hidden; + transition: max-height 0.25s ease; +} + +.subghz-console-body.collapsed { + max-height: 0; +} + +.subghz-console-log { + padding: 6px 12px; + overflow-y: auto; + max-height: 114px; + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + line-height: 1.6; +} + +.subghz-log-entry { + display: flex; + gap: 8px; + animation: subghz-fade-in 0.2s ease; +} + +.subghz-log-ts { + color: var(--text-dim, #666); + flex-shrink: 0; +} + +.subghz-log-msg { color: var(--text-secondary, #999); } +.subghz-log-msg.info { color: var(--accent-cyan, #00d4ff); } +.subghz-log-msg.success { color: #00ff88; } +.subghz-log-msg.warn { color: #ffaa00; } +.subghz-log-msg.error { color: var(--accent-red, #ff4444); } + +/* ===== Action Hub ===== */ +.subghz-action-hub { + display: flex; + flex-direction: column; + flex: 1; + justify-content: center; + padding: 24px; + gap: 16px; +} + +.subghz-hub-header { + text-align: center; +} + +.subghz-hub-header-title { + font-family: 'JetBrains Mono', monospace; + font-size: 20px; + font-weight: 700; + color: var(--accent-cyan, #00d4ff); + letter-spacing: 1px; +} + +.subghz-hub-header-sub { + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + color: var(--text-dim, #666); + margin-top: 2px; +} + +.subghz-hub-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.subghz-hub-card { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 24px 16px; + background: var(--bg-card, #161b22); + border: 1px solid var(--border-color, #2a3040); + border-radius: 8px; + cursor: pointer; + transition: border-color 0.2s, background 0.2s, transform 0.15s; + text-align: center; +} + +.subghz-hub-card:hover { + transform: translateY(-2px); +} + +.subghz-hub-card:active { + transform: translateY(0); +} + +.subghz-hub-card--cyan { border-color: rgba(0, 212, 255, 0.2); } +.subghz-hub-card--cyan:hover { border-color: var(--accent-cyan, #00d4ff); background: rgba(0, 212, 255, 0.05); } +.subghz-hub-card--cyan .subghz-hub-icon { color: var(--accent-cyan, #00d4ff); } + +.subghz-hub-card--green { border-color: rgba(0, 255, 136, 0.2); } +.subghz-hub-card--green:hover { border-color: #00ff88; background: rgba(0, 255, 136, 0.05); } +.subghz-hub-card--green .subghz-hub-icon { color: #00ff88; } + +.subghz-hub-card--orange { border-color: rgba(255, 170, 0, 0.2); } +.subghz-hub-card--orange:hover { border-color: #ffaa00; background: rgba(255, 170, 0, 0.05); } +.subghz-hub-card--orange .subghz-hub-icon { color: #ffaa00; } + +.subghz-hub-card--red { border-color: rgba(255, 68, 68, 0.25); } +.subghz-hub-card--red:hover { border-color: #ff4444; background: rgba(255, 68, 68, 0.08); } +.subghz-hub-card--red .subghz-hub-icon { color: #ff6b6b; } + +.subghz-hub-card--purple { border-color: rgba(180, 120, 255, 0.2); } +.subghz-hub-card--purple:hover { border-color: #b478ff; background: rgba(180, 120, 255, 0.05); } +.subghz-hub-card--purple .subghz-hub-icon { color: #b478ff; } + +.subghz-hub-icon { + display: flex; + align-items: center; + justify-content: center; +} + +.subghz-hub-title { + font-family: 'JetBrains Mono', monospace; + font-size: 14px; + font-weight: 600; + color: var(--text-primary, #e0e0e0); +} + +.subghz-hub-desc { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + color: var(--text-dim, #666); +} + +/* ===== Operation Panels ===== */ +.subghz-op-panel { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.subghz-op-panel-header { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + background: var(--bg-card, #161b22); + border: 1px solid var(--border-color, #2a3040); + border-radius: 6px; + margin-bottom: 8px; + flex-shrink: 0; +} + +.subghz-op-panel-actions { + margin-left: auto; + display: flex; + gap: 8px; +} + +.subghz-op-panel-actions .subghz-btn { + padding: 6px 12px; + font-size: 11px; + width: auto; +} + +.subghz-saved-actions { + align-items: center; +} + +.subghz-saved-actions .subghz-btn { + min-width: 92px; +} + +.subghz-saved-selection-count { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + color: var(--accent-cyan, #00d4ff); + margin-right: 4px; +} + +.subghz-op-back-btn { + padding: 3px 10px; + border: 1px solid var(--border-color, #2a3040); + border-radius: 4px; + background: transparent; + color: var(--text-secondary, #999); + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + cursor: pointer; + transition: border-color 0.15s, color 0.15s; +} + +.subghz-op-back-btn:hover { + border-color: var(--accent-cyan, #00d4ff); + color: var(--accent-cyan, #00d4ff); +} + +.subghz-op-panel-title { + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + color: var(--text-primary, #e0e0e0); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.subghz-decode-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 360px; + gap: 12px; + flex: 1; + min-height: 0; +} + +.subghz-decode-telemetry { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px; + background: var(--bg-card, #161b22); + border: 1px solid var(--border-color, #2a3040); + border-radius: 6px; + min-height: 0; +} + +.subghz-decode-scopes { + display: flex; + flex-direction: column; + gap: 12px; +} + +@media (max-width: 1200px) { + .subghz-rx-info-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 900px) { + .subghz-decode-layout { + grid-template-columns: 1fr; + } + + .subghz-decode-scopes { + grid-template-columns: 1fr; + } +} + +/* ===== RX Display ===== */ +.subghz-rx-display { + flex: 1; + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: flex-start; + gap: 12px; + padding: 12px 16px; + overflow: auto; +} + +.subghz-rx-display > * { + width: 100%; + max-width: 980px; + margin: 0 auto; +} + +.subghz-rx-recording { + display: flex; + align-items: center; + gap: 8px; + font-family: 'JetBrains Mono', monospace; + font-size: 14px; + font-weight: 600; + color: var(--accent-red, #ff4444); + letter-spacing: 1px; +} + +.subghz-rx-rec-dot { + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--accent-red, #ff4444); + animation: subghz-pulse 0.8s ease-in-out infinite; +} + +.subghz-rx-info-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + width: 100%; + max-width: 980px; +} + +.subghz-rx-info-item { + display: flex; + flex-direction: column; + gap: 2px; + padding: 10px; + background: rgba(0, 0, 0, 0.3); + border-radius: 4px; + text-align: center; +} + +.subghz-rx-info-label { + font-family: 'JetBrains Mono', monospace; + font-size: 9px; + color: var(--text-dim, #666); + letter-spacing: 0.5px; +} + +.subghz-rx-info-value { + font-family: 'JetBrains Mono', monospace; + font-size: 16px; + font-weight: 600; + color: var(--text-primary, #e0e0e0); +} + +.subghz-rx-info-value.accent-cyan { color: var(--accent-cyan, #00d4ff); } + +.subghz-rx-level-wrapper { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + max-width: 980px; +} + +.subghz-rx-hint { + width: 100%; + max-width: 980px; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + padding: 7px 10px; + border: 1px solid var(--border-color, #2a3040); + border-radius: 4px; + background: rgba(0, 0, 0, 0.22); + font-family: 'JetBrains Mono', monospace; +} + +.subghz-rx-hint-label { + font-size: 9px; + color: var(--text-dim, #666); + letter-spacing: 0.5px; +} + +.subghz-rx-hint-text { + font-size: 11px; + color: var(--text-secondary, #999); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.subghz-rx-hint-confidence { + font-size: 10px; + color: #00ff88; + border: 1px solid rgba(0, 255, 136, 0.35); + border-radius: 999px; + padding: 1px 8px; + background: rgba(0, 255, 136, 0.09); +} + +.subghz-rx-burst-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 66px; + padding: 2px 8px; + border: 1px solid var(--border-color, #2a3040); + border-radius: 999px; + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + color: var(--text-dim, #666); + background: rgba(0, 0, 0, 0.2); +} + +.subghz-rx-burst-pill.active { + color: #ffaa00; + border-color: rgba(255, 170, 0, 0.7); + background: rgba(255, 170, 0, 0.15); +} + +.subghz-rx-burst-pill.recent { + color: #00d4ff; + border-color: rgba(0, 212, 255, 0.65); + background: rgba(0, 212, 255, 0.12); +} + +.subghz-rx-level-label { + font-family: 'JetBrains Mono', monospace; + font-size: 9px; + color: var(--text-dim, #666); + letter-spacing: 0.5px; + flex-shrink: 0; +} + +.subghz-rx-level-bar { + flex: 1; + height: 6px; + background: rgba(255, 255, 255, 0.05); + border-radius: 3px; + overflow: hidden; +} + +.subghz-rx-level-fill { + height: 100%; + background: linear-gradient(90deg, #00ff88, #ffaa00, #ff4444); + border-radius: 3px; + transition: width 0.3s ease; +} + +.subghz-rx-scope-wrap { + width: 100%; + max-width: 980px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.subghz-rx-scope-label { + font-family: 'JetBrains Mono', monospace; + font-size: 9px; + color: var(--text-dim, #666); + letter-spacing: 0.5px; +} + +.subghz-rx-scope { + width: 100%; + height: 120px; + background: #0d1117; + border: 1px solid #1a1f2e; + border-radius: 4px; + overflow: hidden; +} + +.subghz-rx-scope.burst-active, +.subghz-rx-waterfall.burst-active { + border-color: rgba(255, 170, 0, 0.9); + box-shadow: 0 0 0 1px rgba(255, 170, 0, 0.35), 0 0 16px rgba(255, 170, 0, 0.25); +} + +.subghz-rx-scope canvas { + width: 100%; + height: 100%; + display: block; +} + +.subghz-rx-waterfall { + width: 100%; + height: 160px; + background: #0d1117; + border: 1px solid #1a1f2e; + border-radius: 4px; + overflow: hidden; +} + +.subghz-rx-waterfall canvas { + width: 100%; + height: 100%; + display: block; +} + +.subghz-rx-waterfall-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.subghz-rx-waterfall-controls { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.subghz-wf-control { + display: flex; + align-items: center; + gap: 6px; + font-family: 'JetBrains Mono', monospace; + font-size: 9px; + color: var(--text-dim, #666); + letter-spacing: 0.4px; +} + +.subghz-wf-control input[type="range"] { + width: 80px; +} + +.subghz-wf-value { + min-width: 26px; + text-align: right; + color: var(--text-secondary, #999); +} + +.subghz-wf-pause-btn { + padding: 2px 8px; + border: 1px solid var(--border-color, #2a3040); + border-radius: 4px; + background: transparent; + color: var(--text-secondary, #999); + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + cursor: pointer; + transition: border-color 0.15s, color 0.15s, background 0.15s; +} + +.subghz-wf-pause-btn:hover { + border-color: var(--accent-cyan, #00d4ff); + color: var(--accent-cyan, #00d4ff); +} + +.subghz-wf-pause-btn.paused { + color: #ffaa00; + border-color: rgba(255, 170, 0, 0.6); +} + +/* ===== TX Display ===== */ +.subghz-tx-display { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + padding: 24px; +} + +.subghz-tx-pulse-ring { + position: relative; + width: 60px; + height: 60px; + display: flex; + align-items: center; + justify-content: center; +} + +.subghz-tx-pulse-ring::before, +.subghz-tx-pulse-ring::after { + content: ''; + position: absolute; + border-radius: 50%; + border: 2px solid var(--accent-red, #ff4444); + animation: subghz-tx-ring 1.5s ease-out infinite; +} + +.subghz-tx-pulse-ring::before { + width: 100%; + height: 100%; + opacity: 0.6; +} + +.subghz-tx-pulse-ring::after { + width: 100%; + height: 100%; + animation-delay: 0.5s; + opacity: 0.3; +} + +@keyframes subghz-tx-ring { + 0% { transform: scale(0.5); opacity: 0.8; } + 100% { transform: scale(1.5); opacity: 0; } +} + +.subghz-tx-pulse-dot { + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--accent-red, #ff4444); + z-index: 1; + animation: subghz-pulse 0.6s ease-in-out infinite; +} + +.subghz-tx-display.idle .subghz-tx-pulse-ring::before, +.subghz-tx-display.idle .subghz-tx-pulse-ring::after, +.subghz-tx-display.idle .subghz-tx-pulse-dot { + animation: none; + opacity: 0.3; +} + +.subghz-tx-display.idle .subghz-tx-label { + color: var(--text-secondary, #999); +} + +.subghz-tx-label { + font-family: 'JetBrains Mono', monospace; + font-size: 14px; + font-weight: 600; + color: var(--accent-red, #ff4444); + letter-spacing: 2px; +} + +.subghz-tx-info-grid { + display: flex; + gap: 20px; +} + +.subghz-tx-info-item { + display: flex; + flex-direction: column; + gap: 2px; + text-align: center; +} + +.subghz-tx-info-label { + font-family: 'JetBrains Mono', monospace; + font-size: 9px; + color: var(--text-dim, #666); + letter-spacing: 0.5px; +} + +.subghz-tx-info-value { + font-family: 'JetBrains Mono', monospace; + font-size: 16px; + font-weight: 600; + color: var(--text-primary, #e0e0e0); +} + +.subghz-tx-info-value.accent-red { color: var(--accent-red, #ff4444); } + +/* ===== Sweep Layout ===== */ +.subghz-sweep-layout { + flex: 1; + display: flex; + gap: 8px; + min-height: 0; +} + +.subghz-sweep-layout .subghz-sweep-chart-wrapper { + flex: 1; + display: block; + min-height: 200px; +} + +.subghz-sweep-peaks-sidebar { + width: 160px; + background: var(--bg-card, #161b22); + border: 1px solid var(--border-color, #2a3040); + border-radius: 6px; + padding: 8px; + overflow-y: auto; + flex-shrink: 0; +} + +.subghz-sweep-peaks-title { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + color: var(--text-dim, #666); + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 6px; + padding-bottom: 4px; + border-bottom: 1px solid var(--border-color, #2a3040); +} + +/* ===== Responsive ===== */ +@media (max-width: 768px) { + .subghz-sweep-chart-wrapper { + min-height: 180px; + } + + .subghz-tx-modal { + padding: 16px; + } + + .subghz-tx-segment-grid { + grid-template-columns: 1fr; + } + + .subghz-action-hub { + padding: 12px; + } + + .subghz-hub-grid { + grid-template-columns: 1fr; + } + + .subghz-hub-card { + padding: 16px 12px; + } + + .subghz-sweep-peaks-sidebar { + display: none; + } + + .subghz-stats-strip { + padding: 4px 8px; + } + + .subghz-burst-indicator { + font-size: 9px; + padding: 2px 6px; + margin-right: 4px; + } + + .subghz-strip-group { + padding: 0 6px; + } + + .subghz-rx-info-grid { + grid-template-columns: 1fr; + } + + .subghz-rx-hint { + grid-template-columns: 1fr; + gap: 4px; + } + + .subghz-saved-actions { + flex-wrap: wrap; + justify-content: flex-end; + } + + .subghz-saved-actions .subghz-btn { + min-width: 78px; + } + + .subghz-tx-info-grid { + flex-direction: column; + gap: 10px; + } + + .subghz-captures-list-main { + grid-template-columns: 1fr; + } + + .subghz-tx-burst-item { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/static/css/responsive.css b/static/css/responsive.css index c7e1f0c..bc83d52 100644 --- a/static/css/responsive.css +++ b/static/css/responsive.css @@ -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; diff --git a/static/css/settings.css b/static/css/settings.css index d626954..159dc29 100644 --- a/static/css/settings.css +++ b/static/css/settings.css @@ -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; diff --git a/static/js/core/app.js b/static/js/core/app.js index fbfdce4..820ee7c 100644 --- a/static/js/core/app.js +++ b/static/js/core/app.js @@ -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'); } }); diff --git a/static/js/modes/dmr.js b/static/js/modes/dmr.js index f0e63c0..bb5c811 100644 --- a/static/js/modes/dmr.js +++ b/static/js/modes/dmr.js @@ -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 = '
No bookmarks saved
'; + 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) => ` -
- ${b.label} - ${b.protocol.toUpperCase()} - -
- `).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 ============== diff --git a/static/js/modes/listening-post.js b/static/js/modes/listening-post.js index 0dfd755..a8eec79 100644 --- a/static/js/modes/listening-post.js +++ b/static/js/modes/listening-post.js @@ -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, diff --git a/static/js/modes/subghz.js b/static/js/modes/subghz.js new file mode 100644 index 0000000..66f7695 --- /dev/null +++ b/static/js/modes/subghz.js @@ -0,0 +1,2746 @@ +/** + * SubGHz Transceiver Mode + * HackRF One SubGHz signal capture, decode, replay, and spectrum analysis + */ + +const SubGhz = (function() { + let eventSource = null; + let statusTimer = null; + let statusPollTimer = null; + let rxStartTime = null; + let sweepCanvas = null; + let sweepCtx = null; + let sweepData = []; + let pendingTxCaptureId = null; + let pendingTxCaptureMeta = null; + let pendingTxBursts = []; + let txTimelineDragState = null; + let rxScopeCanvas = null; + let rxScopeCtx = null; + let rxScopeData = []; + let rxScopeResizeObserver = null; + let rxWaterfallCanvas = null; + let rxWaterfallCtx = null; + let rxWaterfallPalette = null; + let rxWaterfallResizeObserver = null; + let rxWaterfallPaused = false; + let rxWaterfallFloor = 20; + let rxWaterfallRange = 180; + let decodeScopeCanvas = null; + let decodeScopeCtx = null; + let decodeScopeData = []; + let decodeScopeResizeObserver = null; + let decodeWaterfallCanvas = null; + let decodeWaterfallCtx = null; + let decodeWaterfallPalette = null; + let decodeWaterfallResizeObserver = null; + + // Dashboard state + let activePanel = null; // null = hub, 'rx'|'sweep'|'tx'|'saved' + let signalCount = 0; + let captureCount = 0; + let consoleEntries = []; + let consoleCollapsed = false; + let currentPhase = null; // 'tuning'|'listening'|'decoding'|null + let currentMode = 'idle'; // tracks backend mode for timer/strip + let lastRawLine = ''; + let lastRawLineTs = 0; + let lastBurstLineTs = 0; + let burstBadgeTimer = null; + let lastRxHintTs = 0; + let captureSelectMode = false; + let selectedCaptureIds = new Set(); + let latestCaptures = []; + let lastTxCaptureId = null; + let lastTxRequest = null; + let txModalIntent = 'tx'; + + // HackRF detection + let hackrfDetected = false; + let rtl433Detected = false; + let sweepDetected = false; + + // Interactive sweep state + const SWEEP_PAD = { top: 20, right: 20, bottom: 30, left: 50 }; + const SWEEP_POWER_MIN = -100; + const SWEEP_POWER_MAX = 0; + + let sweepHoverFreq = null; + let sweepHoverPower = null; + let sweepSelectedFreq = null; + let sweepPeaks = []; + let sweepPeakHold = []; + let sweepInteractionBound = false; + let sweepResizeObserver = null; + let sweepTooltipEl = null; + let sweepCtxMenuEl = null; + let sweepActionBarEl = null; + let sweepDismissHandler = null; + + /** + * Initialize the SubGHz mode + */ + function init() { + loadCaptures(); + startStream(); + startStatusPolling(); + syncTriggerControls(); + + // Check HackRF availability and restore panel state + fetch('/subghz/status') + .then(r => r.json()) + .then(data => { + updateDeviceStatus(data); + updateStatusUI(data); + + const mode = data.mode || 'idle'; + if (mode === 'decode') { + // Legacy decode mode may still be running via API, but this UI + // intentionally focuses on RAW capture/replay/sweep. + showHub(); + showConsole(); + startStatusTimer(); + addConsoleEntry('Decode mode is disabled in this UI layout.', 'warn'); + } else if (mode === 'rx') { + showPanel('rx'); + updateRxDisplay(getParams()); + initRxScope(); + initRxWaterfall(); + syncWaterfallControls(); + showConsole(); + startStatusTimer(); + } else if (mode === 'sweep') { + showPanel('sweep'); + initSweepCanvas(); + showConsole(); + } else if (mode === 'tx') { + showPanel('tx'); + showConsole(); + startStatusTimer(); + } else { + showHub(); + } + }) + .catch(() => showHub()); + } + + function syncTriggerControls() { + const enabled = !!document.getElementById('subghzTriggerEnabled')?.checked; + const preEl = document.getElementById('subghzTriggerPreMs'); + const postEl = document.getElementById('subghzTriggerPostMs'); + if (preEl) preEl.disabled = !enabled; + if (postEl) postEl.disabled = !enabled; + } + + function startStatusPolling() { + if (statusPollTimer) clearInterval(statusPollTimer); + const refresh = () => { + fetch('/subghz/status') + .then(r => r.json()) + .then(data => { + updateDeviceStatus(data); + updateStatusUI(data); + }) + .catch(() => {}); + }; + refresh(); + statusPollTimer = setInterval(refresh, 3000); + } + + // ------ DEVICE DETECTION ------ + + function updateDeviceStatus(data) { + const hackrfAvailable = !!data.hackrf_available; + const hackrfInfoAvailable = data.hackrf_info_available !== false; + const hackrfDetectionPaused = data.hackrf_detection_paused === true; + const hackrfConnectedRaw = data.hackrf_connected; + const hackrfConnected = hackrfConnectedRaw === true; + const hackrfKnownDisconnected = hackrfConnectedRaw === false; + const hackrfDetectUnknown = hackrfAvailable && !hackrfConnected && !hackrfKnownDisconnected; + hackrfDetected = hackrfConnected; + rtl433Detected = !!data.rtl433_available; + sweepDetected = !!data.sweep_available; + + // Sidebar device indicator + const dot = document.getElementById('subghzDeviceDot'); + const label = document.getElementById('subghzDeviceLabel'); + if (dot) { + dot.className = 'subghz-device-dot'; + if (hackrfDetectUnknown) { + dot.classList.add('unknown'); + } else { + dot.classList.add(hackrfConnected ? 'connected' : 'disconnected'); + } + } + if (label) { + if (hackrfConnected) { + label.textContent = 'HackRF Connected'; + } else if (!hackrfAvailable) { + label.textContent = 'HackRF Tools Missing'; + } else if (hackrfDetectUnknown && hackrfDetectionPaused) { + label.textContent = 'HackRF Status Paused (active stream)'; + } else if (hackrfDetectUnknown && !hackrfInfoAvailable) { + label.textContent = 'HackRF Detection Unavailable'; + } else if (hackrfDetectUnknown) { + label.textContent = 'HackRF Status Unknown'; + } else { + label.textContent = 'HackRF Not Detected'; + } + label.classList.toggle('error', !hackrfConnected && hackrfKnownDisconnected); + } + + // Tool badges + setToolBadge('subghzToolHackrf', hackrfAvailable); + setToolBadge('subghzToolSweep', sweepDetected); + + // Stats strip device badge + const stripDot = document.getElementById('subghzStripDeviceDot'); + if (stripDot) { + stripDot.className = 'subghz-strip-device-dot'; + if (hackrfDetectUnknown) { + stripDot.classList.add('unknown'); + } else { + stripDot.classList.add(hackrfConnected ? 'connected' : 'disconnected'); + } + } + } + + function setToolBadge(id, available) { + const el = document.getElementById(id); + if (!el) return; + el.classList.toggle('available', available); + el.classList.toggle('missing', !available); + } + + /** + * Set frequency from preset button + */ + function setFreq(mhz) { + const el = document.getElementById('subghzFrequency'); + if (el) el.value = mhz; + } + + /** + * Switch between RAW receive / sweep sidebar tabs. + * Only toggles sidebar tab content visibility — does NOT open visuals panels. + */ + function switchTab(tab) { + document.querySelectorAll('.subghz-tab').forEach(t => { + t.classList.toggle('active', t.dataset.tab === tab); + }); + const tabRx = document.getElementById('subghzTabRx'); + const tabSweep = document.getElementById('subghzTabSweep'); + if (tabRx) tabRx.classList.toggle('active', tab === 'rx'); + if (tabSweep) tabSweep.classList.toggle('active', tab === 'sweep'); + } + + /** + * Get common parameters from inputs + */ + function getParams() { + const freqMhz = parseFloat(document.getElementById('subghzFrequency')?.value || '433.92'); + const serial = (document.getElementById('subghzDeviceSerial')?.value || '').trim(); + const params = { + frequency_hz: Math.round(freqMhz * 1000000), + lna_gain: parseInt(document.getElementById('subghzLnaGain')?.value || '24'), + vga_gain: parseInt(document.getElementById('subghzVgaGain')?.value || '20'), + sample_rate: parseInt(document.getElementById('subghzSampleRate')?.value || '2000000'), + }; + const triggerEnabled = !!document.getElementById('subghzTriggerEnabled')?.checked; + params.trigger_enabled = triggerEnabled; + if (triggerEnabled) { + params.trigger_pre_ms = parseInt(document.getElementById('subghzTriggerPreMs')?.value || '350'); + params.trigger_post_ms = parseInt(document.getElementById('subghzTriggerPostMs')?.value || '700'); + } + if (serial) params.device_serial = serial; + return params; + } + + // ------ COORDINATE HELPERS ------ + + function sweepPixelToFreqPower(canvasX, canvasY) { + if (!sweepCanvas || sweepData.length < 2) return { freq: 0, power: 0, inChart: false }; + const w = sweepCanvas.width; + const h = sweepCanvas.height; + const chartW = w - SWEEP_PAD.left - SWEEP_PAD.right; + const chartH = h - SWEEP_PAD.top - SWEEP_PAD.bottom; + const inChart = canvasX >= SWEEP_PAD.left && canvasX <= w - SWEEP_PAD.right && + canvasY >= SWEEP_PAD.top && canvasY <= h - SWEEP_PAD.bottom; + const ratio = Math.max(0, Math.min(1, (canvasX - SWEEP_PAD.left) / chartW)); + const freqMin = sweepData[0].freq; + const freqMax = sweepData[sweepData.length - 1].freq; + const freq = freqMin + ratio * (freqMax - freqMin); + const powerRatio = Math.max(0, Math.min(1, (h - SWEEP_PAD.bottom - canvasY) / chartH)); + const power = SWEEP_POWER_MIN + powerRatio * (SWEEP_POWER_MAX - SWEEP_POWER_MIN); + return { freq, power, inChart }; + } + + function sweepFreqToPixelX(freqMhz) { + if (!sweepCanvas || sweepData.length < 2) return 0; + const chartW = sweepCanvas.width - SWEEP_PAD.left - SWEEP_PAD.right; + const freqMin = sweepData[0].freq; + const freqMax = sweepData[sweepData.length - 1].freq; + const ratio = (freqMhz - freqMin) / (freqMax - freqMin); + return SWEEP_PAD.left + ratio * chartW; + } + + function interpolatePower(freqMhz) { + if (sweepData.length === 0) return SWEEP_POWER_MIN; + if (sweepData.length === 1) return sweepData[0].power; + let lo = 0, hi = sweepData.length - 1; + if (freqMhz <= sweepData[lo].freq) return sweepData[lo].power; + if (freqMhz >= sweepData[hi].freq) return sweepData[hi].power; + while (hi - lo > 1) { + const mid = (lo + hi) >> 1; + if (sweepData[mid].freq <= freqMhz) lo = mid; + else hi = mid; + } + const t = (freqMhz - sweepData[lo].freq) / (sweepData[hi].freq - sweepData[lo].freq); + return sweepData[lo].power + t * (sweepData[hi].power - sweepData[lo].power); + } + + // ------ RX SCOPE ------ + + function initRxScope() { + rxScopeCanvas = document.getElementById('subghzRxScope'); + if (!rxScopeCanvas) return; + rxScopeCtx = rxScopeCanvas.getContext('2d'); + resizeRxScope(); + + if (!rxScopeResizeObserver && rxScopeCanvas.parentElement) { + rxScopeResizeObserver = new ResizeObserver(() => { + resizeRxScope(); + drawRxScope(); + }); + rxScopeResizeObserver.observe(rxScopeCanvas.parentElement); + } + + drawRxScope(); + } + + function initDecodeScope() { + decodeScopeCanvas = document.getElementById('subghzDecodeScope'); + if (!decodeScopeCanvas) return; + decodeScopeCtx = decodeScopeCanvas.getContext('2d'); + resizeDecodeScope(); + + if (!decodeScopeResizeObserver && decodeScopeCanvas.parentElement) { + decodeScopeResizeObserver = new ResizeObserver(() => { + resizeDecodeScope(); + drawDecodeScope(); + }); + decodeScopeResizeObserver.observe(decodeScopeCanvas.parentElement); + } + + drawDecodeScope(); + } + + function initRxWaterfall() { + rxWaterfallCanvas = document.getElementById('subghzRxWaterfall'); + if (!rxWaterfallCanvas) return; + rxWaterfallCtx = rxWaterfallCanvas.getContext('2d'); + rxWaterfallPalette = rxWaterfallPalette || buildWaterfallPalette(); + resizeRxWaterfall(); + clearWaterfall(rxWaterfallCtx, rxWaterfallCanvas); + syncWaterfallControls(); + + if (!rxWaterfallResizeObserver && rxWaterfallCanvas.parentElement) { + rxWaterfallResizeObserver = new ResizeObserver(() => { + resizeRxWaterfall(); + clearWaterfall(rxWaterfallCtx, rxWaterfallCanvas); + }); + rxWaterfallResizeObserver.observe(rxWaterfallCanvas.parentElement); + } + } + + function initDecodeWaterfall() { + decodeWaterfallCanvas = document.getElementById('subghzDecodeWaterfall'); + if (!decodeWaterfallCanvas) return; + decodeWaterfallCtx = decodeWaterfallCanvas.getContext('2d'); + decodeWaterfallPalette = decodeWaterfallPalette || buildWaterfallPalette(); + resizeDecodeWaterfall(); + clearWaterfall(decodeWaterfallCtx, decodeWaterfallCanvas); + + if (!decodeWaterfallResizeObserver && decodeWaterfallCanvas.parentElement) { + decodeWaterfallResizeObserver = new ResizeObserver(() => { + resizeDecodeWaterfall(); + clearWaterfall(decodeWaterfallCtx, decodeWaterfallCanvas); + }); + decodeWaterfallResizeObserver.observe(decodeWaterfallCanvas.parentElement); + } + } + + function resizeRxScope() { + if (!rxScopeCanvas || !rxScopeCanvas.parentElement) return; + const rect = rxScopeCanvas.parentElement.getBoundingClientRect(); + rxScopeCanvas.width = Math.max(10, rect.width); + rxScopeCanvas.height = Math.max(10, rect.height); + } + + function resizeDecodeScope() { + if (!decodeScopeCanvas || !decodeScopeCanvas.parentElement) return; + const rect = decodeScopeCanvas.parentElement.getBoundingClientRect(); + decodeScopeCanvas.width = Math.max(10, rect.width); + decodeScopeCanvas.height = Math.max(10, rect.height); + } + + function resizeRxWaterfall() { + if (!rxWaterfallCanvas || !rxWaterfallCanvas.parentElement) return; + const rect = rxWaterfallCanvas.parentElement.getBoundingClientRect(); + rxWaterfallCanvas.width = Math.max(10, rect.width); + rxWaterfallCanvas.height = Math.max(10, rect.height); + } + + function resizeDecodeWaterfall() { + if (!decodeWaterfallCanvas || !decodeWaterfallCanvas.parentElement) return; + const rect = decodeWaterfallCanvas.parentElement.getBoundingClientRect(); + decodeWaterfallCanvas.width = Math.max(10, rect.width); + decodeWaterfallCanvas.height = Math.max(10, rect.height); + } + + function updateRxLevel(level) { + updateLevel('subghzRxLevel', level); + } + + function updateDecodeLevel(level) { + updateLevel('subghzDecodeLevel', level); + } + + function updateRxWaveform(samples) { + if (!Array.isArray(samples)) return; + if (!rxScopeCanvas) initRxScope(); + rxScopeData = samples; + drawRxScope(); + } + + function updateDecodeWaveform(samples) { + if (!Array.isArray(samples)) return; + if (!decodeScopeCanvas) initDecodeScope(); + decodeScopeData = samples; + drawDecodeScope(); + } + + function updateRxSpectrum(bins) { + if (!Array.isArray(bins) || !bins.length) return; + if (rxWaterfallPaused) return; + if (!rxWaterfallCanvas) initRxWaterfall(); + drawWaterfallRow(rxWaterfallCtx, rxWaterfallCanvas, rxWaterfallPalette, bins, rxWaterfallFloor, rxWaterfallRange); + } + + function updateDecodeSpectrum(bins) { + if (!Array.isArray(bins) || !bins.length) return; + if (!decodeWaterfallCanvas) initDecodeWaterfall(); + drawWaterfallRow(decodeWaterfallCtx, decodeWaterfallCanvas, decodeWaterfallPalette, bins, rxWaterfallFloor, rxWaterfallRange); + } + + function drawRxScope() { + drawScope(rxScopeCtx, rxScopeCanvas, rxScopeData); + } + + function drawDecodeScope() { + drawScope(decodeScopeCtx, decodeScopeCanvas, decodeScopeData); + } + + function buildWaterfallPalette() { + const stops = [ + { v: 0, c: [7, 11, 18] }, + { v: 64, c: [11, 42, 111] }, + { v: 128, c: [0, 212, 255] }, + { v: 192, c: [255, 170, 0] }, + { v: 255, c: [255, 255, 255] }, + ]; + const palette = new Array(256); + for (let i = 0; i < stops.length - 1; i++) { + const a = stops[i]; + const b = stops[i + 1]; + const span = b.v - a.v; + for (let v = a.v; v <= b.v; v++) { + const t = span === 0 ? 0 : (v - a.v) / span; + const r = Math.round(a.c[0] + (b.c[0] - a.c[0]) * t); + const g = Math.round(a.c[1] + (b.c[1] - a.c[1]) * t); + const bch = Math.round(a.c[2] + (b.c[2] - a.c[2]) * t); + palette[v] = [r, g, bch]; + } + } + return palette; + } + + function drawScope(ctx, canvas, data) { + if (!ctx || !canvas) return; + const w = canvas.width; + const h = canvas.height; + ctx.clearRect(0, 0, w, h); + ctx.fillStyle = '#0d1117'; + ctx.fillRect(0, 0, w, h); + + ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)'; + ctx.lineWidth = 1; + for (let i = 1; i < 4; i++) { + const y = (h / 4) * i; + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(w, y); + ctx.stroke(); + } + + ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)'; + ctx.beginPath(); + ctx.moveTo(0, h / 2); + ctx.lineTo(w, h / 2); + ctx.stroke(); + + if (!data || !data.length) return; + + let peak = 0; + for (let i = 0; i < data.length; i++) { + const abs = Math.abs(Number(data[i]) || 0); + if (abs > peak) peak = abs; + } + // Auto-scale low-amplitude noise/signal so activity is visible. + const gain = peak > 0 ? Math.min(12, 0.92 / peak) : 1; + + ctx.strokeStyle = '#00d4ff'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + const n = data.length; + if (n === 1) { + const v = Math.max(-1, Math.min(1, (Number(data[0]) || 0) * gain)); + const y = (0.5 - (v / 2)) * h; + ctx.moveTo(0, y); + ctx.lineTo(w, y); + ctx.stroke(); + return; + } + for (let i = 0; i < n; i++) { + const x = (i / (n - 1)) * w; + const v = Math.max(-1, Math.min(1, (Number(data[i]) || 0) * gain)); + const y = (0.5 - (v / 2)) * h; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + } + + function clearWaterfall(ctx, canvas) { + if (!ctx || !canvas) return; + ctx.fillStyle = '#0d1117'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + } + + function drawWaterfallRow(ctx, canvas, palette, bins, floor, range) { + if (!ctx || !canvas) return; + const w = canvas.width; + const h = canvas.height; + if (h < 2 || w < 2) return; + + // Shift image down by 1px + ctx.drawImage(canvas, 0, 0, w, h - 1, 0, 1, w, h - 1); + + const row = ctx.createImageData(w, 1); + const data = row.data; + const paletteRef = palette || buildWaterfallPalette(); + for (let x = 0; x < w; x++) { + const idx = Math.floor((x / (w - 1)) * (bins.length - 1)); + const raw = Math.max(0, Math.min(255, bins[idx] || 0)); + const rangeVal = Math.max(16, range || 180); + const normalized = Math.max(0, Math.min(1, (raw - (floor || 0)) / rangeVal)); + const val = Math.round(normalized * 255); + const c = paletteRef[val] || [0, 0, 0]; + const offset = x * 4; + data[offset] = c[0]; + data[offset + 1] = c[1]; + data[offset + 2] = c[2]; + data[offset + 3] = 255; + } + ctx.putImageData(row, 0, 0); + } + + function updateLevel(id, level) { + const el = document.getElementById(id); + if (!el) return; + const clamped = Math.max(0, Math.min(100, Number(level) || 0)); + // Boost low-level values so weak-but-real activity is visible. + const boosted = clamped <= 0 ? 0 : Math.min(100, Math.round(Math.sqrt(clamped / 100) * 100)); + el.style.width = boosted + '%'; + } + + function syncWaterfallControls() { + const floorEl = document.getElementById('subghzWfFloor'); + const rangeEl = document.getElementById('subghzWfRange'); + const floorVal = document.getElementById('subghzWfFloorVal'); + const rangeVal = document.getElementById('subghzWfRangeVal'); + const pauseBtn = document.getElementById('subghzWfPauseBtn'); + + if (floorEl) floorEl.value = rxWaterfallFloor; + if (rangeEl) rangeEl.value = rxWaterfallRange; + if (floorVal) floorVal.textContent = String(rxWaterfallFloor); + if (rangeVal) rangeVal.textContent = String(rxWaterfallRange); + if (pauseBtn) { + pauseBtn.textContent = rxWaterfallPaused ? 'RESUME' : 'PAUSE'; + pauseBtn.classList.toggle('paused', rxWaterfallPaused); + } + } + + function setWaterfallFloor(value) { + const next = Math.max(0, Math.min(200, parseInt(value, 10) || 0)); + rxWaterfallFloor = next; + syncWaterfallControls(); + } + + function setWaterfallRange(value) { + const next = Math.max(16, Math.min(255, parseInt(value, 10) || 180)); + rxWaterfallRange = next; + syncWaterfallControls(); + } + + function toggleWaterfall() { + rxWaterfallPaused = !rxWaterfallPaused; + syncWaterfallControls(); + } + + function updateRxStats(stats) { + const sizeEl = document.getElementById('subghzRxFileSize'); + const rateEl = document.getElementById('subghzRxRate'); + if (sizeEl) sizeEl.textContent = formatBytes(stats.file_size || 0); + if (rateEl) rateEl.textContent = (stats.rate_kb ? stats.rate_kb.toFixed(1) : '0') + ' KB/s'; + } + + function resetRxVisuals() { + rxScopeData = []; + updateRxLevel(0); + drawRxScope(); + clearWaterfall(rxWaterfallCtx, rxWaterfallCanvas); + updateRxStats({ file_size: 0, rate_kb: 0 }); + updateRxHint('', 0, ''); + } + + function resetDecodeVisuals() { + decodeScopeData = []; + updateDecodeLevel(0); + drawDecodeScope(); + clearWaterfall(decodeWaterfallCtx, decodeWaterfallCanvas); + } + + // ------ STATUS ------ + + function updateStatusUI(data) { + const dot = document.getElementById('subghzStatusDot'); + const text = document.getElementById('subghzStatusText'); + const timer = document.getElementById('subghzStatusTimer'); + const mode = data.mode || 'idle'; + currentMode = mode; + + if (dot) { + dot.className = 'subghz-status-dot'; + if (mode !== 'idle') dot.classList.add(mode); + } + + const labels = { idle: 'Idle', rx: 'Capturing', decode: 'Decoding', tx: 'Transmitting', sweep: 'Sweeping' }; + if (text) text.textContent = labels[mode] || mode; + + if (timer && data.elapsed_seconds) { + timer.textContent = formatDuration(data.elapsed_seconds); + } else if (timer) { + timer.textContent = ''; + } + + // Toggle sidebar buttons + toggleButtons(mode); + + // Update stats strip + updateStatsStrip(mode); + + // RX recording indicator + const rec = document.getElementById('subghzRxRecording'); + if (rec) rec.style.display = (mode === 'rx') ? 'flex' : 'none'; + + if (mode === 'idle') { + if (burstBadgeTimer) { + clearTimeout(burstBadgeTimer); + burstBadgeTimer = null; + } + setBurstIndicator('idle', 'NO BURST'); + setRxBurstPill('idle', 'IDLE'); + updateRxHint('', 0, ''); + setBurstCanvasHighlight('rx', false); + setBurstCanvasHighlight('decode', false); + } + + if (activePanel === 'tx') { + updateTxPanelState(mode === 'tx'); + } + } + + function toggleButtons(mode) { + const setEnabled = (id, enabled) => { + const el = document.getElementById(id); + if (!el) return; + el.disabled = !enabled; + el.classList.toggle('disabled', !enabled); + }; + + const enableMap = [ + ['subghzRxStartBtn', mode === 'idle'], + ['subghzRxStopBtn', mode === 'rx'], + ['subghzRxStartBtnPanel', mode === 'idle'], + ['subghzRxStopBtnPanel', mode === 'rx'], + ['subghzSweepStartBtn', mode === 'idle'], + ['subghzSweepStopBtn', mode === 'sweep'], + ['subghzSweepStartBtnPanel', mode === 'idle'], + ['subghzSweepStopBtnPanel', mode === 'sweep'], + ]; + + for (const [id, enabled] of enableMap) { + setEnabled(id, enabled); + } + } + + function formatDuration(seconds) { + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return m > 0 ? `${m}m ${s}s` : `${s}s`; + } + + function formatBytes(bytes) { + const sizes = ['B', 'KB', 'MB', 'GB']; + let val = Math.max(0, Number(bytes) || 0); + let idx = 0; + while (val >= 1024 && idx < sizes.length - 1) { + val /= 1024; + idx += 1; + } + const fixed = idx === 0 ? 0 : 1; + return `${val.toFixed(fixed)} ${sizes[idx]}`; + } + + function startStatusTimer() { + rxStartTime = Date.now(); + if (statusTimer) clearInterval(statusTimer); + statusTimer = setInterval(() => { + const elapsed = (Date.now() - rxStartTime) / 1000; + const formatted = formatDuration(elapsed); + + // Update sidebar timer + const timer = document.getElementById('subghzStatusTimer'); + if (timer) timer.textContent = formatted; + + // Update stats strip timer + const stripTimer = document.getElementById('subghzStripTimer'); + if (stripTimer) stripTimer.textContent = formatted; + + // Update TX elapsed if TX panel is active + if (currentMode === 'tx') { + const txElapsed = document.getElementById('subghzTxElapsed'); + if (txElapsed) txElapsed.textContent = formatted; + } + }, 1000); + } + + function stopStatusTimer() { + if (statusTimer) { + clearInterval(statusTimer); + statusTimer = null; + } + rxStartTime = null; + const timer = document.getElementById('subghzStatusTimer'); + if (timer) timer.textContent = ''; + const stripTimer = document.getElementById('subghzStripTimer'); + if (stripTimer) stripTimer.textContent = ''; + } + + // ------ RECEIVE ------ + + function startRx() { + const params = getParams(); + fetch('/subghz/receive/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }) + .then(r => r.json()) + .then(data => { + if (data.status === 'started') { + updateStatusUI({ mode: 'rx' }); + startStatusTimer(); + showPanel('rx'); + updateRxDisplay(params); + initRxScope(); + initRxWaterfall(); + syncWaterfallControls(); + resetRxVisuals(); + showConsole(); + addConsoleEntry('RX capture started at ' + (params.frequency_hz / 1e6).toFixed(3) + ' MHz', 'info'); + if (params.trigger_enabled) { + const pre = Number(params.trigger_pre_ms || 0); + const post = Number(params.trigger_post_ms || 0); + addConsoleEntry( + `Smart trigger armed (pre ${pre}ms / post ${post}ms)`, + 'info' + ); + } + updatePhaseIndicator('tuning'); + setTimeout(() => updatePhaseIndicator('listening'), 500); + } else { + addConsoleEntry(data.message || 'Failed to start capture', 'error'); + alert(data.message || 'Failed to start capture'); + } + }) + .catch(err => alert('Error: ' + err.message)); + } + + function stopRx() { + fetch('/subghz/receive/stop', { method: 'POST' }) + .then(r => r.json()) + .then(data => { + updateStatusUI({ mode: 'idle' }); + stopStatusTimer(); + resetRxVisuals(); + addConsoleEntry('Capture stopped', 'warn'); + updatePhaseIndicator(null); + loadCaptures(); + }) + .catch(err => alert('Error: ' + err.message)); + } + + // ------ DECODE ------ + + function startDecode() { + const params = getParams(); + clearDecodeOutput(); + fetch('/subghz/decode/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }) + .then(r => r.json()) + .then(data => { + if (data.status === 'started') { + updateStatusUI({ mode: 'decode' }); + showPanel('decode'); + showConsole(); + initDecodeScope(); + initDecodeWaterfall(); + resetDecodeVisuals(); + addConsoleEntry('Decode started at ' + (params.frequency_hz / 1e6).toFixed(3) + ' MHz', 'info'); + addConsoleEntry('[decode] Profile: ' + (params.decode_profile || 'weather'), 'info'); + if (data.sample_rate && Number(data.sample_rate) !== Number(params.sample_rate)) { + addConsoleEntry( + '[decode] Sample rate adjusted to ' + (data.sample_rate / 1000).toFixed(0) + ' kHz for stability', + 'info' + ); + } + updatePhaseIndicator('tuning'); + setTimeout(() => updatePhaseIndicator('listening'), 800); + } else { + addConsoleEntry(data.message || 'Failed to start decode', 'error'); + alert(data.message || 'Failed to start decode'); + } + }) + .catch(err => alert('Error: ' + err.message)); + } + + function stopDecode() { + fetch('/subghz/decode/stop', { method: 'POST' }) + .then(r => r.json()) + .then(() => { + updateStatusUI({ mode: 'idle' }); + addConsoleEntry('Decode stopped', 'warn'); + resetDecodeVisuals(); + updatePhaseIndicator(null); + }) + .catch(err => alert('Error: ' + err.message)); + } + + function clearDecodeOutput() { + const el = document.getElementById('subghzDecodeOutput'); + if (el) el.innerHTML = '
Waiting for signals...
'; + lastRawLine = ''; + lastRawLineTs = 0; + lastBurstLineTs = 0; + } + + function appendDecodeEntry(data) { + const el = document.getElementById('subghzDecodeOutput'); + if (!el) return; + + // Remove empty placeholder + const empty = el.querySelector('.subghz-empty'); + if (empty) empty.remove(); + + const entry = document.createElement('div'); + entry.className = 'subghz-decode-entry'; + const model = data.model || 'Unknown'; + const isRaw = model.toLowerCase() === 'raw'; + if (isRaw) { + const rawText = String(data.text || '').trim(); + const now = Date.now(); + if (rawText && rawText === lastRawLine && (now - lastRawLineTs) < 2500) { + return; + } + lastRawLine = rawText; + lastRawLineTs = now; + } + if (isRaw) { + entry.classList.add('is-raw'); + } + + let html = `${escapeHtml(model)}`; + if (isRaw && typeof data.text === 'string') { + html += `: ${escapeHtml(data.text)}`; + } else { + const skipKeys = ['type', 'model', 'time', 'mic']; + for (const [key, value] of Object.entries(data)) { + if (skipKeys.includes(key)) continue; + html += `${escapeHtml(key)}: ${escapeHtml(String(value))} `; + } + } + + entry.innerHTML = html; + el.appendChild(entry); + el.scrollTop = el.scrollHeight; + + while (el.children.length > 200) { + el.removeChild(el.firstChild); + } + + // Dashboard updates + if (!isRaw) { + signalCount++; + updateStatsStrip('decode'); + addConsoleEntry('Signal: ' + model, 'success'); + } + updatePhaseIndicator('decoding'); + } + + function setBurstCanvasHighlight(mode, active) { + const targets = mode === 'decode' + ? ['subghzDecodeScope', 'subghzDecodeWaterfall'] + : ['subghzRxScope', 'subghzRxWaterfall']; + for (const id of targets) { + const canvas = document.getElementById(id); + const host = canvas?.parentElement; + if (host) host.classList.toggle('burst-active', !!active); + } + } + + function setBurstIndicator(state, text) { + const badge = document.getElementById('subghzBurstIndicator'); + const label = document.getElementById('subghzBurstText'); + if (!badge || !label) return; + badge.classList.remove('active', 'recent'); + if (state === 'active') badge.classList.add('active'); + if (state === 'recent') badge.classList.add('recent'); + label.textContent = text || 'NO BURST'; + } + + function setRxBurstPill(state, text) { + const pill = document.getElementById('subghzRxBurstPill'); + if (!pill) return; + pill.classList.remove('active', 'recent'); + if (state === 'active') pill.classList.add('active'); + if (state === 'recent') pill.classList.add('recent'); + pill.textContent = text || 'IDLE'; + } + + function updateRxHint(hint, confidence, protocolHint) { + const textEl = document.getElementById('subghzRxHintText'); + const confEl = document.getElementById('subghzRxHintConfidence'); + if (textEl) { + if (hint) { + textEl.textContent = protocolHint + ? `${hint} - ${protocolHint}` + : hint; + } else { + textEl.textContent = 'No modulation hint yet'; + } + } + if (confEl) { + if (typeof confidence === 'number' && confidence > 0) { + confEl.textContent = `${Math.round(confidence * 100)}%`; + } else { + confEl.textContent = '--'; + } + } + } + + function clearBurstIndicatorLater(delayMs) { + if (burstBadgeTimer) clearTimeout(burstBadgeTimer); + burstBadgeTimer = setTimeout(() => { + setBurstIndicator('idle', 'NO BURST'); + setRxBurstPill('idle', 'IDLE'); + setBurstCanvasHighlight('rx', false); + setBurstCanvasHighlight('decode', false); + }, delayMs); + } + + function handleRxBurst(data) { + if (!data) return; + const mode = data.mode === 'decode' ? 'decode' : 'rx'; + + if (data.event === 'start') { + const startOffset = Math.max(0, Number(data.start_offset_s || 0)); + setBurstIndicator('active', `LIVE ${mode.toUpperCase()} +${startOffset.toFixed(2)}s`); + if (mode === 'rx') setRxBurstPill('active', 'BURST'); + setBurstCanvasHighlight(mode, true); + if (burstBadgeTimer) { + clearTimeout(burstBadgeTimer); + burstBadgeTimer = null; + } + return; + } + + if (data.event !== 'end') return; + const now = Date.now(); + if ((now - lastBurstLineTs) < 250) return; + lastBurstLineTs = now; + + const durationMs = Math.max(0, parseInt(data.duration_ms || 0, 10) || 0); + const peakLevel = Math.max(0, Math.min(100, parseInt(data.peak_level || 0, 10) || 0)); + const startOffset = Math.max(0, Number(data.start_offset_s || 0)); + const modHint = typeof data.modulation_hint === 'string' ? data.modulation_hint.trim() : ''; + const fp = typeof data.fingerprint === 'string' ? data.fingerprint.trim() : ''; + const extras = [ + modHint ? modHint : '', + fp ? `fp ${fp.slice(0, 8)}` : '', + ].filter(Boolean).join(' • '); + const burstMsg = `RF burst ${durationMs} ms @ +${startOffset.toFixed(2)}s (peak ${peakLevel}%)${extras ? ' - ' + extras : ''}`; + + setBurstCanvasHighlight(mode, false); + setBurstIndicator('recent', `${durationMs}ms - ${peakLevel}%`); + if (mode === 'rx') setRxBurstPill('recent', `${durationMs}ms`); + clearBurstIndicatorLater(2200); + + addConsoleEntry(`[${mode}] ${burstMsg}`, 'success'); + + if (mode === 'decode') { + appendDecodeEntry({ + model: 'RF Burst', + duration_ms: durationMs, + peak_level: `${peakLevel}%`, + offset_s: startOffset.toFixed(2), + }); + } + } + + // ------ TRANSMIT ------ + + function estimateCaptureDurationSeconds(capture) { + if (!capture) return 0; + const direct = Number(capture.duration_seconds || 0); + if (direct > 0) return direct; + const sr = Number(capture.sample_rate || 0); + const size = Number(capture.size_bytes || 0); + if (sr > 0 && size > 0) return size / (sr * 2); + return 0; + } + + function syncTxSegmentSelection(changedField) { + const startEl = document.getElementById('subghzTxSegmentStart'); + const endEl = document.getElementById('subghzTxSegmentEnd'); + const enabledEl = document.getElementById('subghzTxSegmentEnabled'); + const summaryEl = document.getElementById('subghzTxSegmentSummary'); + const totalEl = document.getElementById('subghzTxModalDuration'); + + const total = estimateCaptureDurationSeconds(pendingTxCaptureMeta); + const segmentEnabled = !!enabledEl?.checked && total > 0; + + if (startEl) startEl.disabled = !segmentEnabled; + if (endEl) endEl.disabled = !segmentEnabled; + + if (!segmentEnabled) { + if (summaryEl) summaryEl.textContent = `Full capture (${total.toFixed(3)} s)`; + return; + } + + let start = Math.max(0, Number(startEl?.value || 0)); + let end = Math.max(0, Number(endEl?.value || total)); + if (changedField === 'start' && end <= start) end = Math.min(total, start + 0.05); + if (changedField === 'end' && end <= start) start = Math.max(0, end - 0.05); + start = Math.max(0, Math.min(total, start)); + end = Math.max(start + 0.01, Math.min(total, end)); + + if (startEl) startEl.value = start.toFixed(3); + if (endEl) endEl.value = end.toFixed(3); + if (totalEl) totalEl.textContent = `${total.toFixed(3)} s`; + if (summaryEl) summaryEl.textContent = `Segment ${start.toFixed(3)}s - ${end.toFixed(3)}s (${(end - start).toFixed(3)} s)`; + } + + function applyTxBurstSegment(startSeconds, durationSeconds, paddingSeconds) { + const total = estimateCaptureDurationSeconds(pendingTxCaptureMeta); + if (total <= 0) return; + const pad = Math.max(0, Number(paddingSeconds || 0)); + const start = Math.max(0, Number(startSeconds || 0) - pad); + const end = Math.min(total, Number(startSeconds || 0) + Number(durationSeconds || 0) + pad); + + const enabledEl = document.getElementById('subghzTxSegmentEnabled'); + const startEl = document.getElementById('subghzTxSegmentStart'); + const endEl = document.getElementById('subghzTxSegmentEnd'); + + if (enabledEl) enabledEl.checked = true; + if (startEl) startEl.value = start.toFixed(3); + if (endEl) endEl.value = end.toFixed(3); + syncTxSegmentSelection('end'); + } + + function setTxTimelineRangeText(text) { + const rangeEl = document.getElementById('subghzTxBurstRange'); + if (rangeEl) rangeEl.textContent = text; + } + + function bindTxTimelineEditor(timeline, totalSeconds) { + if (!timeline || totalSeconds <= 0) return; + const selection = timeline.querySelector('.subghz-tx-burst-selection'); + if (!selection) return; + + timeline.onmousedown = (event) => { + if (event.button !== 0) return; + if (event.target?.classList?.contains('subghz-tx-burst-marker')) return; + const rect = timeline.getBoundingClientRect(); + const startPx = Math.max(0, Math.min(rect.width, event.clientX - rect.left)); + txTimelineDragState = { rect, startPx, currentPx: startPx }; + timeline.classList.add('dragging'); + selection.style.display = ''; + selection.style.left = `${startPx}px`; + selection.style.width = '1px'; + setTxTimelineRangeText('Drag to define TX segment'); + event.preventDefault(); + }; + + const onMove = (event) => { + if (!txTimelineDragState) return; + const { rect, startPx } = txTimelineDragState; + const currentPx = Math.max(0, Math.min(rect.width, event.clientX - rect.left)); + txTimelineDragState.currentPx = currentPx; + const left = Math.min(startPx, currentPx); + const width = Math.max(1, Math.abs(currentPx - startPx)); + selection.style.left = `${left}px`; + selection.style.width = `${width}px`; + + const startSec = (left / rect.width) * totalSeconds; + const endSec = ((left + width) / rect.width) * totalSeconds; + setTxTimelineRangeText( + `Selected ${startSec.toFixed(3)}s - ${endSec.toFixed(3)}s (${(endSec - startSec).toFixed(3)}s)` + ); + }; + + const onUp = () => { + if (!txTimelineDragState) return; + const { rect, startPx, currentPx } = txTimelineDragState; + txTimelineDragState = null; + timeline.classList.remove('dragging'); + + const left = Math.min(startPx, currentPx); + const right = Math.max(startPx, currentPx); + const startSec = (left / rect.width) * totalSeconds; + const endSec = (right / rect.width) * totalSeconds; + const minSpanSeconds = Math.max(0.01, totalSeconds * 0.0025); + if ((endSec - startSec) >= minSpanSeconds) { + applyTxBurstSegment(startSec, endSec - startSec, 0.0); + setTxTimelineRangeText( + `Segment ${startSec.toFixed(3)}s - ${endSec.toFixed(3)}s (${(endSec - startSec).toFixed(3)}s)` + ); + } else { + selection.style.display = 'none'; + setTxTimelineRangeText('Drag on timeline to select TX segment'); + } + }; + + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + timeline.onmouseleave = () => {}; + timeline.dataset.editorBound = '1'; + timeline._txEditorCleanup = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + } + + function renderTxBurstAssist(capture) { + const section = document.getElementById('subghzTxBurstAssist'); + const timeline = document.getElementById('subghzTxBurstTimeline'); + const list = document.getElementById('subghzTxBurstList'); + if (!section || !timeline || !list) return; + if (typeof timeline._txEditorCleanup === 'function') { + timeline._txEditorCleanup(); + timeline._txEditorCleanup = null; + } + + pendingTxBursts = Array.isArray(capture?.bursts) + ? capture.bursts + .map(b => ({ + start_seconds: Math.max(0, Number(b.start_seconds || 0)), + duration_seconds: Math.max(0, Number(b.duration_seconds || 0)), + peak_level: Math.max(0, Math.min(100, Number(b.peak_level || 0))), + modulation_hint: typeof b.modulation_hint === 'string' ? b.modulation_hint : '', + modulation_confidence: Math.max(0, Math.min(1, Number(b.modulation_confidence || 0))), + fingerprint: typeof b.fingerprint === 'string' ? b.fingerprint : '', + })) + .filter(b => b.duration_seconds > 0) + .sort((a, b) => a.start_seconds - b.start_seconds) + : []; + + timeline.innerHTML = ''; + list.innerHTML = ''; + timeline.classList.remove('dragging'); + const selection = document.createElement('div'); + selection.className = 'subghz-tx-burst-selection'; + timeline.appendChild(selection); + const total = estimateCaptureDurationSeconds(capture); + setTxTimelineRangeText('Drag on timeline to select TX segment'); + + if (!pendingTxBursts.length || total <= 0) { + section.style.display = ''; + const empty = document.createElement('div'); + empty.className = 'subghz-tx-burst-empty'; + empty.textContent = 'No burst markers in this capture yet. Record a fresh RAW capture to auto-mark burst timings.'; + list.appendChild(empty); + bindTxTimelineEditor(timeline, Math.max(0, total)); + return; + } + + section.style.display = ''; + const showBursts = pendingTxBursts.slice(0, 60); + for (let i = 0; i < showBursts.length; i++) { + const burst = showBursts[i]; + const leftPct = Math.max(0, Math.min(100, (burst.start_seconds / total) * 100)); + const widthPct = Math.max(0.35, Math.min(100, (burst.duration_seconds / total) * 100)); + + const marker = document.createElement('button'); + marker.type = 'button'; + marker.className = 'subghz-tx-burst-marker'; + marker.style.left = `${leftPct}%`; + marker.style.width = `${widthPct}%`; + marker.title = `Burst ${i + 1}: +${burst.start_seconds.toFixed(3)}s for ${burst.duration_seconds.toFixed(3)}s`; + marker.addEventListener('click', () => { + applyTxBurstSegment(burst.start_seconds, burst.duration_seconds, 0.06); + }); + timeline.appendChild(marker); + + const row = document.createElement('div'); + row.className = 'subghz-tx-burst-item'; + const text = document.createElement('span'); + const burstParts = [ + `#${i + 1}`, + `+${burst.start_seconds.toFixed(3)}s`, + `${burst.duration_seconds.toFixed(3)}s`, + `peak ${burst.peak_level}%`, + ]; + if (burst.modulation_hint) { + burstParts.push(`${burst.modulation_hint} ${Math.round(burst.modulation_confidence * 100)}%`); + } + if (burst.fingerprint) { + burstParts.push(`fp ${burst.fingerprint.slice(0, 8)}`); + } + text.textContent = burstParts.join(' '); + const useBtn = document.createElement('button'); + useBtn.type = 'button'; + useBtn.textContent = 'Use'; + useBtn.addEventListener('click', () => { + applyTxBurstSegment(burst.start_seconds, burst.duration_seconds, 0.06); + }); + row.appendChild(text); + row.appendChild(useBtn); + list.appendChild(row); + } + bindTxTimelineEditor(timeline, total); + } + + function cleanupTxModalState(closeOverlay = true, clearCapture = true) { + if (clearCapture) { + pendingTxCaptureId = null; + pendingTxCaptureMeta = null; + } + pendingTxBursts = []; + txTimelineDragState = null; + txModalIntent = 'tx'; + const timeline = document.getElementById('subghzTxBurstTimeline'); + if (timeline && typeof timeline._txEditorCleanup === 'function') { + timeline._txEditorCleanup(); + timeline._txEditorCleanup = null; + } + if (closeOverlay) { + const overlay = document.getElementById('subghzTxModalOverlay'); + if (overlay) overlay.classList.remove('active'); + } + } + + function pickStrongestBurstSegment(totalDuration, paddingSeconds = 0.06) { + if (!Array.isArray(pendingTxBursts) || pendingTxBursts.length === 0 || totalDuration <= 0) return null; + const strongest = pendingTxBursts + .slice() + .sort((a, b) => { + const peakDiff = Number(b.peak_level || 0) - Number(a.peak_level || 0); + if (peakDiff !== 0) return peakDiff; + return Number(b.duration_seconds || 0) - Number(a.duration_seconds || 0); + })[0]; + const startRaw = Number(strongest?.start_seconds || 0); + const durRaw = Number(strongest?.duration_seconds || 0); + if (durRaw <= 0) return null; + const start = Math.max(0, startRaw - paddingSeconds); + const end = Math.min(totalDuration, startRaw + durRaw + paddingSeconds); + if (end <= start) return null; + return { + start_seconds: Number(start.toFixed(3)), + duration_seconds: Number((end - start).toFixed(3)), + }; + } + + function populateTxModalFromCapture(capture) { + if (!capture) return; + pendingTxCaptureMeta = capture; + const freqMhz = (Number(capture.frequency_hz || 0) / 1000000).toFixed(3); + const freqEl = document.getElementById('subghzTxModalFreq'); + if (freqEl) freqEl.textContent = freqMhz + ' MHz'; + const total = estimateCaptureDurationSeconds(capture); + const durationEl = document.getElementById('subghzTxModalDuration'); + if (durationEl) durationEl.textContent = `${total.toFixed(3)} s`; + const enabledEl = document.getElementById('subghzTxSegmentEnabled'); + const startEl = document.getElementById('subghzTxSegmentStart'); + const endEl = document.getElementById('subghzTxSegmentEnd'); + if (enabledEl) enabledEl.checked = false; + if (startEl) { + startEl.value = '0.000'; + startEl.min = '0'; + startEl.max = total.toFixed(3); + startEl.step = '0.01'; + } + if (endEl) { + endEl.value = total.toFixed(3); + endEl.min = '0'; + endEl.max = total.toFixed(3); + endEl.step = '0.01'; + } + syncTxSegmentSelection(); + renderTxBurstAssist(capture); + + if (txModalIntent === 'trim') { + if (enabledEl) enabledEl.checked = true; + const auto = pickStrongestBurstSegment(total, 0.06); + if (auto) { + applyTxBurstSegment(auto.start_seconds, auto.duration_seconds, 0); + setTxTimelineRangeText( + `Trim target ${auto.start_seconds.toFixed(3)}s - ${(auto.start_seconds + auto.duration_seconds).toFixed(3)}s` + ); + } else { + syncTxSegmentSelection(); + setTxTimelineRangeText('Select a segment, then click Trim + Save'); + } + } + } + + function getModalTxSegment(options = {}) { + const allowAutoBurst = options.allowAutoBurst === true; + const requireSelection = options.requireSelection === true; + const totalDuration = estimateCaptureDurationSeconds(pendingTxCaptureMeta); + if (totalDuration <= 0) { + return { error: 'Capture duration unavailable' }; + } + + const segmentEnabled = !!document.getElementById('subghzTxSegmentEnabled')?.checked; + if (segmentEnabled) { + const startVal = Number(document.getElementById('subghzTxSegmentStart')?.value || 0); + const endVal = Number(document.getElementById('subghzTxSegmentEnd')?.value || 0); + const startSeconds = Math.max(0, Math.min(totalDuration, startVal)); + const endSeconds = Math.max(0, Math.min(totalDuration, endVal)); + const durationSeconds = endSeconds - startSeconds; + if (durationSeconds <= 0) { + return { error: 'Segment end must be greater than start' }; + } + return { + start_seconds: Number(startSeconds.toFixed(3)), + duration_seconds: Number(durationSeconds.toFixed(3)), + source: 'manual', + }; + } + + if (allowAutoBurst) { + const auto = pickStrongestBurstSegment(totalDuration, 0.06); + if (auto) { + return { ...auto, source: 'auto-burst' }; + } + } + + if (requireSelection) { + return { error: 'Select a segment on the timeline first' }; + } + return null; + } + + function buildTxRequest(captureId, segment) { + const txGain = parseInt(document.getElementById('subghzTxGain')?.value || '20', 10); + const maxDuration = parseInt(document.getElementById('subghzTxMaxDuration')?.value || '10', 10); + const serial = (document.getElementById('subghzDeviceSerial')?.value || '').trim(); + const body = { + capture_id: captureId, + tx_gain: txGain, + max_duration: maxDuration, + }; + if (segment && Number(segment.duration_seconds || 0) > 0) { + body.start_seconds = Number(segment.start_seconds.toFixed(3)); + body.duration_seconds = Number(segment.duration_seconds.toFixed(3)); + } + if (serial) body.device_serial = serial; + return body; + } + + function transmitWithBody(body, logMessage, logLevel) { + const txGain = Number(body.tx_gain || 0); + showPanel('tx'); + updateTxPanelState(true); + showConsole(); + addConsoleEntry(logMessage || 'Preparing transmission...', logLevel || 'warn'); + + fetch('/subghz/transmit', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + .then(r => r.json()) + .then(data => { + if (data.status === 'transmitting') { + lastTxCaptureId = body.capture_id; + const txSegment = data.segment && typeof data.segment === 'object' + ? { + start_seconds: Number(data.segment.start_seconds || 0), + duration_seconds: Number(data.segment.duration_seconds || 0), + } + : (typeof body.start_seconds === 'number' && typeof body.duration_seconds === 'number' + ? { start_seconds: body.start_seconds, duration_seconds: body.duration_seconds } + : null); + lastTxRequest = { capture_id: body.capture_id }; + if (txSegment && txSegment.duration_seconds > 0) { + lastTxRequest.start_seconds = Number(txSegment.start_seconds.toFixed(3)); + lastTxRequest.duration_seconds = Number(txSegment.duration_seconds.toFixed(3)); + } + + updateStatusUI({ mode: 'tx' }); + updateTxPanelState(true); + startStatusTimer(); + addConsoleEntry('Transmitting on ' + ((data.frequency_hz || 0) / 1e6).toFixed(3) + ' MHz', 'warn'); + if (txSegment && txSegment.duration_seconds > 0) { + addConsoleEntry( + `TX segment ${txSegment.start_seconds.toFixed(3)}s + ${txSegment.duration_seconds.toFixed(3)}s`, + 'info' + ); + } + + const freqDisplay = document.getElementById('subghzTxFreqDisplay'); + const gainDisplay = document.getElementById('subghzTxGainDisplay'); + if (freqDisplay && data.frequency_hz) freqDisplay.textContent = (data.frequency_hz / 1e6).toFixed(3) + ' MHz'; + if (gainDisplay) gainDisplay.textContent = txGain + ' dB'; + } else { + updateTxPanelState(false); + addConsoleEntry(data.message || 'TX failed', 'error'); + alert(data.message || 'TX failed'); + } + }) + .catch(err => { + updateTxPanelState(false); + alert('TX error: ' + err.message); + }); + } + + function showTxConfirm(captureId, intent) { + txModalIntent = intent === 'trim' ? 'trim' : 'tx'; + pendingTxCaptureId = captureId; + pendingTxCaptureMeta = null; + pendingTxBursts = []; + const burstAssist = document.getElementById('subghzTxBurstAssist'); + if (burstAssist) burstAssist.style.display = 'none'; + + const overlay = document.getElementById('subghzTxModalOverlay'); + if (overlay) overlay.classList.add('active'); + + fetch(`/subghz/captures/${encodeURIComponent(captureId)}`) + .then(r => r.json()) + .then(data => { + if (data.capture) { + populateTxModalFromCapture(data.capture); + } else { + throw new Error('Capture not found'); + } + }) + .catch(() => { + const durationEl = document.getElementById('subghzTxModalDuration'); + if (durationEl) durationEl.textContent = '--'; + const summaryEl = document.getElementById('subghzTxSegmentSummary'); + if (summaryEl) summaryEl.textContent = 'Segment controls unavailable'; + const burstAssistEl = document.getElementById('subghzTxBurstAssist'); + if (burstAssistEl) burstAssistEl.style.display = 'none'; + }); + } + + function showTrimCapture(captureId) { + showTxConfirm(captureId, 'trim'); + } + + function cancelTx() { + cleanupTxModalState(true, true); + } + + function confirmTx() { + if (!pendingTxCaptureId) return; + const segment = getModalTxSegment({ allowAutoBurst: false, requireSelection: false }); + if (segment && segment.error) { + alert(segment.error); + return; + } + const body = buildTxRequest(pendingTxCaptureId, segment); + cleanupTxModalState(true, true); + transmitWithBody(body, 'Preparing transmission...', 'warn'); + } + + function trimCaptureSelection() { + if (!pendingTxCaptureId) return; + const segment = getModalTxSegment({ allowAutoBurst: true, requireSelection: true }); + if (!segment || segment.error) { + alert(segment?.error || 'Select a segment before trimming'); + return; + } + + const trimBtn = document.getElementById('subghzTxTrimBtn'); + const originalText = trimBtn?.textContent || 'Trim + Save'; + if (trimBtn) { + trimBtn.disabled = true; + trimBtn.textContent = 'Trimming...'; + } + + fetch(`/subghz/captures/${encodeURIComponent(pendingTxCaptureId)}/trim`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + start_seconds: segment.start_seconds, + duration_seconds: segment.duration_seconds, + }), + }) + .then(async r => ({ ok: r.ok, data: await r.json() })) + .then(({ ok, data }) => { + if (!ok || data.status === 'error') { + throw new Error(data.message || 'Trim failed'); + } + if (!data.capture) { + throw new Error('Trim completed but capture metadata missing'); + } + + pendingTxCaptureId = data.capture.id; + txModalIntent = 'tx'; + populateTxModalFromCapture(data.capture); + loadCaptures(); + + addConsoleEntry( + `Trimmed capture saved (${segment.duration_seconds.toFixed(3)}s).`, + 'success' + ); + }) + .catch(err => { + alert('Trim failed: ' + err.message); + }) + .finally(() => { + if (trimBtn) { + trimBtn.disabled = false; + trimBtn.textContent = originalText; + } + }); + } + + function stopTx() { + fetch('/subghz/transmit/stop', { method: 'POST' }) + .then(r => r.json()) + .then(() => { + finalizeTxUi('Transmission stopped'); + }) + .catch(err => alert('Error: ' + err.message)); + } + + function finalizeTxUi(message) { + updateStatusUI({ mode: 'idle' }); + updateTxPanelState(false); + stopStatusTimer(); + if (message) addConsoleEntry(message, 'info'); + updatePhaseIndicator(null); + loadCaptures(); + } + + function updateTxPanelState(transmitting) { + const txDisplay = document.getElementById('subghzTxDisplay'); + const label = document.getElementById('subghzTxStateLabel'); + const stopBtn = document.getElementById('subghzTxStopBtn'); + const chooseBtn = document.getElementById('subghzTxChooseCaptureBtn'); + const replayBtn = document.getElementById('subghzTxReplayLastBtn'); + if (txDisplay) { + txDisplay.classList.toggle('transmitting', !!transmitting); + txDisplay.classList.toggle('idle', !transmitting); + } + if (label) label.textContent = transmitting ? 'TRANSMITTING' : 'READY'; + if (stopBtn) { + stopBtn.style.display = transmitting ? '' : 'none'; + stopBtn.disabled = !transmitting; + } + if (chooseBtn) { + chooseBtn.style.display = transmitting ? 'none' : ''; + chooseBtn.disabled = !!transmitting; + } + if (replayBtn) { + const canReplay = !!(lastTxRequest && lastTxRequest.capture_id); + replayBtn.style.display = (!transmitting && canReplay) ? '' : 'none'; + replayBtn.disabled = transmitting || !canReplay; + } + } + + function replayLastTx() { + if (!lastTxRequest || !lastTxRequest.capture_id) { + addConsoleEntry('No previous transmission capture selected yet.', 'warn'); + return; + } + const body = buildTxRequest(lastTxRequest.capture_id, ( + typeof lastTxRequest.start_seconds === 'number' && + typeof lastTxRequest.duration_seconds === 'number' + ) ? { + start_seconds: lastTxRequest.start_seconds, + duration_seconds: lastTxRequest.duration_seconds, + } : null); + transmitWithBody(body, 'Replaying last selected segment...', 'info'); + } + + // ------ SWEEP ------ + + function startSweep() { + const startMhz = parseFloat(document.getElementById('subghzSweepStart')?.value || '300'); + const endMhz = parseFloat(document.getElementById('subghzSweepEnd')?.value || '928'); + const serial = (document.getElementById('subghzDeviceSerial')?.value || '').trim(); + + sweepData = []; + showPanel('sweep'); + initSweepCanvas(); + + const body = { + freq_start_mhz: startMhz, + freq_end_mhz: endMhz, + }; + if (serial) body.device_serial = serial; + + fetch('/subghz/sweep/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + .then(r => r.json()) + .then(data => { + if (data.status === 'started') { + updateStatusUI({ mode: 'sweep' }); + showConsole(); + addConsoleEntry('Sweep ' + startMhz + ' - ' + endMhz + ' MHz', 'info'); + updatePhaseIndicator('tuning'); + setTimeout(() => updatePhaseIndicator('listening'), 300); + } else { + addConsoleEntry(data.message || 'Failed to start sweep', 'error'); + alert(data.message || 'Failed to start sweep'); + } + }) + .catch(err => alert('Error: ' + err.message)); + } + + function stopSweep() { + fetch('/subghz/sweep/stop', { method: 'POST' }) + .then(r => r.json()) + .then(() => { + updateStatusUI({ mode: 'idle' }); + addConsoleEntry('Sweep stopped', 'warn'); + updatePhaseIndicator(null); + }) + .catch(err => alert('Error: ' + err.message)); + } + + function initSweepCanvas() { + sweepCanvas = document.getElementById('subghzSweepCanvas'); + if (!sweepCanvas) return; + sweepCtx = sweepCanvas.getContext('2d'); + resizeSweepCanvas(); + bindSweepInteraction(); + + if (!sweepResizeObserver && sweepCanvas.parentElement) { + sweepResizeObserver = new ResizeObserver(() => { + resizeSweepCanvas(); + drawSweepChart(); + }); + sweepResizeObserver.observe(sweepCanvas.parentElement); + } + } + + function resizeSweepCanvas() { + if (!sweepCanvas || !sweepCanvas.parentElement) return; + const rect = sweepCanvas.parentElement.getBoundingClientRect(); + sweepCanvas.width = rect.width - 24; + sweepCanvas.height = rect.height - 24; + } + + function updateSweepChart(points) { + for (const pt of points) { + const idx = sweepData.findIndex(d => Math.abs(d.freq - pt.freq) < 0.01); + if (idx >= 0) { + sweepData[idx].power = pt.power; + } else { + sweepData.push(pt); + } + } + sweepData.sort((a, b) => a.freq - b.freq); + + detectPeaks(); + drawSweepChart(); + } + + function detectPeaks() { + if (sweepData.length < 5) { sweepPeaks = []; return; } + const now = Date.now(); + const candidates = []; + + for (let i = 2; i < sweepData.length - 2; i++) { + const p = sweepData[i].power; + if (p > sweepData[i - 1].power && p > sweepData[i + 1].power && + p > sweepData[i - 2].power && p > sweepData[i + 2].power) { + let leftMin = p, rightMin = p; + for (let j = 1; j <= 20 && i - j >= 0; j++) leftMin = Math.min(leftMin, sweepData[i - j].power); + for (let j = 1; j <= 20 && i + j < sweepData.length; j++) rightMin = Math.min(rightMin, sweepData[i + j].power); + const prominence = p - Math.max(leftMin, rightMin); + if (prominence >= 10) { + candidates.push({ freq: sweepData[i].freq, power: p, prominence }); + } + } + } + + candidates.sort((a, b) => b.power - a.power); + sweepPeaks = candidates.slice(0, 10); + + for (const peak of sweepPeaks) { + const existing = sweepPeakHold.find(h => Math.abs(h.freq - peak.freq) < 0.5); + if (existing) { + if (peak.power >= existing.power) { + existing.power = peak.power; + existing.ts = now; + } + } else { + sweepPeakHold.push({ freq: peak.freq, power: peak.power, ts: now }); + } + } + + sweepPeakHold = sweepPeakHold.filter(h => now - h.ts < 5000); + updatePeakList(); + } + + function drawSweepChart() { + if (!sweepCtx || !sweepCanvas || sweepData.length < 2) return; + + const ctx = sweepCtx; + const w = sweepCanvas.width; + const h = sweepCanvas.height; + const pad = SWEEP_PAD; + + ctx.clearRect(0, 0, w, h); + + ctx.fillStyle = '#0d1117'; + ctx.fillRect(0, 0, w, h); + + const freqMin = sweepData[0].freq; + const freqMax = sweepData[sweepData.length - 1].freq; + const powerMin = SWEEP_POWER_MIN; + const powerMax = SWEEP_POWER_MAX; + + const chartW = w - pad.left - pad.right; + const chartH = h - pad.top - pad.bottom; + + const freqToX = f => pad.left + ((f - freqMin) / (freqMax - freqMin)) * chartW; + const powerToY = p => pad.top + chartH - ((p - powerMin) / (powerMax - powerMin)) * chartH; + + // Grid + ctx.strokeStyle = '#1a1f2e'; + ctx.lineWidth = 1; + ctx.font = '10px JetBrains Mono, monospace'; + ctx.fillStyle = '#666'; + + for (let db = powerMin; db <= powerMax; db += 20) { + const y = powerToY(db); + ctx.beginPath(); + ctx.moveTo(pad.left, y); + ctx.lineTo(w - pad.right, y); + ctx.stroke(); + ctx.fillText(db + ' dB', 4, y + 3); + } + + const freqRange = freqMax - freqMin; + const freqStep = freqRange > 500 ? 100 : freqRange > 200 ? 50 : freqRange > 50 ? 10 : 5; + for (let f = Math.ceil(freqMin / freqStep) * freqStep; f <= freqMax; f += freqStep) { + const x = freqToX(f); + ctx.beginPath(); + ctx.moveTo(x, pad.top); + ctx.lineTo(x, h - pad.bottom); + ctx.stroke(); + ctx.fillText(f + '', x - 10, h - 8); + } + + // Spectrum line + ctx.beginPath(); + ctx.strokeStyle = '#00d4ff'; + ctx.lineWidth = 1.5; + + for (let i = 0; i < sweepData.length; i++) { + const x = freqToX(sweepData[i].freq); + const y = powerToY(sweepData[i].power); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + + // Fill under curve + ctx.lineTo(freqToX(freqMax), powerToY(powerMin)); + ctx.lineTo(freqToX(freqMin), powerToY(powerMin)); + ctx.closePath(); + ctx.fillStyle = 'rgba(0, 212, 255, 0.05)'; + ctx.fill(); + + // Peak hold dashes + const now = Date.now(); + ctx.strokeStyle = 'rgba(255, 170, 0, 0.4)'; + ctx.lineWidth = 2; + for (const hold of sweepPeakHold) { + const age = (now - hold.ts) / 5000; + ctx.globalAlpha = 1 - age; + const x = freqToX(hold.freq); + const y = powerToY(hold.power); + ctx.beginPath(); + ctx.moveTo(x - 6, y); + ctx.lineTo(x + 6, y); + ctx.stroke(); + } + ctx.globalAlpha = 1; + + // Peak markers + for (const peak of sweepPeaks) { + const x = freqToX(peak.freq); + const y = powerToY(peak.power); + ctx.fillStyle = '#ffaa00'; + ctx.beginPath(); + ctx.moveTo(x, y - 8); + ctx.lineTo(x - 4, y - 2); + ctx.lineTo(x + 4, y - 2); + ctx.closePath(); + ctx.fill(); + ctx.font = '9px JetBrains Mono, monospace'; + ctx.fillStyle = 'rgba(255, 170, 0, 0.8)'; + ctx.textAlign = 'center'; + ctx.fillText(peak.freq.toFixed(1), x, y - 10); + } + ctx.textAlign = 'start'; + + // Active frequency marker + const activeFreq = parseFloat(document.getElementById('subghzFrequency')?.value); + if (activeFreq && activeFreq >= freqMin && activeFreq <= freqMax) { + const x = freqToX(activeFreq); + ctx.save(); + ctx.setLineDash([6, 4]); + ctx.strokeStyle = 'rgba(0, 255, 136, 0.6)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x, pad.top); + ctx.lineTo(x, h - pad.bottom); + ctx.stroke(); + ctx.restore(); + } + + // Selected frequency marker + if (sweepSelectedFreq !== null && sweepSelectedFreq >= freqMin && sweepSelectedFreq <= freqMax) { + const x = freqToX(sweepSelectedFreq); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x, pad.top); + ctx.lineTo(x, h - pad.bottom); + ctx.stroke(); + } + + // Hover cursor line + if (sweepHoverFreq !== null && sweepHoverFreq >= freqMin && sweepHoverFreq <= freqMax) { + const x = freqToX(sweepHoverFreq); + ctx.save(); + ctx.setLineDash([3, 3]); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.25)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x, pad.top); + ctx.lineTo(x, h - pad.bottom); + ctx.stroke(); + ctx.restore(); + } + } + + // ------ SWEEP INTERACTION ------ + + function bindSweepInteraction() { + if (sweepInteractionBound || !sweepCanvas) return; + sweepInteractionBound = true; + sweepCanvas.style.cursor = 'crosshair'; + + if (!sweepTooltipEl) { + sweepTooltipEl = document.createElement('div'); + sweepTooltipEl.className = 'subghz-sweep-tooltip'; + document.body.appendChild(sweepTooltipEl); + } + + if (!sweepCtxMenuEl) { + sweepCtxMenuEl = document.createElement('div'); + sweepCtxMenuEl.className = 'subghz-sweep-ctx-menu'; + document.body.appendChild(sweepCtxMenuEl); + } + + sweepDismissHandler = (e) => { + if (sweepCtxMenuEl && !sweepCtxMenuEl.contains(e.target)) { + sweepCtxMenuEl.style.display = 'none'; + } + if (sweepActionBarEl && !sweepActionBarEl.contains(e.target) && e.target !== sweepCanvas) { + sweepActionBarEl.classList.remove('visible'); + } + }; + document.addEventListener('click', sweepDismissHandler); + + function mouseToCanvas(e) { + const rect = sweepCanvas.getBoundingClientRect(); + const scaleX = sweepCanvas.width / rect.width; + const scaleY = sweepCanvas.height / rect.height; + return { + x: (e.clientX - rect.left) * scaleX, + y: (e.clientY - rect.top) * scaleY, + }; + } + + sweepCanvas.addEventListener('mousemove', (e) => { + const { x, y } = mouseToCanvas(e); + const info = sweepPixelToFreqPower(x, y); + if (!info.inChart || sweepData.length < 2) { + sweepHoverFreq = null; + sweepHoverPower = null; + if (sweepTooltipEl) sweepTooltipEl.style.display = 'none'; + drawSweepChart(); + return; + } + sweepHoverFreq = info.freq; + sweepHoverPower = interpolatePower(info.freq); + if (sweepTooltipEl) { + sweepTooltipEl.innerHTML = + '' + sweepHoverFreq.toFixed(3) + ' MHz' + + ' · ' + + '' + sweepHoverPower.toFixed(1) + ' dB'; + sweepTooltipEl.style.left = (e.clientX + 14) + 'px'; + sweepTooltipEl.style.top = (e.clientY - 30) + 'px'; + sweepTooltipEl.style.display = 'block'; + } + drawSweepChart(); + }); + + sweepCanvas.addEventListener('mouseleave', () => { + sweepHoverFreq = null; + sweepHoverPower = null; + if (sweepTooltipEl) sweepTooltipEl.style.display = 'none'; + drawSweepChart(); + }); + + sweepCanvas.addEventListener('click', (e) => { + const { x, y } = mouseToCanvas(e); + const info = sweepPixelToFreqPower(x, y); + if (!info.inChart || sweepData.length < 2) return; + sweepSelectedFreq = info.freq; + tuneFromSweep(info.freq); + showSweepActionBar(e.clientX, e.clientY, info.freq); + drawSweepChart(); + }); + + sweepCanvas.addEventListener('contextmenu', (e) => { + e.preventDefault(); + const { x, y } = mouseToCanvas(e); + const info = sweepPixelToFreqPower(x, y); + if (!info.inChart || sweepData.length < 2) return; + const freq = info.freq; + const freqStr = freq.toFixed(3); + + sweepCtxMenuEl.innerHTML = + '
' + freqStr + ' MHz
' + + '
▶Tune Here
' + + '
●Open RAW at ' + freqStr + ' MHz
'; + + sweepCtxMenuEl.style.left = e.clientX + 'px'; + sweepCtxMenuEl.style.top = e.clientY + 'px'; + sweepCtxMenuEl.style.display = 'block'; + + sweepCtxMenuEl.querySelectorAll('.subghz-ctx-item').forEach(item => { + item.onclick = () => { + sweepCtxMenuEl.style.display = 'none'; + const action = item.dataset.action; + if (action === 'tune') tuneFromSweep(freq); + else if (action === 'capture') tuneAndCapture(freq); + }; + }); + }); + } + + // ------ SWEEP ACTIONS ------ + + function tuneFromSweep(freqMhz) { + const el = document.getElementById('subghzFrequency'); + if (el) el.value = freqMhz.toFixed(3); + sweepSelectedFreq = freqMhz; + drawSweepChart(); + } + + function tuneAndCapture(freqMhz) { + tuneFromSweep(freqMhz); + stopSweep(); + hideSweepActionBar(); + setTimeout(() => { + showPanel('rx'); + updateRxDisplay(getParams()); + showConsole(); + addConsoleEntry('Tuned to ' + freqMhz.toFixed(3) + ' MHz. Press Start to capture RAW.', 'info'); + }, 300); + } + + // ------ FLOATING ACTION BAR ------ + + function showSweepActionBar(clientX, clientY, freqMhz) { + if (!sweepActionBarEl) { + sweepActionBarEl = document.createElement('div'); + sweepActionBarEl.className = 'subghz-sweep-action-bar'; + document.body.appendChild(sweepActionBarEl); + } + + sweepActionBarEl.innerHTML = + '' + + ''; + + sweepActionBarEl.querySelector('.tune').onclick = (e) => { + e.stopPropagation(); + tuneFromSweep(freqMhz); + hideSweepActionBar(); + }; + sweepActionBarEl.querySelector('.capture').onclick = (e) => { + e.stopPropagation(); + tuneAndCapture(freqMhz); + }; + + sweepActionBarEl.style.left = (clientX + 10) + 'px'; + sweepActionBarEl.style.top = (clientY + 14) + 'px'; + sweepActionBarEl.classList.remove('visible'); + void sweepActionBarEl.offsetHeight; + sweepActionBarEl.classList.add('visible'); + } + + function hideSweepActionBar() { + if (sweepActionBarEl) sweepActionBarEl.classList.remove('visible'); + } + + // ------ PEAK LIST ------ + + function updatePeakList() { + // Update sidebar, sweep panel, and any other peak lists + const lists = [ + document.getElementById('subghzPeakList'), + document.getElementById('subghzSweepPeakList'), + ]; + for (const list of lists) { + if (!list) continue; + list.innerHTML = ''; + for (const peak of sweepPeaks) { + const item = document.createElement('div'); + item.className = 'subghz-peak-item'; + item.innerHTML = + '' + peak.freq.toFixed(3) + ' MHz' + + '' + peak.power.toFixed(1) + ' dB'; + item.onclick = () => tuneFromSweep(peak.freq); + list.appendChild(item); + } + } + } + + // ------ CAPTURES LIBRARY ------ + + function loadCaptures() { + fetch('/subghz/captures') + .then(r => r.json()) + .then(data => { + const captures = data.captures || []; + latestCaptures = captures; + const validIds = new Set(captures.map(c => c.id)); + selectedCaptureIds = new Set([...selectedCaptureIds].filter(id => validIds.has(id))); + captureCount = captures.length; + updateStatsStrip(); + updateSavedSelectionUi(); + renderCaptures(captures); + }) + .catch(() => {}); + } + + function burstCountForCapture(cap) { + return Array.isArray(cap?.bursts) ? cap.bursts.length : 0; + } + + function updateSavedSelectionUi() { + const selectBtn = document.getElementById('subghzSavedSelectBtn'); + const selectAllBtn = document.getElementById('subghzSavedSelectAllBtn'); + const deleteBtn = document.getElementById('subghzSavedDeleteSelectedBtn'); + const countEl = document.getElementById('subghzSavedSelectionCount'); + if (selectBtn) selectBtn.textContent = captureSelectMode ? 'Done' : 'Select'; + if (selectAllBtn) selectAllBtn.style.display = captureSelectMode ? '' : 'none'; + if (deleteBtn) { + deleteBtn.style.display = captureSelectMode ? '' : 'none'; + deleteBtn.disabled = selectedCaptureIds.size === 0; + } + if (countEl) { + countEl.style.display = captureSelectMode ? '' : 'none'; + countEl.textContent = `${selectedCaptureIds.size} selected`; + } + } + + function renderCaptures(captures) { + // Render to both the visuals panel and the sidebar + const targets = [ + { + list: document.getElementById('subghzCapturesList'), + empty: document.getElementById('subghzCapturesEmpty'), + selectable: true, + }, + { + list: document.getElementById('subghzSidebarCaptures'), + empty: document.getElementById('subghzSidebarCapturesEmpty'), + selectable: false, + }, + ]; + + for (const { list, empty, selectable } of targets) { + if (!list) continue; + + // Clear existing cards + list.querySelectorAll('.subghz-capture-card').forEach(c => c.remove()); + + if (captures.length === 0) { + if (empty) empty.style.display = ''; + continue; + } + + if (empty) empty.style.display = 'none'; + + for (const cap of captures) { + const freqMhz = (cap.frequency_hz / 1000000).toFixed(3); + const sizeKb = (cap.size_bytes / 1024).toFixed(1); + const ts = cap.timestamp ? new Date(cap.timestamp).toLocaleString() : ''; + const dur = cap.duration_seconds ? cap.duration_seconds.toFixed(1) + 's' : ''; + const burstCount = burstCountForCapture(cap); + const selected = selectedCaptureIds.has(cap.id); + const modulationHint = typeof cap.modulation_hint === 'string' ? cap.modulation_hint : ''; + const modulationConfidence = Number(cap.modulation_confidence || 0); + const protocolHint = typeof cap.protocol_hint === 'string' ? cap.protocol_hint : ''; + const dominantFingerprint = typeof cap.dominant_fingerprint === 'string' ? cap.dominant_fingerprint : ''; + const fingerprintGroup = typeof cap.fingerprint_group === 'string' ? cap.fingerprint_group : ''; + const fingerprintGroupSize = Number(cap.fingerprint_group_size || 0); + const labelSource = typeof cap.label_source === 'string' ? cap.label_source : ''; + + const card = document.createElement('div'); + card.className = 'subghz-capture-card'; + if (burstCount > 0) card.classList.add('has-bursts'); + if (selectable && captureSelectMode) card.classList.add('select-mode'); + if (selectable && selected) card.classList.add('selected'); + + let actionsHtml = ''; + if (selectable && captureSelectMode) { + actionsHtml = ` +
+ +
+ `; + } else { + actionsHtml = ` +
+ + + + + +
+ `; + } + + card.innerHTML = ` +
+ ${escapeHtml(freqMhz)} MHz +
+ ${burstCount > 0 ? `${burstCount} burst${burstCount === 1 ? '' : 's'}` : ''} + ${escapeHtml(ts)} +
+
+ ${burstCount > 0 ? ` +
+ BURSTS DETECTED + ${burstCount} +
+ ` : ''} + ${(cap.label || modulationHint || dominantFingerprint) ? ` +
+ ${cap.label && labelSource === 'auto' ? `AUTO LABEL` : ''} + ${modulationHint ? `${escapeHtml(modulationHint)} ${modulationConfidence > 0 ? Math.round(modulationConfidence * 100) + '%' : ''}` : ''} + ${fingerprintGroup ? `${escapeHtml(fingerprintGroup)}${fingerprintGroupSize > 1 ? ' x' + fingerprintGroupSize : ''}` : ''} +
+ ` : ''} + ${cap.label ? `
${escapeHtml(cap.label)}
` : ''} + ${protocolHint ? `
${escapeHtml(protocolHint)}
` : ''} + ${dominantFingerprint ? `
Fingerprint: ${escapeHtml(dominantFingerprint)}
` : ''} +
+ ${escapeHtml(dur)} + ${escapeHtml(sizeKb)} KB + ${escapeHtml(String(cap.sample_rate / 1000))} kHz +
+ ${actionsHtml} + `; + if (selectable && captureSelectMode) { + card.addEventListener('click', (event) => toggleCaptureSelection(cap.id, event)); + } + list.appendChild(card); + } + } + } + + function toggleCaptureSelectMode(forceValue) { + captureSelectMode = typeof forceValue === 'boolean' ? forceValue : !captureSelectMode; + if (!captureSelectMode) selectedCaptureIds.clear(); + updateSavedSelectionUi(); + renderCaptures(latestCaptures); + } + + function selectAllCaptures() { + if (!captureSelectMode) return; + const allIds = latestCaptures.map(c => c.id); + if (selectedCaptureIds.size >= allIds.length) { + selectedCaptureIds.clear(); + } else { + selectedCaptureIds = new Set(allIds); + } + updateSavedSelectionUi(); + renderCaptures(latestCaptures); + } + + function toggleCaptureSelection(id, event) { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + if (!captureSelectMode) return; + if (selectedCaptureIds.has(id)) selectedCaptureIds.delete(id); + else selectedCaptureIds.add(id); + updateSavedSelectionUi(); + renderCaptures(latestCaptures); + } + + function deleteSelectedCaptures() { + if (!captureSelectMode || selectedCaptureIds.size === 0) return; + const ids = [...selectedCaptureIds]; + if (!confirm(`Delete ${ids.length} selected capture${ids.length === 1 ? '' : 's'}?`)) return; + + Promise.all( + ids.map(id => fetch(`/subghz/captures/${encodeURIComponent(id)}`, { method: 'DELETE' })) + ) + .then(() => { + selectedCaptureIds.clear(); + captureSelectMode = false; + updateSavedSelectionUi(); + loadCaptures(); + }) + .catch(err => alert('Error deleting captures: ' + err.message)); + } + + function deleteCapture(id) { + if (!confirm('Delete this capture?')) return; + fetch(`/subghz/captures/${encodeURIComponent(id)}`, { method: 'DELETE' }) + .then(r => r.json()) + .then(() => loadCaptures()) + .catch(err => alert('Error: ' + err.message)); + } + + function renameCapture(id) { + const label = prompt('Enter label for this capture:'); + if (label === null) return; + fetch(`/subghz/captures/${encodeURIComponent(id)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ label: label }), + }) + .then(r => r.json()) + .then(() => loadCaptures()) + .catch(err => alert('Error: ' + err.message)); + } + + function downloadCapture(id) { + window.open(`/subghz/captures/${encodeURIComponent(id)}/download`, '_blank'); + } + + // ------ SSE STREAM ------ + + function startStream() { + if (eventSource) { + eventSource.close(); + } + + eventSource = new EventSource('/subghz/stream'); + + eventSource.onmessage = function(e) { + try { + const data = JSON.parse(e.data); + handleEvent(data); + } catch (err) { + // Ignore parse errors (keepalives etc.) + } + }; + + eventSource.onerror = function() { + setTimeout(() => { + if (document.getElementById('subghzMode')?.classList.contains('active')) { + startStream(); + } + }, 3000); + }; + } + + function handleEvent(data) { + const type = data.type; + + if (type === 'status') { + updateStatusUI(data); + if (data.status === 'started') { + if (data.mode === 'rx') startStatusTimer(); + if (data.mode === 'decode') { + showConsole(); + } + if (data.mode === 'sweep') { + if (activePanel !== 'sweep') showPanel('sweep'); + } + } else if (data.status === 'stopped' || data.status === 'decode_stopped' || data.status === 'sweep_stopped') { + stopStatusTimer(); + resetRxVisuals(); + resetDecodeVisuals(); + addConsoleEntry('Operation stopped', 'warn'); + updatePhaseIndicator(null); + if (data.mode === 'idle') loadCaptures(); + } + } else if (type === 'decode') { + appendDecodeEntry(data); + } else if (type === 'decode_raw') { + appendDecodeEntry({ model: 'Raw', text: data.text }); + } else if (type === 'rx_level') { + updateRxLevel(data.level); + } else if (type === 'rx_waveform') { + updateRxWaveform(data.samples); + } else if (type === 'rx_spectrum') { + updateRxSpectrum(data.bins); + } else if (type === 'rx_stats') { + updateRxStats(data); + } else if (type === 'rx_hint') { + const confidence = Number(data.confidence || 0); + updateRxHint(data.modulation_hint || '', confidence, data.protocol_hint || ''); + const now = Date.now(); + if ((now - lastRxHintTs) > 4000 && data.modulation_hint) { + lastRxHintTs = now; + addConsoleEntry( + `[rx] Hint: ${data.modulation_hint} (${Math.round(confidence * 100)}%)` + + (data.protocol_hint ? ` - ${data.protocol_hint}` : ''), + 'info' + ); + } + } else if (type === 'decode_level') { + updateDecodeLevel(data.level); + } else if (type === 'decode_waveform') { + updateDecodeWaveform(data.samples); + } else if (type === 'decode_spectrum') { + updateDecodeSpectrum(data.bins); + } else if (type === 'rx_burst') { + handleRxBurst(data); + } else if (type === 'sweep') { + if (data.points) { + updateSweepChart(data.points); + updatePhaseIndicator('decoding'); + } + } else if (type === 'tx_status') { + if (data.status === 'transmitting') { + updateStatusUI({ mode: 'tx' }); + if (activePanel !== 'tx') showPanel('tx'); + updateTxPanelState(true); + startStatusTimer(); + addConsoleEntry('Transmission started', 'warn'); + } else if (data.status === 'tx_complete' || data.status === 'tx_stopped') { + if (currentMode === 'tx' || activePanel === 'tx') { + finalizeTxUi('Transmission ended'); + } else { + updateStatusUI({ mode: 'idle' }); + stopStatusTimer(); + loadCaptures(); + } + } + } else if (type === 'info') { + // rtl_433 stderr info lines + if (data.text) addConsoleEntry(data.text, 'info'); + } else if (type === 'error') { + addConsoleEntry(data.message || 'Error', 'error'); + updatePhaseIndicator('error'); + alert(data.message || 'SubGHz error'); + } + } + + // ------ UTILITIES ------ + + function escapeHtml(str) { + if (!str) return ''; + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + } + + /** + * Clean up when switching away from SubGHz mode + */ + function destroy() { + if (eventSource) { + eventSource.close(); + eventSource = null; + } + if (statusPollTimer) { + clearInterval(statusPollTimer); + statusPollTimer = null; + } + if (burstBadgeTimer) { + clearTimeout(burstBadgeTimer); + burstBadgeTimer = null; + } + const txTimeline = document.getElementById('subghzTxBurstTimeline'); + if (txTimeline && typeof txTimeline._txEditorCleanup === 'function') { + txTimeline._txEditorCleanup(); + txTimeline._txEditorCleanup = null; + } + txTimelineDragState = null; + stopStatusTimer(); + setBurstIndicator('idle', 'NO BURST'); + setRxBurstPill('idle', 'IDLE'); + setBurstCanvasHighlight('rx', false); + setBurstCanvasHighlight('decode', false); + + // Clean up interactive sweep elements + if (sweepTooltipEl) { sweepTooltipEl.remove(); sweepTooltipEl = null; } + if (sweepCtxMenuEl) { sweepCtxMenuEl.remove(); sweepCtxMenuEl = null; } + if (sweepActionBarEl) { sweepActionBarEl.remove(); sweepActionBarEl = null; } + if (sweepDismissHandler) { + document.removeEventListener('click', sweepDismissHandler); + sweepDismissHandler = null; + } + if (sweepResizeObserver) { + sweepResizeObserver.disconnect(); + sweepResizeObserver = null; + } + sweepInteractionBound = false; + sweepHoverFreq = null; + sweepSelectedFreq = null; + sweepPeaks = []; + sweepPeakHold = []; + + if (rxScopeResizeObserver) { + rxScopeResizeObserver.disconnect(); + rxScopeResizeObserver = null; + } + if (rxWaterfallResizeObserver) { + rxWaterfallResizeObserver.disconnect(); + rxWaterfallResizeObserver = null; + } + rxScopeCanvas = null; + rxScopeCtx = null; + rxScopeData = []; + rxWaterfallCanvas = null; + rxWaterfallCtx = null; + rxWaterfallPalette = null; + rxWaterfallPaused = false; + if (decodeScopeResizeObserver) { + decodeScopeResizeObserver.disconnect(); + decodeScopeResizeObserver = null; + } + if (decodeWaterfallResizeObserver) { + decodeWaterfallResizeObserver.disconnect(); + decodeWaterfallResizeObserver = null; + } + decodeScopeCanvas = null; + decodeScopeCtx = null; + decodeScopeData = []; + decodeWaterfallCanvas = null; + decodeWaterfallCtx = null; + decodeWaterfallPalette = null; + + // Reset dashboard state + activePanel = null; + signalCount = 0; + captureCount = 0; + consoleEntries = []; + consoleCollapsed = false; + currentPhase = null; + currentMode = 'idle'; + lastRawLine = ''; + lastRawLineTs = 0; + lastBurstLineTs = 0; + lastRxHintTs = 0; + pendingTxBursts = []; + captureSelectMode = false; + selectedCaptureIds = new Set(); + latestCaptures = []; + lastTxCaptureId = null; + lastTxRequest = null; + txModalIntent = 'tx'; + } + + // ------ DASHBOARD: HUB & PANELS ------ + + function showHub() { + activePanel = null; + const hub = document.getElementById('subghzActionHub'); + if (hub) hub.style.display = ''; + const panels = ['Rx', 'Sweep', 'Tx', 'Saved']; + panels.forEach(p => { + const el = document.getElementById('subghzPanel' + p); + if (el) el.style.display = 'none'; + }); + updateStatsStrip('idle'); + updateStatusUI({ mode: currentMode }); + } + + function showPanel(panel) { + activePanel = panel; + const hub = document.getElementById('subghzActionHub'); + if (hub) hub.style.display = 'none'; + const panelMap = { rx: 'Rx', sweep: 'Sweep', tx: 'Tx', saved: 'Saved' }; + Object.values(panelMap).forEach(p => { + const el = document.getElementById('subghzPanel' + p); + if (el) el.style.display = 'none'; + }); + const target = document.getElementById('subghzPanel' + (panelMap[panel] || '')); + if (target) target.style.display = ''; + if (panel === 'rx') { + initRxScope(); + initRxWaterfall(); + syncWaterfallControls(); + } else if (panel === 'saved') { + updateSavedSelectionUi(); + loadCaptures(); + } else if (panel === 'tx') { + updateTxPanelState(currentMode === 'tx'); + } + updateStatsStrip(); + updateStatusUI({ mode: currentMode }); + } + + function hubAction(action) { + if (action === 'rx') { + showPanel('rx'); + updateRxDisplay(getParams()); + showConsole(); + addConsoleEntry('RAW panel ready. Press Start when you want to capture.', 'info'); + } else if (action === 'txselect') { + showPanel('saved'); + loadCaptures(); + } else if (action === 'sweep') { + startSweep(); + } else if (action === 'saved') { + showPanel('saved'); + loadCaptures(); + } + } + + function backToHub() { + // Stop any running operation + if (currentMode !== 'idle') { + if (currentMode === 'rx') stopRx(); + else if (currentMode === 'sweep') stopSweep(); + else if (currentMode === 'tx') stopTx(); + } + showHub(); + const consoleEl = document.getElementById('subghzConsole'); + if (consoleEl) consoleEl.style.display = 'none'; + updatePhaseIndicator(null); + } + + function stopActive() { + if (currentMode === 'rx') stopRx(); + else if (currentMode === 'sweep') stopSweep(); + else if (currentMode === 'tx') stopTx(); + } + + // ------ DASHBOARD: STATS STRIP ------ + + function updateStatsStrip(mode) { + const stripDot = document.getElementById('subghzStripDot'); + const stripStatus = document.getElementById('subghzStripStatus'); + const stripFreq = document.getElementById('subghzStripFreq'); + const stripMode = document.getElementById('subghzStripMode'); + const stripSignals = document.getElementById('subghzStripSignals'); + const stripCaptures = document.getElementById('subghzStripCaptures'); + + if (!mode) mode = currentMode || 'idle'; + + if (stripDot) { + stripDot.className = 'subghz-strip-dot'; + if (mode !== 'idle' && mode !== 'saved') { + stripDot.classList.add(mode, 'active'); + } + } + + const labels = { idle: 'Idle', rx: 'Capturing', decode: 'Decoding', tx: 'Transmitting', sweep: 'Sweeping', saved: 'Library' }; + if (stripStatus) stripStatus.textContent = labels[mode] || mode; + + const freqEl = document.getElementById('subghzFrequency'); + if (stripFreq && freqEl) { + stripFreq.textContent = freqEl.value || '--'; + } + + const modeLabels = { idle: '--', decode: 'READ', rx: 'RAW', sweep: 'SWEEP', tx: 'TX', saved: 'SAVED' }; + if (stripMode) stripMode.textContent = modeLabels[mode] || '--'; + + if (stripSignals) stripSignals.textContent = signalCount; + if (stripCaptures) stripCaptures.textContent = captureCount; + } + + // ------ DASHBOARD: RX DISPLAY ------ + + function updateRxDisplay(params) { + const freqEl = document.getElementById('subghzRxFreq'); + const lnaEl = document.getElementById('subghzRxLna'); + const vgaEl = document.getElementById('subghzRxVga'); + const srEl = document.getElementById('subghzRxSampleRate'); + + if (freqEl) freqEl.textContent = (params.frequency_hz / 1e6).toFixed(3) + ' MHz'; + if (lnaEl) lnaEl.textContent = params.lna_gain + ' dB'; + if (vgaEl) vgaEl.textContent = params.vga_gain + ' dB'; + if (srEl) srEl.textContent = (params.sample_rate / 1000) + ' kHz'; + } + + // ------ DASHBOARD: CONSOLE ------ + + function addConsoleEntry(msg, level) { + level = level || ''; + const now = new Date(); + const ts = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + consoleEntries.push({ ts, msg, level }); + if (consoleEntries.length > 100) consoleEntries.shift(); + + const log = document.getElementById('subghzConsoleLog'); + if (!log) return; + + const entry = document.createElement('div'); + entry.className = 'subghz-log-entry'; + entry.innerHTML = '' + escapeHtml(ts) + '' + + '' + escapeHtml(msg) + ''; + log.appendChild(entry); + log.scrollTop = log.scrollHeight; + + while (log.children.length > 100) { + log.removeChild(log.firstChild); + } + } + + function showConsole() { + const consoleEl = document.getElementById('subghzConsole'); + if (consoleEl) consoleEl.style.display = ''; + } + + function toggleConsole() { + consoleCollapsed = !consoleCollapsed; + const body = document.getElementById('subghzConsoleBody'); + const btn = document.getElementById('subghzConsoleToggleBtn'); + if (body) body.classList.toggle('collapsed', consoleCollapsed); + if (btn) btn.classList.toggle('collapsed', consoleCollapsed); + } + + function clearConsole() { + consoleEntries = []; + const log = document.getElementById('subghzConsoleLog'); + if (log) log.innerHTML = ''; + } + + function updatePhaseIndicator(phase) { + currentPhase = phase; + const steps = ['tuning', 'listening', 'decoding']; + const phaseEls = { + tuning: document.getElementById('subghzPhaseTuning'), + listening: document.getElementById('subghzPhaseListening'), + decoding: document.getElementById('subghzPhaseDecoding'), + }; + + if (!phase) { + Object.values(phaseEls).forEach(el => { + if (el) el.className = 'subghz-phase-step'; + }); + return; + } + + if (phase === 'error') { + Object.values(phaseEls).forEach(el => { + if (el) { + el.className = 'subghz-phase-step'; + el.classList.add('error'); + } + }); + return; + } + + const activeIdx = steps.indexOf(phase); + steps.forEach((step, idx) => { + const el = phaseEls[step]; + if (!el) return; + el.className = 'subghz-phase-step'; + if (idx < activeIdx) el.classList.add('completed'); + else if (idx === activeIdx) el.classList.add('active'); + }); + } + + // ------ PUBLIC API ------ + return { + init, + destroy, + setFreq, + syncTriggerControls, + switchTab, + startRx, + stopRx, + startDecode, + stopDecode, + startSweep, + stopSweep, + showTxConfirm, + showTrimCapture, + cancelTx, + syncTxSegmentSelection, + confirmTx, + trimCaptureSelection, + stopTx, + replayLastTx, + loadCaptures, + toggleCaptureSelectMode, + selectAllCaptures, + deleteSelectedCaptures, + toggleCaptureSelection, + deleteCapture, + renameCapture, + downloadCapture, + tuneFromSweep, + tuneAndCapture, + // Dashboard + showHub, + showPanel, + hubAction, + backToHub, + stopActive, + toggleConsole, + clearConsole, + // Waterfall controls + toggleWaterfall, + setWaterfallFloor, + setWaterfallRange, + }; +})(); diff --git a/static/js/modes/weather-satellite.js b/static/js/modes/weather-satellite.js index 51ecaa9..6aec996 100644 --- a/static/js/modes/weather-satellite.js +++ b/static/js/modes/weather-satellite.js @@ -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); diff --git a/templates/index.html b/templates/index.html index ae5062d..7b81929 100644 --- a/templates/index.html +++ b/templates/index.html @@ -63,6 +63,7 @@ + @@ -190,9 +191,9 @@ Meshtastic -

Signal Source

@@ -547,6 +552,8 @@ {% include 'partials/modes/websdr.html' %} + {% include 'partials/modes/subghz.html' %} +
+

Pager Decoder

@@ -1759,6 +1769,301 @@
+ +
+ + +
+
+ + + HackRF + +
+ + Idle +
+
+
+
+
+ -- + MHZ +
+
+ -- + MODE +
+
+
+
+
+ 0 + SIGNALS +
+
+ 0 + CAPTURES +
+
+
+
+ +
+
+ + +
+
+
+ TUNING + ▸ + LISTENING + ▸ + DECODING +
+
+ + NO BURST +
+ +
+
+
+
+
+ + +
+
+
HackRF One
+
SubGHz Transceiver — 1 MHz - 6 GHz
+
+
+
+
+ +
+
Read RAW
+
Capture raw IQ via hackrf_transfer
+
+
+
+ +
+
Transmit
+
Replay a saved capture
+
+
+
+ +
+
Freq Analyzer
+
Wideband sweep via hackrf_sweep
+
+
+
+ +
+
Saved
+
Signal library & replay
+
+
+
+ + + + +
+
+ + Read RAW — Signal Capture +
+ + +
+
+
+
+ + RECORDING +
+
+
+ FREQUENCY + -- +
+
+ LNA GAIN + -- +
+
+ VGA GAIN + -- +
+
+ SAMPLE RATE + -- +
+
+ FILE SIZE + 0 KB +
+
+ DATA RATE + 0 KB/s +
+
+
+ SIGNAL + IDLE +
+
+
+
+
+ ANALYSIS + No modulation hint yet + -- +
+
+ WAVEFORM +
+ +
+
+
+
+ WATERFALL +
+
+ FLOOR + + 20 +
+
+ RANGE + + 180 +
+ +
+
+
+ +
+
+
+
+ + +
+
+ + Frequency Analyzer +
+ + +
+
+
+
+ +
+
+
PEAKS
+
+
+
+
+ + +
+
+ + Transmit +
+
+
+
+
+
READY
+
+
+ FREQUENCY + -- +
+
+ TX GAIN + -- +
+
+ ELAPSED + 0s +
+
+
+ + + +
+
+
+ + +
+
+ + Saved Captures +
+ 0 selected + + + +
+
+
+
No captures yet
+
+
+ + +
+
+

Confirm Transmission

+

You are about to transmit a radio signal on:

+

--- MHz

+

Capture duration: --

+
+ +
+ + + + +
+

Full capture

+
+
+
Detected Bursts
+
+
Drag on timeline to select TX segment
+
+
+

Ensure you have proper authorization to transmit on this frequency.

+
+ + + +
+
+
+ +
+
@@ -2538,6 +2843,7 @@ + diff --git a/templates/partials/modes/tscm.html b/templates/partials/modes/tscm.html index fe9f617..3f6737f 100644 --- a/templates/partials/modes/tscm.html +++ b/templates/partials/modes/tscm.html @@ -2,7 +2,7 @@
-

TSCM Sweep Alpha

+

TSCM Sweep Alpha

@@ -65,14 +65,6 @@
- - - -
@@ -115,8 +107,8 @@
-
-

Advanced

+
+

Advanced

@@ -156,8 +148,8 @@
-
-

Tools

+
+

Tools

+ +
diff --git a/templates/partials/modes/weather-satellite.html b/templates/partials/modes/weather-satellite.html index 18aeee1..46e9477 100644 --- a/templates/partials/modes/weather-satellite.html +++ b/templates/partials/modes/weather-satellite.html @@ -1,22 +1,26 @@ -
-
-

Weather Satellite Decoder

-

- Receive and decode weather images from NOAA and Meteor satellites. - Uses SatDump for live SDR capture and image processing. -

-
+
+
+

Weather Satellite Decoder

+
+ ALPHA: Weather Satellite capture is experimental and may fail depending on SatDump version, SDR driver support, and pass conditions. +
+

+ Receive and decode weather images from NOAA and Meteor satellites. + Uses SatDump for live SDR capture and image processing. +

+

Satellite

@@ -187,10 +191,11 @@
diff --git a/templates/partials/modes/wifi.html b/templates/partials/modes/wifi.html index ba23ec6..e922049 100644 --- a/templates/partials/modes/wifi.html +++ b/templates/partials/modes/wifi.html @@ -1,7 +1,8 @@
-
+
+

Signal Source

- -
- - -
- - - - - - -
+

Export

+ +
+ +
+ + +
+ + + + + +
diff --git a/templates/partials/nav.html b/templates/partials/nav.html index 79e1cb4..64c53d2 100644 --- a/templates/partials/nav.html +++ b/templates/partials/nav.html @@ -71,8 +71,8 @@ {{ mode_item('listening', 'Listening Post', '') }} {{ mode_item('spystations', 'Spy Stations', '') }} {{ mode_item('meshtastic', 'Meshtastic', '') }} - {{ mode_item('dmr', 'Digital Voice', '') }} {{ mode_item('websdr', 'WebSDR', '') }} + {{ mode_item('subghz', 'SubGHz', '') }}
@@ -191,8 +191,8 @@ {{ mobile_item('listening', 'Scanner', '') }} {{ mobile_item('spystations', 'Spy', '') }} {{ mobile_item('meshtastic', 'Mesh', '') }} - {{ mobile_item('dmr', 'DMR', '') }} {{ mobile_item('websdr', 'WebSDR', '') }} + {{ mobile_item('subghz', 'SubGHz', '') }} {# JavaScript stub for pages that don't have switchMode defined #} diff --git a/templates/partials/settings-modal.html b/templates/partials/settings-modal.html index b9c951b..86ba4f5 100644 --- a/templates/partials/settings-modal.html +++ b/templates/partials/settings-modal.html @@ -323,7 +323,6 @@ - diff --git a/templates/satellite_dashboard.html b/templates/satellite_dashboard.html index f6492a9..d487052 100644 --- a/templates/satellite_dashboard.html +++ b/templates/satellite_dashboard.html @@ -107,12 +107,8 @@
@@ -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() { diff --git a/tests/test_dmr.py b/tests/test_dmr.py index d245663..6a683d4 100644 --- a/tests/test_dmr.py +++ b/tests/test_dmr.py @@ -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 diff --git a/tests/test_routes.py b/tests/test_routes.py index e70408c..51aafc2 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -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): diff --git a/tests/test_rtl_fm_modulation.py b/tests/test_rtl_fm_modulation.py new file mode 100644 index 0000000..821b6b1 --- /dev/null +++ b/tests/test_rtl_fm_modulation.py @@ -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' + diff --git a/tests/test_subghz.py b/tests/test_subghz.py new file mode 100644 index 0000000..6e73ba8 --- /dev/null +++ b/tests/test_subghz.py @@ -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' diff --git a/tests/test_subghz_routes.py b/tests/test_subghz_routes.py new file mode 100644 index 0000000..0ade15b --- /dev/null +++ b/tests/test_subghz_routes.py @@ -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') diff --git a/utils/constants.py b/utils/constants.py index 2b5edff..a0be48b 100644 --- a/utils/constants.py +++ b/utils/constants.py @@ -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 # ============================================================================= diff --git a/utils/dependencies.py b/utils/dependencies.py index a12eca7..e595103 100644 --- a/utils/dependencies.py +++ b/utils/dependencies.py @@ -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': { diff --git a/utils/sdr/detection.py b/utils/sdr/detection.py index 7ddce3e..e97c6b8 100644 --- a/utils/sdr/detection.py +++ b/utils/sdr/detection.py @@ -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: diff --git a/utils/sdr/rtlsdr.py b/utils/sdr/rtlsdr.py index 25b4495..1e68c35 100644 --- a/utils/sdr/rtlsdr.py +++ b/utils/sdr/rtlsdr.py @@ -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)]) diff --git a/utils/sstv/vis.py b/utils/sstv/vis.py index a7c840a..bd3d3ed 100644 --- a/utils/sstv/vis.py +++ b/utils/sstv/vis.py @@ -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): diff --git a/utils/subghz.py b/utils/subghz.py new file mode 100644 index 0000000..adb82d3 --- /dev/null +++ b/utils/subghz.py @@ -0,0 +1,2809 @@ +"""SubGHz transceiver manager for HackRF-based signal capture, decode, and replay. + +Provides IQ capture via hackrf_transfer, protocol decoding via hackrf_transfer piped +to rtl_433, signal replay/transmit with safety enforcement, and wideband spectrum +sweeps via hackrf_sweep. +""" + +from __future__ import annotations + +import json +import hashlib +import os +import queue +import shutil +import subprocess +import threading +import time +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import BinaryIO, Callable + +import numpy as np + +from utils.logging import get_logger +from utils.process import register_process, safe_terminate, unregister_process +from utils.constants import ( + SUBGHZ_TX_ALLOWED_BANDS, + SUBGHZ_FREQ_MIN_MHZ, + SUBGHZ_FREQ_MAX_MHZ, + SUBGHZ_LNA_GAIN_MIN, + SUBGHZ_LNA_GAIN_MAX, + SUBGHZ_VGA_GAIN_MIN, + SUBGHZ_VGA_GAIN_MAX, + SUBGHZ_TX_VGA_GAIN_MIN, + SUBGHZ_TX_VGA_GAIN_MAX, + SUBGHZ_TX_MAX_DURATION, +) + +logger = get_logger('intercept.subghz') + + +@dataclass +class SubGhzCapture: + """Metadata for a saved IQ capture.""" + capture_id: str + filename: str + frequency_hz: int + sample_rate: int + lna_gain: int + vga_gain: int + timestamp: str + duration_seconds: float = 0.0 + size_bytes: int = 0 + label: str = '' + label_source: str = '' + decoded_protocols: list[str] = field(default_factory=list) + bursts: list[dict] = field(default_factory=list) + modulation_hint: str = '' + modulation_confidence: float = 0.0 + protocol_hint: str = '' + dominant_fingerprint: str = '' + fingerprint_group: str = '' + fingerprint_group_size: int = 0 + trigger_enabled: bool = False + trigger_pre_seconds: float = 0.0 + trigger_post_seconds: float = 0.0 + + def to_dict(self) -> dict: + return { + 'id': self.capture_id, + 'filename': self.filename, + 'frequency_hz': self.frequency_hz, + 'sample_rate': self.sample_rate, + 'lna_gain': self.lna_gain, + 'vga_gain': self.vga_gain, + 'timestamp': self.timestamp, + 'duration_seconds': self.duration_seconds, + 'size_bytes': self.size_bytes, + 'label': self.label, + 'label_source': self.label_source, + 'decoded_protocols': self.decoded_protocols, + 'bursts': self.bursts, + 'modulation_hint': self.modulation_hint, + 'modulation_confidence': self.modulation_confidence, + 'protocol_hint': self.protocol_hint, + 'dominant_fingerprint': self.dominant_fingerprint, + 'fingerprint_group': self.fingerprint_group, + 'fingerprint_group_size': self.fingerprint_group_size, + 'trigger_enabled': self.trigger_enabled, + 'trigger_pre_seconds': self.trigger_pre_seconds, + 'trigger_post_seconds': self.trigger_post_seconds, + } + + +@dataclass +class SweepPoint: + """A single frequency/power data point from hackrf_sweep.""" + freq_mhz: float + power_dbm: float + + def to_dict(self) -> dict: + return {'freq': self.freq_mhz, 'power': self.power_dbm} + + +class SubGhzManager: + """Singleton manager for SubGHz transceiver operations. + + Manages hackrf_transfer (RX/TX), rtl_433 (decode), and hackrf_sweep (spectrum) + subprocesses with mutual exclusion and safety enforcement. + """ + + def __init__(self, data_dir: str | Path | None = None): + self._data_dir = Path(data_dir) if data_dir else Path('data/subghz') + self._captures_dir = self._data_dir / 'captures' + self._captures_dir.mkdir(parents=True, exist_ok=True) + + # Process state + self._rx_process: subprocess.Popen | None = None + self._decode_process: subprocess.Popen | None = None + self._decode_hackrf_process: subprocess.Popen | None = None + self._tx_process: subprocess.Popen | None = None + self._sweep_process: subprocess.Popen | None = None + + self._lock = threading.RLock() + self._callback: Callable[[dict], None] | None = None + + # RX state + self._rx_start_time: float = 0 + self._rx_frequency_hz: int = 0 + self._rx_sample_rate: int = 0 + self._rx_lna_gain: int = 0 + self._rx_vga_gain: int = 0 + self._rx_file: Path | None = None + self._rx_file_handle: BinaryIO | None = None + self._rx_thread: threading.Thread | None = None + self._rx_stop = False + self._rx_bytes_written = 0 + self._rx_bursts: list[dict] = [] + self._rx_trigger_enabled = False + self._rx_trigger_pre_s = 0.35 + self._rx_trigger_post_s = 0.7 + self._rx_trigger_first_burst_start: float | None = None + self._rx_trigger_last_burst_end: float | None = None + self._rx_autostop_pending = False + self._rx_modulation_hint = '' + self._rx_modulation_confidence = 0.0 + self._rx_protocol_hint = '' + self._rx_fingerprint_counts: dict[str, int] = {} + + # Decode state + self._decode_start_time: float = 0 + self._decode_frequency_hz: int = 0 + self._decode_sample_rate: int = 0 + self._decode_stop = False + + # TX state + self._tx_start_time: float = 0 + self._tx_watchdog: threading.Timer | None = None + self._tx_capture_id: str = '' + self._tx_temp_file: Path | None = None + + # Sweep state + self._sweep_running = False + self._sweep_thread: threading.Thread | None = None + + # Tool availability + self._hackrf_available: bool | None = None + self._hackrf_info_available: bool | None = None + self._hackrf_device_cache: bool | None = None + self._hackrf_device_cache_ts: float = 0.0 + self._rtl433_available: bool | None = None + self._sweep_available: bool | None = None + + @property + def data_dir(self) -> Path: + return self._data_dir + + def set_callback(self, callback: Callable[[dict], None] | None) -> None: + self._callback = callback + + def _emit(self, event: dict) -> None: + if self._callback: + try: + self._callback(event) + except Exception as e: + logger.error(f"Error in SubGHz callback: {e}") + + # ------------------------------------------------------------------ + # Tool detection + # ------------------------------------------------------------------ + + def check_hackrf(self) -> bool: + if self._hackrf_available is None: + self._hackrf_available = shutil.which('hackrf_transfer') is not None + return self._hackrf_available + + def check_hackrf_info(self) -> bool: + if self._hackrf_info_available is None: + self._hackrf_info_available = shutil.which('hackrf_info') is not None + return self._hackrf_info_available + + def check_hackrf_device(self) -> bool | None: + """Return True if a HackRF device is detected, False if not, or None if detection unavailable.""" + if not self.check_hackrf_info(): + return None + + now = time.time() + if self._hackrf_device_cache is not None and (now - self._hackrf_device_cache_ts) < 2.0: + return self._hackrf_device_cache + + try: + from utils.sdr.detection import detect_hackrf_devices + connected = len(detect_hackrf_devices()) > 0 + except Exception as exc: + logger.debug(f"HackRF device detection failed: {exc}") + connected = False + + self._hackrf_device_cache = connected + self._hackrf_device_cache_ts = now + return connected + + def _require_hackrf_device(self) -> str | None: + """Return an error string if HackRF is explicitly not detected.""" + detected = self.check_hackrf_device() + if detected is False: + return 'HackRF device not detected' + return None + + def check_rtl433(self) -> bool: + if self._rtl433_available is None: + self._rtl433_available = shutil.which('rtl_433') is not None + return self._rtl433_available + + def check_sweep(self) -> bool: + if self._sweep_available is None: + self._sweep_available = shutil.which('hackrf_sweep') is not None + return self._sweep_available + + # ------------------------------------------------------------------ + # Status + # ------------------------------------------------------------------ + + @property + def active_mode(self) -> str: + """Return current active mode or 'idle'.""" + with self._lock: + if self._rx_process and self._rx_process.poll() is None: + return 'rx' + if self._decode_process and self._decode_process.poll() is None: + return 'decode' + if self._tx_process and self._tx_process.poll() is None: + return 'tx' + if self._sweep_process and self._sweep_process.poll() is None: + return 'sweep' + return 'idle' + + def get_status(self) -> dict: + mode = self.active_mode + hackrf_info_available = self.check_hackrf_info() + detect_paused = mode in {'rx', 'decode', 'tx', 'sweep'} + if detect_paused: + # Avoid probing HackRF while a stream is active. A fresh "disconnected" + # cache result should still surface to the UI, otherwise mark unknown. + if self._hackrf_device_cache is False and (time.time() - self._hackrf_device_cache_ts) < 15.0: + hackrf_connected: bool | None = False + else: + hackrf_connected = None + else: + hackrf_connected = self.check_hackrf_device() + status: dict = { + 'mode': mode, + 'hackrf_available': self.check_hackrf(), + 'hackrf_info_available': hackrf_info_available, + 'hackrf_connected': hackrf_connected, + 'hackrf_detection_paused': detect_paused, + 'rtl433_available': self.check_rtl433(), + 'sweep_available': self.check_sweep(), + } + if mode == 'rx': + elapsed = time.time() - self._rx_start_time if self._rx_start_time else 0 + status.update({ + 'frequency_hz': self._rx_frequency_hz, + 'sample_rate': self._rx_sample_rate, + 'elapsed_seconds': round(elapsed, 1), + 'trigger_enabled': self._rx_trigger_enabled, + 'trigger_pre_seconds': round(self._rx_trigger_pre_s, 3), + 'trigger_post_seconds': round(self._rx_trigger_post_s, 3), + }) + elif mode == 'decode': + elapsed = time.time() - self._decode_start_time if self._decode_start_time else 0 + status.update({ + 'frequency_hz': self._decode_frequency_hz, + 'sample_rate': self._decode_sample_rate, + 'elapsed_seconds': round(elapsed, 1), + }) + elif mode == 'tx': + elapsed = time.time() - self._tx_start_time if self._tx_start_time else 0 + status.update({ + 'capture_id': self._tx_capture_id, + 'elapsed_seconds': round(elapsed, 1), + }) + return status + + # ------------------------------------------------------------------ + # RECEIVE (IQ capture via hackrf_transfer -r) + # ------------------------------------------------------------------ + + def start_receive( + self, + frequency_hz: int, + sample_rate: int = 2000000, + lna_gain: int = 32, + vga_gain: int = 20, + trigger_enabled: bool = False, + trigger_pre_ms: int = 350, + trigger_post_ms: int = 700, + device_serial: str | None = None, + ) -> dict: + with self._lock: + if self.active_mode != 'idle': + return {'status': 'error', 'message': f'Already running: {self.active_mode}'} + + if not self.check_hackrf(): + return {'status': 'error', 'message': 'hackrf_transfer not found'} + device_err = self._require_hackrf_device() + if device_err: + return {'status': 'error', 'message': device_err} + + # Validate gains + lna_gain = max(SUBGHZ_LNA_GAIN_MIN, min(SUBGHZ_LNA_GAIN_MAX, lna_gain)) + vga_gain = max(SUBGHZ_VGA_GAIN_MIN, min(SUBGHZ_VGA_GAIN_MAX, vga_gain)) + + # Generate filename + ts = datetime.now().strftime('%Y%m%d_%H%M%S') + freq_mhz = frequency_hz / 1_000_000 + basename = f"{freq_mhz:.3f}MHz_{ts}" + iq_file = self._captures_dir / f"{basename}.iq" + + cmd = [ + 'hackrf_transfer', + '-r', str(iq_file), + '-f', str(frequency_hz), + '-s', str(sample_rate), + '-l', str(lna_gain), + '-g', str(vga_gain), + ] + if device_serial: + cmd.extend(['-d', device_serial]) + + logger.info(f"SubGHz RX: {' '.join(cmd)}") + + try: + try: + iq_file.touch(exist_ok=True) + except OSError as e: + logger.error(f"Failed to create RX file: {e}") + return {'status': 'error', 'message': 'Failed to create capture file'} + + self._rx_process = subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + register_process(self._rx_process) + + try: + self._rx_file_handle = open(iq_file, 'rb', buffering=0) + except OSError as e: + safe_terminate(self._rx_process) + unregister_process(self._rx_process) + self._rx_process = None + logger.error(f"Failed to open RX file: {e}") + return {'status': 'error', 'message': 'Failed to open capture file'} + + self._rx_start_time = time.time() + self._rx_frequency_hz = frequency_hz + self._rx_sample_rate = sample_rate + self._rx_lna_gain = lna_gain + self._rx_vga_gain = vga_gain + self._rx_file = iq_file + self._rx_stop = False + self._rx_bytes_written = 0 + self._rx_bursts = [] + self._rx_trigger_enabled = bool(trigger_enabled) + self._rx_trigger_pre_s = max(0.05, min(5.0, float(trigger_pre_ms) / 1000.0)) + self._rx_trigger_post_s = max(0.10, min(10.0, float(trigger_post_ms) / 1000.0)) + self._rx_trigger_first_burst_start = None + self._rx_trigger_last_burst_end = None + self._rx_autostop_pending = False + self._rx_modulation_hint = '' + self._rx_modulation_confidence = 0.0 + self._rx_protocol_hint = '' + self._rx_fingerprint_counts = {} + + # Start capture stream reader + self._rx_thread = threading.Thread( + target=self._rx_capture_loop, + daemon=True, + ) + self._rx_thread.start() + + # Monitor stderr in background + threading.Thread( + target=self._monitor_rx_stderr, + daemon=True, + ).start() + + self._emit({ + 'type': 'status', + 'mode': 'rx', + 'status': 'started', + 'frequency_hz': frequency_hz, + 'sample_rate': sample_rate, + 'trigger_enabled': self._rx_trigger_enabled, + 'trigger_pre_seconds': round(self._rx_trigger_pre_s, 3), + 'trigger_post_seconds': round(self._rx_trigger_post_s, 3), + }) + + if self._rx_trigger_enabled: + self._emit({ + 'type': 'info', + 'text': ( + f'[rx] Smart trigger armed ' + f'(pre {self._rx_trigger_pre_s:.2f}s, post {self._rx_trigger_post_s:.2f}s)' + ), + }) + + return { + 'status': 'started', + 'frequency_hz': frequency_hz, + 'sample_rate': sample_rate, + 'file': iq_file.name, + 'trigger_enabled': self._rx_trigger_enabled, + 'trigger_pre_seconds': round(self._rx_trigger_pre_s, 3), + 'trigger_post_seconds': round(self._rx_trigger_post_s, 3), + } + + except FileNotFoundError: + return {'status': 'error', 'message': 'hackrf_transfer not found'} + except Exception as e: + logger.error(f"Failed to start RX: {e}") + return {'status': 'error', 'message': str(e)} + + def _estimate_modulation_hint( + self, + data: bytes, + ) -> tuple[str, float, str]: + """Estimate coarse modulation family from raw IQ characteristics.""" + if not data: + return 'Unknown', 0.0, 'No samples' + try: + raw = np.frombuffer(data, dtype=np.int8).astype(np.float32) + if raw.size < 2048: + return 'Unknown', 0.0, 'Insufficient samples' + + i_vals = raw[0::2] + q_vals = raw[1::2] + if i_vals.size == 0 or q_vals.size == 0: + return 'Unknown', 0.0, 'Invalid IQ frame' + + # Light decimation for lower CPU while preserving burst shape. + i_vals = i_vals[::4] + q_vals = q_vals[::4] + if i_vals.size < 256 or q_vals.size < 256: + return 'Unknown', 0.0, 'Short frame' + + iq = i_vals + 1j * q_vals + amp = np.abs(iq) + mean_amp = float(np.mean(amp)) + std_amp = float(np.std(amp)) + amp_cv = std_amp / max(mean_amp, 1.0) + + phase_step = np.angle(iq[1:] * np.conj(iq[:-1])) + phase_var = float(np.std(phase_step)) + + # Simple pulse run-length profile on envelope. + envelope = amp - float(np.median(amp)) + env_scale = float(np.percentile(np.abs(envelope), 92)) + if env_scale <= 1e-6: + pulse_density = 0.0 + mean_run = 0.0 + else: + norm = np.clip(envelope / env_scale, -1.0, 1.0) + high = norm > 0.25 + pulse_density = float(np.mean(high)) + changes = np.where(np.diff(high.astype(np.int8)) != 0)[0] + if changes.size >= 2: + runs = np.diff(np.concatenate(([0], changes, [high.size - 1]))) + mean_run = float(np.mean(runs)) + else: + mean_run = float(high.size) + + scores = { + 'OOK/ASK': 0.0, + 'FSK/GFSK': 0.0, + 'PWM/PPM': 0.0, + } + + # OOK: stronger amplitude contrast and moderate pulse occupancy. + scores['OOK/ASK'] += max(0.0, min(1.0, (amp_cv - 0.22) / 0.35)) + scores['OOK/ASK'] += max(0.0, 1.0 - abs(pulse_density - 0.4) / 0.4) * 0.35 + + # FSK: flatter amplitude, more phase movement. + scores['FSK/GFSK'] += max(0.0, min(1.0, (phase_var - 0.45) / 0.9)) + scores['FSK/GFSK'] += max(0.0, min(1.0, (0.33 - amp_cv) / 0.28)) * 0.45 + + # PWM/PPM: high edge density with short run lengths. + edge_density = 0.0 if mean_run <= 0 else min(1.0, 28.0 / max(mean_run, 1.0)) + scores['PWM/PPM'] += max(0.0, min(1.0, (amp_cv - 0.28) / 0.45)) + scores['PWM/PPM'] += edge_density * 0.6 + + best_family = max(scores, key=scores.get) + best_score = float(scores[best_family]) + confidence = max(0.0, min(0.97, best_score)) + if confidence < 0.25: + return 'Unknown', confidence, 'No clear modulation signature' + + reason = ( + f'amp_cv={amp_cv:.2f} phase_var={phase_var:.2f} ' + f'pulse_density={pulse_density:.2f}' + ) + return best_family, confidence, reason + except Exception: + return 'Unknown', 0.0, 'Modulation analysis failed' + + def _fingerprint_burst_bytes( + self, + data: bytes, + sample_rate: int, + duration_seconds: float, + ) -> str: + """Create a stable burst fingerprint for grouping similar signals.""" + if not data: + return '' + try: + raw = np.frombuffer(data, dtype=np.int8).astype(np.float32) + if raw.size < 512: + return '' + + i_vals = raw[0::2] + q_vals = raw[1::2] + if i_vals.size == 0 or q_vals.size == 0: + return '' + + amp = np.sqrt(i_vals * i_vals + q_vals * q_vals) + if amp.size < 64: + return '' + + # Normalize and downsample envelope into a fixed-size shape vector. + amp = amp - float(np.median(amp)) + scale = float(np.percentile(np.abs(amp), 95)) + if scale <= 1e-6: + scale = 1.0 + amp = np.clip(amp / scale, -1.0, 1.0) + target = 128 + if amp.size != target: + idx = np.linspace(0, amp.size - 1, target).astype(int) + amp = amp[idx] + quant = np.round((amp + 1.0) * 7.5).astype(np.uint8) + + # Include coarse timing and center-energy traits. + burst_ms = int(max(1, round(duration_seconds * 1000))) + sr_khz = int(max(1, round(sample_rate / 1000))) + payload = ( + quant.tobytes() + + burst_ms.to_bytes(2, 'little', signed=False) + + sr_khz.to_bytes(2, 'little', signed=False) + ) + return hashlib.sha1(payload).hexdigest()[:16] + except Exception: + return '' + + def _protocol_hint_from_capture( + self, + frequency_hz: int, + modulation_hint: str, + burst_count: int, + ) -> str: + freq = frequency_hz / 1_000_000 + mod = (modulation_hint or '').upper() + if burst_count <= 0: + return 'No burst activity' + if 433.70 <= freq <= 434.10 and 'OOK' in mod and burst_count >= 2: + return 'Likely weather sensor / simple remote telemetry' + if 868.0 <= freq <= 870.0 and 'OOK' in mod: + return 'Likely EU ISM OOK sensor/remote' + if 902.0 <= freq <= 928.0 and 'FSK' in mod: + return 'Likely ISM telemetry (FSK/GFSK)' + if 'PWM' in mod: + return 'Likely pulse-width/distance keyed remote' + if 'FSK' in mod: + return 'Likely continuous-tone telemetry' + if 'OOK' in mod: + return 'Likely OOK keyed burst transmitter' + return 'Unknown protocol family' + + def _auto_capture_label( + self, + frequency_hz: int, + burst_count: int, + modulation_hint: str, + protocol_hint: str, + ) -> str: + freq = frequency_hz / 1_000_000 + mod = (modulation_hint or '').upper() + if burst_count <= 0: + return f'Raw Capture {freq:.3f} MHz' + if 'weather' in protocol_hint.lower(): + return f'Weather-like Burst ({burst_count})' + if 'OOK' in mod: + return f'OOK Burst Cluster ({burst_count})' + if 'FSK' in mod: + return f'FSK Telemetry Burst ({burst_count})' + if 'PWM' in mod: + return f'PWM/PPM Burst ({burst_count})' + return f'RF Burst Capture ({burst_count})' + + def _trim_capture_to_trigger_window( + self, + iq_file: Path, + sample_rate: int, + duration_seconds: float, + bursts: list[dict], + ) -> tuple[float, list[dict]]: + """Trim a full capture to trigger window using configured pre/post roll.""" + if not self._rx_trigger_enabled or not bursts or sample_rate <= 0: + return duration_seconds, bursts + + first_start = min(float(b.get('start_seconds', 0.0)) for b in bursts) + last_end = max( + float(b.get('start_seconds', 0.0)) + float(b.get('duration_seconds', 0.0)) + for b in bursts + ) + start_s = max(0.0, first_start - self._rx_trigger_pre_s) + end_s = min(duration_seconds, last_end + self._rx_trigger_post_s) + if end_s <= start_s: + return duration_seconds, bursts + if start_s <= 0.001 and (duration_seconds - end_s) <= 0.001: + return duration_seconds, bursts + + bytes_per_second = max(2, int(sample_rate) * 2) + start_byte = int(start_s * bytes_per_second) & ~1 + end_byte = int(end_s * bytes_per_second) & ~1 + if end_byte <= start_byte: + return duration_seconds, bursts + + tmp_path = iq_file.with_suffix('.trimtmp') + try: + with open(iq_file, 'rb') as src, open(tmp_path, 'wb') as dst: + src.seek(start_byte) + remaining = end_byte - start_byte + while remaining > 0: + chunk = src.read(min(262144, remaining)) + if not chunk: + break + dst.write(chunk) + remaining -= len(chunk) + os.replace(tmp_path, iq_file) + except OSError as exc: + logger.error(f"Failed trimming trigger capture: {exc}") + try: + if tmp_path.exists(): + tmp_path.unlink() + except OSError: + pass + return duration_seconds, bursts + + trimmed_duration = max(0.0, float(end_byte - start_byte) / float(bytes_per_second)) + adjusted_bursts: list[dict] = [] + for burst in bursts: + raw_start = float(burst.get('start_seconds', 0.0)) + raw_dur = max(0.0, float(burst.get('duration_seconds', 0.0))) + raw_end = raw_start + raw_dur + if raw_end < start_s or raw_start > end_s: + continue + adjusted = dict(burst) + adjusted['start_seconds'] = round(max(0.0, raw_start - start_s), 3) + adjusted['duration_seconds'] = round(raw_dur, 3) + adjusted_bursts.append(adjusted) + return trimmed_duration, adjusted_bursts if adjusted_bursts else bursts + + def _rx_capture_loop(self) -> None: + """Read IQ data from the capture file and emit UI metrics.""" + process = self._rx_process + file_handle = self._rx_file_handle + + if not process or not file_handle: + logger.error("RX capture loop missing process/file handle") + return + + CHUNK = 262144 # 256 KB (~64 ms @ 2 Msps complex int8 IQ) + LEVEL_INTERVAL = 0.05 + WAVE_INTERVAL = 0.25 + SPECTRUM_INTERVAL = 0.25 + STATS_INTERVAL = 1.0 + HINT_EVAL_INTERVAL = 0.25 + HINT_EMIT_INTERVAL = 1.5 + + last_level = 0.0 + last_wave = 0.0 + last_spectrum = 0.0 + last_stats = time.time() + last_log = time.time() + last_hint_eval = 0.0 + last_hint_emit = 0.0 + bytes_since_stats = 0 + first_chunk = True + burst_active = False + burst_start = 0.0 + burst_last_high = 0.0 + burst_peak = 0 + burst_bytes = bytearray() + burst_hint_family = 'Unknown' + burst_hint_conf = 0.0 + BURST_OFF_HOLD = 0.18 + BURST_MIN_DURATION = 0.04 + MAX_BURST_BYTES = max(262144, int(max(1, self._rx_sample_rate) * 2 * 2)) + smooth_level = 0.0 + prev_smooth_level = 0.0 + noise_floor = 0.0 + peak_tracker = 0.0 + on_threshold = 0.0 + warmup_until = time.time() + 1.0 + modulation_scores: dict[str, float] = { + 'OOK/ASK': 0.0, + 'FSK/GFSK': 0.0, + 'PWM/PPM': 0.0, + } + last_hint_reason = '' + + try: + fd = file_handle.fileno() + if not isinstance(fd, int) or fd < 0: + logger.error("Invalid file descriptor from RX file handle") + return + except (OSError, ValueError, TypeError): + logger.error("Failed to obtain RX file descriptor") + return + + try: + while not self._rx_stop: + try: + data = os.read(fd, CHUNK) + except OSError: + break + if not data: + if process.poll() is not None: + break + time.sleep(0.05) + continue + + self._rx_bytes_written += len(data) + bytes_since_stats += len(data) + if burst_active and len(burst_bytes) < MAX_BURST_BYTES: + room = MAX_BURST_BYTES - len(burst_bytes) + burst_bytes.extend(data[:room]) + + if first_chunk: + first_chunk = False + self._emit({'type': 'info', 'text': '[rx] Receiving IQ data...'}) + + now = time.time() + if now - last_hint_eval >= HINT_EVAL_INTERVAL: + for key in modulation_scores: + modulation_scores[key] *= 0.97 + hint_family, hint_conf, hint_reason = self._estimate_modulation_hint(data) + if hint_family in modulation_scores: + modulation_scores[hint_family] += max(0.05, hint_conf) + last_hint_reason = hint_reason + last_hint_eval = now + + if now - last_level >= LEVEL_INTERVAL: + level = float(self._compute_rx_level(data)) + prev_smooth_level = smooth_level + if smooth_level <= 0: + smooth_level = level + else: + smooth_level = (smooth_level * 0.72) + (level * 0.28) + + if noise_floor <= 0: + noise_floor = smooth_level + elif not burst_active: + # Track receiver noise floor when we are not inside a burst. + noise_floor = (noise_floor * 0.94) + (smooth_level * 0.06) + + peak_tracker = max(smooth_level, peak_tracker * 0.985) + spread = max(2.0, peak_tracker - noise_floor) + on_delta = max(2.8, spread * 0.52) + off_delta = max(1.2, spread * 0.24) + on_threshold = min(95.0, noise_floor + on_delta) + off_threshold = max(0.8, min(on_threshold - 0.5, noise_floor + off_delta)) + rising = smooth_level - prev_smooth_level + + self._emit({'type': 'rx_level', 'level': int(round(smooth_level))}) + + if not burst_active: + if now >= warmup_until and smooth_level >= on_threshold and rising >= 0.35: + burst_active = True + burst_start = now + burst_last_high = now + burst_peak = int(round(smooth_level)) + burst_bytes = bytearray(data[: min(len(data), MAX_BURST_BYTES)]) + burst_hint_family = 'Unknown' + burst_hint_conf = 0.0 + if self._rx_trigger_enabled and self._rx_trigger_first_burst_start is None: + self._rx_trigger_first_burst_start = max( + 0.0, now - self._rx_start_time + ) + self._emit({ + 'type': 'info', + 'text': '[rx] Trigger fired - capturing burst window', + }) + self._emit({ + 'type': 'rx_burst', + 'mode': 'rx', + 'event': 'start', + 'start_offset_s': round( + max(0.0, now - self._rx_start_time), 3 + ), + 'level': int(round(smooth_level)), + }) + else: + if smooth_level >= off_threshold: + burst_last_high = now + burst_peak = max(burst_peak, int(round(smooth_level))) + elif (now - burst_last_high) >= BURST_OFF_HOLD: + duration = now - burst_start + if duration >= BURST_MIN_DURATION: + fp = self._fingerprint_burst_bytes( + bytes(burst_bytes), + self._rx_sample_rate, + duration, + ) + if fp: + self._rx_fingerprint_counts[fp] = ( + self._rx_fingerprint_counts.get(fp, 0) + 1 + ) + burst_hint_family, burst_hint_conf, burst_reason = self._estimate_modulation_hint( + bytes(burst_bytes) + ) + if burst_hint_family in modulation_scores and burst_hint_conf > 0: + modulation_scores[burst_hint_family] += burst_hint_conf * 1.8 + last_hint_reason = burst_reason + burst_data = { + 'start_seconds': round( + max(0.0, burst_start - self._rx_start_time), 3 + ), + 'duration_seconds': round(duration, 3), + 'peak_level': int(burst_peak), + 'fingerprint': fp, + 'modulation_hint': burst_hint_family, + 'modulation_confidence': round(float(burst_hint_conf), 3), + } + if len(self._rx_bursts) < 512: + self._rx_bursts.append(burst_data) + self._rx_trigger_last_burst_end = max( + 0.0, now - self._rx_start_time + ) + self._emit({ + 'type': 'rx_burst', + 'mode': 'rx', + 'event': 'end', + 'start_offset_s': burst_data['start_seconds'], + 'duration_ms': int(duration * 1000), + 'peak_level': int(burst_peak), + 'fingerprint': fp, + 'modulation_hint': burst_hint_family, + 'modulation_confidence': round(float(burst_hint_conf), 3), + }) + burst_active = False + burst_peak = 0 + burst_bytes = bytearray() + last_level = now + + # Emit live modulation/protocol hint periodically. + if now - last_hint_emit >= HINT_EMIT_INTERVAL: + best_family = max(modulation_scores, key=modulation_scores.get) + total_score = sum(max(0.0, v) for v in modulation_scores.values()) + best_score = max(0.0, modulation_scores.get(best_family, 0.0)) + hint_conf = 0.0 if total_score <= 0 else min(0.98, best_score / total_score) + protocol_hint = self._protocol_hint_from_capture( + self._rx_frequency_hz, + best_family if hint_conf >= 0.3 else 'Unknown', + len(self._rx_bursts), + ) + self._rx_protocol_hint = protocol_hint + if hint_conf >= 0.30: + self._rx_modulation_hint = best_family + self._rx_modulation_confidence = hint_conf + self._emit({ + 'type': 'rx_hint', + 'modulation_hint': best_family, + 'confidence': round(hint_conf, 3), + 'protocol_hint': protocol_hint, + 'reason': last_hint_reason, + }) + last_hint_emit = now + + # Smart-trigger auto-stop after quiet post-roll window. + if ( + self._rx_trigger_enabled + and self._rx_trigger_first_burst_start is not None + and not burst_active + and not self._rx_autostop_pending + ): + last_end = self._rx_trigger_last_burst_end + if last_end is not None and (max(0.0, now - self._rx_start_time) - last_end) >= self._rx_trigger_post_s: + self._rx_autostop_pending = True + self._emit({ + 'type': 'info', + 'text': '[rx] Trigger window complete - finalizing capture', + }) + threading.Thread(target=self.stop_receive, daemon=True).start() + break + + if now - last_wave >= WAVE_INTERVAL: + samples = self._extract_waveform(data) + if samples: + self._emit({'type': 'rx_waveform', 'samples': samples}) + last_wave = now + + if now - last_spectrum >= SPECTRUM_INTERVAL: + bins = self._compute_rx_spectrum(data) + if bins: + self._emit({'type': 'rx_spectrum', 'bins': bins}) + last_spectrum = now + + if now - last_stats >= STATS_INTERVAL: + rate_kb = bytes_since_stats / (now - last_stats) / 1024 + file_size = 0 + if self._rx_file and self._rx_file.exists(): + try: + file_size = self._rx_file.stat().st_size + except OSError: + file_size = 0 + self._emit({ + 'type': 'rx_stats', + 'rate_kb': round(rate_kb, 1), + 'file_size': file_size, + 'elapsed_seconds': round(time.time() - self._rx_start_time, 1) if self._rx_start_time else 0, + }) + if now - last_log >= 5.0: + self._emit({ + 'type': 'info', + 'text': ( + f'[rx] IQ: {rate_kb:.0f} KB/s ' + f'(lvl {smooth_level:.1f}, floor {noise_floor:.1f}, thr {on_threshold:.1f})' + ), + }) + last_log = now + bytes_since_stats = 0 + last_stats = now + + if burst_active: + duration = max(0.0, time.time() - burst_start) + if duration >= BURST_MIN_DURATION: + fp = self._fingerprint_burst_bytes( + bytes(burst_bytes), + self._rx_sample_rate, + duration, + ) + if fp: + self._rx_fingerprint_counts[fp] = ( + self._rx_fingerprint_counts.get(fp, 0) + 1 + ) + burst_hint_family, burst_hint_conf, burst_reason = self._estimate_modulation_hint( + bytes(burst_bytes) + ) + if burst_hint_family in modulation_scores and burst_hint_conf > 0: + modulation_scores[burst_hint_family] += burst_hint_conf * 1.8 + last_hint_reason = burst_reason + burst_data = { + 'start_seconds': round( + max(0.0, burst_start - self._rx_start_time), 3 + ), + 'duration_seconds': round(duration, 3), + 'peak_level': int(burst_peak), + 'fingerprint': fp, + 'modulation_hint': burst_hint_family, + 'modulation_confidence': round(float(burst_hint_conf), 3), + } + if len(self._rx_bursts) < 512: + self._rx_bursts.append(burst_data) + self._rx_trigger_last_burst_end = max( + 0.0, time.time() - self._rx_start_time + ) + self._emit({ + 'type': 'rx_burst', + 'mode': 'rx', + 'event': 'end', + 'start_offset_s': burst_data['start_seconds'], + 'duration_ms': int(duration * 1000), + 'peak_level': int(burst_peak), + 'fingerprint': fp, + 'modulation_hint': burst_hint_family, + 'modulation_confidence': round(float(burst_hint_conf), 3), + }) + + # Finalize modulation summary for capture metadata. + if modulation_scores: + best_family = max(modulation_scores, key=modulation_scores.get) + total_score = sum(max(0.0, v) for v in modulation_scores.values()) + best_score = max(0.0, modulation_scores.get(best_family, 0.0)) + hint_conf = 0.0 if total_score <= 0 else min(0.98, best_score / total_score) + if hint_conf >= 0.3: + self._rx_modulation_hint = best_family + self._rx_modulation_confidence = hint_conf + self._rx_protocol_hint = self._protocol_hint_from_capture( + self._rx_frequency_hz, + self._rx_modulation_hint, + len(self._rx_bursts), + ) + finally: + try: + file_handle.close() + except OSError: + pass + with self._lock: + if self._rx_file_handle is file_handle: + self._rx_file_handle = None + + def _compute_rx_level(self, data: bytes) -> int: + """Compute a gain-tolerant 0-100 signal activity score from raw IQ bytes.""" + if not data: + return 0 + try: + samples = np.frombuffer(data, dtype=np.int8).astype(np.float32) + if samples.size < 2: + return 0 + i_vals = samples[0::2] + q_vals = samples[1::2] + if i_vals.size == 0 or q_vals.size == 0: + return 0 + i_vals = i_vals[::4] + q_vals = q_vals[::4] + if i_vals.size == 0 or q_vals.size == 0: + return 0 + mag = np.sqrt(i_vals * i_vals + q_vals * q_vals) + if mag.size == 0: + return 0 + + noise = float(np.percentile(mag, 30)) + signal = float(np.percentile(mag, 90)) + peak = float(np.percentile(mag, 99)) + contrast = max(0.0, signal - noise) + crest = max(0.0, peak - signal) + mean_mag = float(np.mean(mag)) + + # Normalize by local floor so changing gain is less likely to break + # burst visibility (low gain still detectable, high gain not always "on"). + contrast_norm = contrast / max(8.0, noise + 8.0) + crest_norm = crest / max(8.0, signal + 8.0) + energy_norm = mean_mag / 60.0 + level_f = (contrast_norm * 55.0) + (crest_norm * 20.0) + (energy_norm * 10.0) + level = int(max(0, min(100, level_f))) + if level == 0 and contrast > 0.5: + level = 1 + return level + except Exception: + return 0 + + def _extract_waveform(self, data: bytes, points: int = 256) -> list[float]: + """Extract a normalized envelope waveform for UI display.""" + try: + samples = np.frombuffer(data, dtype=np.int8).astype(np.float32) + if samples.size < 2: + return [] + i_vals = samples[0::2] + q_vals = samples[1::2] + if i_vals.size == 0 or q_vals.size == 0: + return [] + mag = np.sqrt(i_vals * i_vals + q_vals * q_vals) + if mag.size == 0: + return [] + step = max(1, mag.size // points) + scoped = mag[::step][:points] + if scoped.size == 0: + return [] + baseline = float(np.median(scoped)) + centered = scoped - baseline + scale = float(np.percentile(np.abs(centered), 95)) + if scale <= 1e-6: + normalized = np.zeros_like(centered) + else: + normalized = np.clip(centered / (scale * 2.5), -1.0, 1.0) + return [round(float(x), 3) for x in normalized.tolist()] + except Exception: + return [] + + def _compute_rx_spectrum(self, data: bytes, bins: int = 256) -> list[int]: + """Compute a simple FFT magnitude slice for waterfall rendering.""" + try: + samples = np.frombuffer(data, dtype=np.int8) + if samples.size < bins * 2: + return [] + fft_size = max(256, bins) + needed = fft_size * 2 + if samples.size < needed: + return [] + samples = samples[:needed].astype(np.float32) + i_vals = samples[0::2] + q_vals = samples[1::2] + iq = i_vals + 1j * q_vals + window = np.hanning(fft_size) + spectrum = np.fft.fftshift(np.fft.fft(iq * window)) + mag = 20 * np.log10(np.abs(spectrum) + 1e-6) + mag -= np.max(mag) + # Map -60..0 dB range to 0..255 + scaled = np.clip((mag + 60.0) / 60.0, 0.0, 1.0) + bins_vals = (scaled * 255).astype(np.uint8) + if bins_vals.size != bins: + idx = np.linspace(0, bins_vals.size - 1, bins).astype(int) + bins_vals = bins_vals[idx] + return bins_vals.tolist() + except Exception: + return [] + + def _monitor_rx_stderr(self) -> None: + process = self._rx_process + if not process or not process.stderr: + return + try: + for line in iter(process.stderr.readline, b''): + text = line.decode('utf-8', errors='replace').strip() + if text: + logger.debug(f"[hackrf_rx] {text}") + if 'error' in text.lower(): + self._emit({'type': 'info', 'text': f'[hackrf_rx] {text}'}) + except Exception: + pass + + def stop_receive(self) -> dict: + thread_to_join: threading.Thread | None = None + file_handle: BinaryIO | None = None + with self._lock: + if not self._rx_process or self._rx_process.poll() is not None: + return {'status': 'not_running'} + + self._rx_stop = True + thread_to_join = self._rx_thread + self._rx_thread = None + file_handle = self._rx_file_handle + + safe_terminate(self._rx_process) + unregister_process(self._rx_process) + self._rx_process = None + + if thread_to_join and thread_to_join.is_alive(): + thread_to_join.join(timeout=2.0) + + if file_handle: + try: + file_handle.close() + except OSError: + pass + with self._lock: + if self._rx_file_handle is file_handle: + self._rx_file_handle = None + + duration = time.time() - self._rx_start_time if self._rx_start_time else 0 + iq_file = self._rx_file + + # Write JSON sidecar metadata + capture = None + if iq_file and iq_file.exists(): + bursts = list(self._rx_bursts) + duration, bursts = self._trim_capture_to_trigger_window( + iq_file=iq_file, + sample_rate=self._rx_sample_rate, + duration_seconds=duration, + bursts=bursts, + ) + size = iq_file.stat().st_size + dominant_fingerprint = '' + dominant_fingerprint_count = 0 + for fp, count in self._rx_fingerprint_counts.items(): + if count > dominant_fingerprint_count: + dominant_fingerprint = fp + dominant_fingerprint_count = count + + modulation_hint = self._rx_modulation_hint + modulation_confidence = float(self._rx_modulation_confidence or 0.0) + if not modulation_hint and bursts: + burst_hint_totals: dict[str, float] = {} + for burst in bursts: + hint_name = str(burst.get('modulation_hint') or '').strip() + hint_conf = float(burst.get('modulation_confidence') or 0.0) + if not hint_name or hint_name.lower() == 'unknown': + continue + burst_hint_totals[hint_name] = burst_hint_totals.get(hint_name, 0.0) + max(0.05, hint_conf) + if burst_hint_totals: + modulation_hint = max(burst_hint_totals, key=burst_hint_totals.get) + total_score = sum(burst_hint_totals.values()) + modulation_confidence = min( + 0.98, + burst_hint_totals[modulation_hint] / max(total_score, 0.001), + ) + + protocol_hint = self._protocol_hint_from_capture( + self._rx_frequency_hz, + modulation_hint, + len(bursts), + ) + label = self._auto_capture_label( + self._rx_frequency_hz, + len(bursts), + modulation_hint, + protocol_hint, + ) + capture_id = uuid.uuid4().hex[:12] + capture = SubGhzCapture( + capture_id=capture_id, + filename=iq_file.name, + frequency_hz=self._rx_frequency_hz, + sample_rate=self._rx_sample_rate, + lna_gain=self._rx_lna_gain, + vga_gain=self._rx_vga_gain, + timestamp=datetime.now(timezone.utc).isoformat(), + duration_seconds=round(duration, 1), + size_bytes=size, + label=label, + label_source='auto', + bursts=bursts, + modulation_hint=modulation_hint, + modulation_confidence=round(modulation_confidence, 3), + protocol_hint=protocol_hint, + dominant_fingerprint=dominant_fingerprint, + trigger_enabled=self._rx_trigger_enabled, + trigger_pre_seconds=round(self._rx_trigger_pre_s, 3), + trigger_post_seconds=round(self._rx_trigger_post_s, 3), + ) + meta_path = iq_file.with_suffix('.json') + try: + meta_path.write_text(json.dumps(capture.to_dict(), indent=2)) + except OSError as e: + logger.error(f"Failed to write capture metadata: {e}") + + with self._lock: + self._rx_file = None + self._rx_start_time = 0 + self._rx_bytes_written = 0 + self._rx_bursts = [] + self._rx_trigger_enabled = False + self._rx_trigger_first_burst_start = None + self._rx_trigger_last_burst_end = None + self._rx_autostop_pending = False + self._rx_modulation_hint = '' + self._rx_modulation_confidence = 0.0 + self._rx_protocol_hint = '' + self._rx_fingerprint_counts = {} + + self._emit({ + 'type': 'status', + 'mode': 'idle', + 'status': 'stopped', + 'duration_seconds': round(duration, 1), + }) + + result = {'status': 'stopped', 'duration_seconds': round(duration, 1)} + if capture: + result['capture'] = capture.to_dict() + return result + + # ------------------------------------------------------------------ + # DECODE (hackrf_transfer piped to rtl_433) + # ------------------------------------------------------------------ + + def start_decode( + self, + frequency_hz: int, + sample_rate: int = 2_000_000, + lna_gain: int = 32, + vga_gain: int = 20, + decode_profile: str = 'weather', + device_serial: str | None = None, + ) -> dict: + with self._lock: + if self.active_mode != 'idle': + return {'status': 'error', 'message': f'Already running: {self.active_mode}'} + + if not self.check_hackrf(): + return {'status': 'error', 'message': 'hackrf_transfer not found'} + if not self.check_rtl433(): + return {'status': 'error', 'message': 'rtl_433 not found'} + device_err = self._require_hackrf_device() + if device_err: + return {'status': 'error', 'message': device_err} + + # Keep decode bandwidth conservative for stability. 2 Msps is enough + # for common SubGHz protocols while staying within HackRF support. + requested_sample_rate = int(sample_rate) + stable_sample_rate = max(2_000_000, min(2_000_000, requested_sample_rate)) + + # Build hackrf_transfer command (producer: raw IQ to stdout) + hackrf_cmd = [ + 'hackrf_transfer', + '-r', '-', + '-f', str(frequency_hz), + '-s', str(stable_sample_rate), + '-l', str(max(SUBGHZ_LNA_GAIN_MIN, min(SUBGHZ_LNA_GAIN_MAX, lna_gain))), + '-g', str(max(SUBGHZ_VGA_GAIN_MIN, min(SUBGHZ_VGA_GAIN_MAX, vga_gain))), + ] + if device_serial: + hackrf_cmd.extend(['-d', device_serial]) + + # Build rtl_433 command (consumer: reads IQ from stdin) + # Feed signed 8-bit complex IQ directly from hackrf_transfer. + rtl433_cmd = [ + 'rtl_433', + '-r', 'cs8:-', + '-s', str(stable_sample_rate), + '-f', str(frequency_hz), + '-F', 'json', + '-F', 'log', + '-M', 'level', + '-M', 'noise:5', + '-Y', 'autolevel', + '-Y', 'ampest', + '-Y', 'minsnr=2.5', + ] + profile = (decode_profile or 'weather').strip().lower() + if profile == 'weather': + # Limit decoder set to weather/temperature/humidity/rain/wind + # protocols for better sensitivity and lower CPU load. + weather_protocol_ids = [ + 2, 3, 8, 12, 16, 18, 19, 20, 31, 32, 34, 40, 47, 50, 52, + 54, 55, 56, 57, 69, 73, 74, 75, 76, 78, 79, 85, 91, 92, + 108, 109, 111, 112, 113, 119, 120, 124, 127, 132, 133, + 134, 138, 141, 143, 144, 145, 146, 147, 152, 153, 157, + 158, 163, 165, 166, 170, 171, 172, 173, 175, 182, 183, + 184, 194, 195, 196, 205, 206, 213, 214, 215, 217, 219, + 221, 222, + ] + rtl433_cmd.extend(['-R', '0']) + for proto_id in weather_protocol_ids: + rtl433_cmd.extend(['-R', str(proto_id)]) + else: + profile = 'all' + + logger.info(f"SubGHz decode: {' '.join(hackrf_cmd)} | {' '.join(rtl433_cmd)}") + + try: + # Start hackrf_transfer (producer). stderr is consumed by a + # dedicated monitor thread so we can surface stream failures. + hackrf_proc = subprocess.Popen( + hackrf_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=0, + ) + register_process(hackrf_proc) + + # Start rtl_433 (consumer) + rtl433_proc = subprocess.Popen( + rtl433_cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=0, + ) + register_process(rtl433_proc) + + self._decode_hackrf_process = hackrf_proc + self._decode_process = rtl433_proc + self._decode_start_time = time.time() + self._decode_frequency_hz = frequency_hz + self._decode_sample_rate = stable_sample_rate + self._decode_stop = False + self._emit({'type': 'info', 'text': f'[decode] Profile: {profile}'}) + if requested_sample_rate != stable_sample_rate: + self._emit({ + 'type': 'info', + 'text': ( + f'[decode] Using {stable_sample_rate} sps ' + f'(requested {requested_sample_rate}) for stable live decode' + ), + }) + + # Buffered relay: hackrf stdout → queue → rtl_433 stdin + # with auto-restart when HackRF USB disconnects. + iq_queue: queue.Queue[bytes | None] = queue.Queue(maxsize=512) + + threading.Thread( + target=self._hackrf_reader, + args=(hackrf_cmd, rtl433_proc, iq_queue), + daemon=True, + ).start() + threading.Thread( + target=self._monitor_decode_hackrf_stderr, + args=(hackrf_proc,), + daemon=True, + ).start() + + threading.Thread( + target=self._rtl433_writer, + args=(rtl433_proc, iq_queue), + daemon=True, + ).start() + + # Read decoded JSON output from rtl_433 stdout + threading.Thread( + target=self._read_decode_output, + daemon=True, + ).start() + + # Monitor rtl_433 stderr + threading.Thread( + target=self._monitor_decode_stderr, + daemon=True, + ).start() + + self._emit({ + 'type': 'status', + 'mode': 'decode', + 'status': 'started', + 'frequency_hz': frequency_hz, + 'sample_rate': stable_sample_rate, + }) + + return { + 'status': 'started', + 'frequency_hz': frequency_hz, + 'sample_rate': stable_sample_rate, + } + + except FileNotFoundError as e: + if self._decode_hackrf_process: + safe_terminate(self._decode_hackrf_process) + unregister_process(self._decode_hackrf_process) + self._decode_hackrf_process = None + return {'status': 'error', 'message': f'Tool not found: {e.filename or "unknown"}'} + except Exception as e: + for proc in (self._decode_hackrf_process, self._decode_process): + if proc: + safe_terminate(proc) + unregister_process(proc) + self._decode_hackrf_process = None + self._decode_process = None + logger.error(f"Failed to start decode: {e}") + return {'status': 'error', 'message': str(e)} + + def _hackrf_reader( + self, + hackrf_cmd: list[str], + rtl433_proc: subprocess.Popen, + iq_queue: queue.Queue, + ) -> None: + """Read IQ from hackrf_transfer stdout into a queue, restarting on USB drops. + + Decouples HackRF USB reads from rtl_433 stdin writes so that any stall + in rtl_433 cannot back-pressure the USB transfer, which on macOS causes + the device to disconnect. + + Uses os.read() on the raw fd to drain the pipe immediately (no Python + buffering), minimising backpressure on the USB transfer path. + """ + CHUNK = 65536 # 64 KB read size for lower latency + RESTART_DELAY = 0.15 # seconds before restart attempt + MAX_RESTARTS = 3600 # allow longer sessions + MAX_QUICK_RESTARTS = 6 + QUICK_RESTART_WINDOW = 20.0 + + restart_times: list[float] = [] + first_chunk = True + + restarts = 0 + while not self._decode_stop: + if rtl433_proc.poll() is not None: + break + if self._decode_process is not rtl433_proc: + break + + hackrf_proc = self._decode_hackrf_process + src = hackrf_proc.stdout if hackrf_proc else None + + if not src or (hackrf_proc and hackrf_proc.poll() is not None): + if restarts >= MAX_RESTARTS: + logger.error("hackrf_transfer: max restarts reached") + self._emit({'type': 'error', 'message': 'HackRF: max restarts reached'}) + break + + # Unregister the dead process before restarting + if hackrf_proc: + unregister_process(hackrf_proc) + + time.sleep(RESTART_DELAY) + + # Re-check stop conditions after sleeping + if self._decode_stop: + break + if rtl433_proc.poll() is not None: + break + if self._decode_process is not rtl433_proc: + break + + with self._lock: + if self._decode_stop: + break + try: + hackrf_proc = subprocess.Popen( + hackrf_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=0, + ) + register_process(hackrf_proc) + self._decode_hackrf_process = hackrf_proc + src = hackrf_proc.stdout + restarts += 1 + now = time.time() + restart_times.append(now) + restart_times = [t for t in restart_times if (now - t) <= QUICK_RESTART_WINDOW] + if len(restart_times) >= MAX_QUICK_RESTARTS: + self._emit({ + 'type': 'error', + 'message': ( + 'HackRF stream is unstable (restarting repeatedly). ' + 'Try lower gain/sample-rate or reconnect the device.' + ), + }) + break + logger.info(f"hackrf_transfer restarted ({restarts})") + self._emit({'type': 'info', 'text': f'[decode] HackRF stream restarted ({restarts})'}) + threading.Thread( + target=self._monitor_decode_hackrf_stderr, + args=(hackrf_proc,), + daemon=True, + ).start() + except Exception as e: + logger.error(f"Failed to restart hackrf_transfer: {e}") + self._emit({ + 'type': 'error', + 'message': f'Failed to restart hackrf_transfer: {e}', + }) + break + + if not src: + break + + # Use raw fd reads to drain the pipe without Python buffering. + # This returns immediately with whatever bytes are available + # (up to CHUNK), avoiding the backpressure that buffered reads + # can cause when they block waiting for a full chunk. + try: + fd = src.fileno() + if not isinstance(fd, int) or fd < 0: + logger.error("Invalid file descriptor from hackrf stdout") + break + except (OSError, ValueError, TypeError): + break + + try: + while not self._decode_stop: + data = os.read(fd, CHUNK) + if not data: + if hackrf_proc and hackrf_proc.poll() is not None: + self._emit({'type': 'info', 'text': '[decode] HackRF stream stopped'}) + break + if first_chunk: + first_chunk = False + self._emit({'type': 'info', 'text': '[decode] IQ source active'}) + try: + iq_queue.put_nowait(data) + except queue.Full: + # Drop oldest chunk to prevent backpressure + logger.debug("IQ queue full, dropping oldest chunk") + try: + iq_queue.get_nowait() + except queue.Empty: + pass + try: + iq_queue.put_nowait(data) + except queue.Full: + pass + except OSError: + pass + + # Signal writer to stop + try: + iq_queue.put_nowait(None) + except queue.Full: + pass + + def _rtl433_writer( + self, + rtl433_proc: subprocess.Popen, + iq_queue: queue.Queue, + ) -> None: + """Drain the IQ queue into rtl_433 stdin.""" + dst = rtl433_proc.stdin + if not dst: + logger.error("rtl_433 stdin is None — cannot write IQ data") + return + + first_chunk = True + last_level = 0.0 + last_wave = 0.0 + last_spectrum = 0.0 + last_stats = time.time() + bytes_since_stats = 0 + LEVEL_INTERVAL = 0.35 + WAVE_INTERVAL = 0.5 + SPECTRUM_INTERVAL = 0.55 + STATS_INTERVAL = 6.0 + writes_since_flush = 0 + burst_active = False + burst_start = 0.0 + burst_last_high = 0.0 + burst_peak = 0 + BURST_ON_LEVEL = 9 + BURST_OFF_HOLD = 0.45 + BURST_MIN_DURATION = 0.05 + try: + while True: + try: + data = iq_queue.get(timeout=2.0) + except queue.Empty: + if rtl433_proc.poll() is not None: + break + continue + if data is None: + break + + now = time.time() + bytes_since_stats += len(data) + + if now - last_level >= LEVEL_INTERVAL: + level = self._compute_rx_level(data) + self._emit({'type': 'decode_level', 'level': level}) + if level >= BURST_ON_LEVEL: + burst_last_high = now + if not burst_active: + burst_active = True + burst_start = now + burst_peak = level + self._emit({ + 'type': 'rx_burst', + 'mode': 'decode', + 'event': 'start', + 'start_offset_s': round( + max(0.0, now - self._decode_start_time), 3 + ), + 'level': int(level), + }) + else: + burst_peak = max(burst_peak, level) + elif burst_active and (now - burst_last_high) >= BURST_OFF_HOLD: + duration = now - burst_start + if duration >= BURST_MIN_DURATION: + self._emit({ + 'type': 'rx_burst', + 'mode': 'decode', + 'event': 'end', + 'start_offset_s': round( + max(0.0, burst_start - self._decode_start_time), 3 + ), + 'duration_ms': int(duration * 1000), + 'peak_level': int(burst_peak), + }) + burst_active = False + burst_peak = 0 + last_level = now + + if now - last_wave >= WAVE_INTERVAL: + samples = self._extract_waveform(data, points=160) + if samples: + self._emit({'type': 'decode_waveform', 'samples': samples}) + last_wave = now + + if now - last_spectrum >= SPECTRUM_INTERVAL: + bins = self._compute_rx_spectrum(data, bins=128) + if bins: + self._emit({'type': 'decode_spectrum', 'bins': bins}) + last_spectrum = now + + # Pass HackRF cs8 IQ bytes through directly. + dst.write(data) + writes_since_flush += 1 + if writes_since_flush >= 8: + dst.flush() + writes_since_flush = 0 + + if first_chunk: + first_chunk = False + logger.info(f"IQ data flowing to rtl_433 ({len(data)} bytes)") + self._emit({ + 'type': 'info', + 'text': '[decode] Receiving IQ data from HackRF...', + }) + + elapsed = now - last_stats + if elapsed >= STATS_INTERVAL: + rate_kb = bytes_since_stats / elapsed / 1024 + self._emit({ + 'type': 'info', + 'text': f'[decode] IQ: {rate_kb:.0f} KB/s — listening for signals...', + }) + self._emit({ + 'type': 'decode_raw', + 'text': f'IQ stream active: {rate_kb:.0f} KB/s', + }) + bytes_since_stats = 0 + last_stats = now + + except (BrokenPipeError, OSError) as e: + logger.debug(f"rtl_433 writer pipe closed: {e}") + self._emit({'type': 'info', 'text': f'[decode] Writer pipe closed: {e}'}) + except Exception as e: + logger.error(f"rtl_433 writer error: {e}") + self._emit({'type': 'error', 'message': f'Decode writer error: {e}'}) + finally: + if burst_active: + duration = max(0.0, time.time() - burst_start) + if duration >= BURST_MIN_DURATION: + self._emit({ + 'type': 'rx_burst', + 'mode': 'decode', + 'event': 'end', + 'start_offset_s': round( + max(0.0, burst_start - self._decode_start_time), 3 + ), + 'duration_ms': int(duration * 1000), + 'peak_level': int(burst_peak), + }) + try: + dst.close() + except OSError: + pass + + def _read_decode_output(self) -> None: + process = self._decode_process + if not process or not process.stdout: + return + got_output = False + try: + for line in iter(process.stdout.readline, b''): + text = line.decode('utf-8', errors='replace').strip() + if not text: + continue + if not got_output: + got_output = True + logger.info("rtl_433 producing output") + try: + data = json.loads(text) + data['type'] = 'decode' + self._emit(data) + except json.JSONDecodeError: + self._emit({'type': 'decode_raw', 'text': text}) + except Exception as e: + logger.error(f"Error reading decode output: {e}") + finally: + rc = process.poll() + unregister_process(process) + if rc is not None and rc != 0 and rc != -15: + logger.warning(f"rtl_433 exited with code {rc}") + self._emit({ + 'type': 'info', + 'text': f'[rtl_433] Exited with code {rc}', + }) + with self._lock: + if self._decode_process is process: + self._decode_process = None + self._decode_frequency_hz = 0 + self._decode_sample_rate = 0 + self._decode_start_time = 0 + self._emit({ + 'type': 'status', + 'mode': 'idle', + 'status': 'decode_stopped', + }) + + def _monitor_decode_hackrf_stderr(self, process: subprocess.Popen) -> None: + if not process or not process.stderr: + return + fatal_disconnect_emitted = False + try: + for line in iter(process.stderr.readline, b''): + text = line.decode('utf-8', errors='replace').strip() + if not text: + continue + logger.debug(f"[hackrf_decode] {text}") + lower = text.lower() + if ( + not fatal_disconnect_emitted + and ( + 'no such device' in lower + or 'device not found' in lower + or 'disconnected' in lower + ) + ): + fatal_disconnect_emitted = True + self._hackrf_device_cache = False + self._hackrf_device_cache_ts = time.time() + self._decode_stop = True + self._emit({ + 'type': 'error', + 'message': ( + 'HackRF disconnected during decode. ' + 'Reconnect the device, then press Start again.' + ), + }) + if ( + 'error' in lower + or 'usb' in lower + or 'overflow' in lower + or 'underflow' in lower + or 'failed' in lower + or 'couldn' in lower + or 'transfer' in lower + ): + self._emit({'type': 'info', 'text': f'[hackrf] {text}'}) + except Exception: + pass + + def _monitor_decode_stderr(self) -> None: + process = self._decode_process + if not process or not process.stderr: + return + decode_keywords = ( + 'pulse', 'sync', 'message', 'decoded', 'snr', 'rssi', + 'level', 'modulation', 'bitbuffer', 'symbol', 'short', + 'noise', 'detected', + ) + try: + for line in iter(process.stderr.readline, b''): + text = line.decode('utf-8', errors='replace').strip() + if text: + logger.debug(f"[rtl_433] {text}") + self._emit({'type': 'info', 'text': f'[rtl_433] {text}'}) + if any(k in text.lower() for k in decode_keywords): + self._emit({'type': 'decode_raw', 'text': text}) + except Exception: + pass + + def stop_decode(self) -> dict: + with self._lock: + hackrf_running = ( + self._decode_hackrf_process + and self._decode_hackrf_process.poll() is None + ) + rtl433_running = ( + self._decode_process + and self._decode_process.poll() is None + ) + + if not hackrf_running and not rtl433_running: + return {'status': 'not_running'} + + # Signal reader thread to stop before killing processes, + # preventing it from spawning a new hackrf_transfer during cleanup. + self._decode_stop = True + + # Terminate upstream (hackrf_transfer) first, then consumer (rtl_433) + if self._decode_hackrf_process: + safe_terminate(self._decode_hackrf_process) + unregister_process(self._decode_hackrf_process) + self._decode_hackrf_process = None + + if self._decode_process: + safe_terminate(self._decode_process) + unregister_process(self._decode_process) + self._decode_process = None + + self._decode_frequency_hz = 0 + self._decode_sample_rate = 0 + self._decode_start_time = 0 + + # Clean up any hackrf_transfer spawned during the race window + time.sleep(0.1) + if self._decode_hackrf_process: + safe_terminate(self._decode_hackrf_process) + unregister_process(self._decode_hackrf_process) + self._decode_hackrf_process = None + + self._emit({ + 'type': 'status', + 'mode': 'idle', + 'status': 'stopped', + }) + + return {'status': 'stopped'} + + # ------------------------------------------------------------------ + # TRANSMIT (replay via hackrf_transfer -t) + # ------------------------------------------------------------------ + + @staticmethod + def validate_tx_frequency(frequency_hz: int) -> str | None: + """Validate that a frequency is within allowed ISM TX bands. + + Returns None if valid, or an error message if invalid. + """ + freq_mhz = frequency_hz / 1_000_000 + for band_low, band_high in SUBGHZ_TX_ALLOWED_BANDS: + if band_low <= freq_mhz <= band_high: + return None + bands_str = ', '.join( + f'{lo}-{hi} MHz' for lo, hi in SUBGHZ_TX_ALLOWED_BANDS + ) + return f'Frequency {freq_mhz:.3f} MHz is outside allowed TX bands: {bands_str}' + + @staticmethod + def _estimate_capture_duration_seconds(capture: SubGhzCapture, file_size: int) -> float: + if capture.duration_seconds and capture.duration_seconds > 0: + return float(capture.duration_seconds) + if capture.sample_rate > 0 and file_size > 0: + return float(file_size) / float(capture.sample_rate * 2) + return 0.0 + + def _cleanup_tx_temp_file(self) -> None: + path = self._tx_temp_file + self._tx_temp_file = None + if not path: + return + try: + if path.exists(): + path.unlink() + except OSError as exc: + logger.debug(f"Failed to remove TX temp file {path}: {exc}") + + def transmit( + self, + capture_id: str, + tx_gain: int = 20, + max_duration: int = 10, + start_seconds: float | None = None, + duration_seconds: float | None = None, + device_serial: str | None = None, + ) -> dict: + with self._lock: + if self.active_mode != 'idle': + return {'status': 'error', 'message': f'Already running: {self.active_mode}'} + + if not self.check_hackrf(): + return {'status': 'error', 'message': 'hackrf_transfer not found'} + device_err = self._require_hackrf_device() + if device_err: + return {'status': 'error', 'message': device_err} + + # Look up capture + capture = self._load_capture(capture_id) + if not capture: + return {'status': 'error', 'message': f'Capture not found: {capture_id}'} + + # Validate TX frequency + freq_error = self.validate_tx_frequency(capture.frequency_hz) + if freq_error: + return {'status': 'error', 'message': freq_error} + + # Enforce gain limit + tx_gain = max(SUBGHZ_TX_VGA_GAIN_MIN, min(SUBGHZ_TX_VGA_GAIN_MAX, tx_gain)) + + # Enforce max duration limit + max_duration = max(1, min(SUBGHZ_TX_MAX_DURATION, max_duration)) + + iq_path = self._captures_dir / capture.filename + if not iq_path.exists(): + return {'status': 'error', 'message': 'IQ file missing'} + + # Clear any orphaned temp segment from a previous TX attempt. + self._cleanup_tx_temp_file() + + tx_path = iq_path + segment_info = None + if start_seconds is not None or duration_seconds is not None: + try: + start_s = max(0.0, float(start_seconds or 0.0)) + except (TypeError, ValueError): + return {'status': 'error', 'message': 'Invalid start_seconds'} + try: + seg_s = None if duration_seconds is None else float(duration_seconds) + except (TypeError, ValueError): + return {'status': 'error', 'message': 'Invalid duration_seconds'} + if seg_s is not None and seg_s <= 0: + return {'status': 'error', 'message': 'duration_seconds must be greater than 0'} + + file_size = iq_path.stat().st_size + total_duration = self._estimate_capture_duration_seconds(capture, file_size) + if total_duration <= 0: + return {'status': 'error', 'message': 'Unable to determine capture duration for segment TX'} + if start_s >= total_duration: + return {'status': 'error', 'message': 'start_seconds is beyond end of capture'} + + end_s = total_duration if seg_s is None else min(total_duration, start_s + seg_s) + if end_s <= start_s: + return {'status': 'error', 'message': 'Selected segment is empty'} + + bytes_per_second = max(2, int(capture.sample_rate) * 2) + start_byte = int(start_s * bytes_per_second) & ~1 + end_byte = int(end_s * bytes_per_second) & ~1 + if end_byte <= start_byte: + return {'status': 'error', 'message': 'Selected segment is too short'} + + segment_size = end_byte - start_byte + segment_name = f".txseg_{capture.capture_id}_{uuid.uuid4().hex[:8]}.iq" + segment_path = self._captures_dir / segment_name + try: + with open(iq_path, 'rb') as src, open(segment_path, 'wb') as dst: + src.seek(start_byte) + remaining = segment_size + while remaining > 0: + chunk = src.read(min(262144, remaining)) + if not chunk: + break + dst.write(chunk) + remaining -= len(chunk) + written = segment_path.stat().st_size if segment_path.exists() else 0 + except OSError as exc: + logger.error(f"Failed to build TX segment: {exc}") + return {'status': 'error', 'message': 'Failed to create TX segment'} + + if written < 2: + try: + segment_path.unlink(missing_ok=True) # type: ignore[arg-type] + except Exception: + pass + return {'status': 'error', 'message': 'Selected TX segment has no IQ data'} + + tx_path = segment_path + self._tx_temp_file = segment_path + segment_info = { + 'start_seconds': round(start_s, 3), + 'duration_seconds': round(written / bytes_per_second, 3), + 'bytes': int(written), + } + + cmd = [ + 'hackrf_transfer', + '-t', str(tx_path), + '-f', str(capture.frequency_hz), + '-s', str(capture.sample_rate), + '-x', str(tx_gain), + ] + if device_serial: + cmd.extend(['-d', device_serial]) + + logger.info(f"SubGHz TX: {' '.join(cmd)}") + + try: + self._tx_process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + register_process(self._tx_process) + self._tx_start_time = time.time() + self._tx_capture_id = capture_id + + # Start watchdog timer + self._tx_watchdog = threading.Timer( + max_duration, self._tx_watchdog_kill + ) + self._tx_watchdog.daemon = True + self._tx_watchdog.start() + + # Monitor TX process + threading.Thread( + target=self._monitor_tx, + daemon=True, + ).start() + + self._emit({ + 'type': 'tx_status', + 'status': 'transmitting', + 'capture_id': capture_id, + 'frequency_hz': capture.frequency_hz, + 'max_duration': max_duration, + 'segment': segment_info, + }) + + return { + 'status': 'transmitting', + 'capture_id': capture_id, + 'frequency_hz': capture.frequency_hz, + 'max_duration': max_duration, + 'segment': segment_info, + } + + except FileNotFoundError: + self._cleanup_tx_temp_file() + return {'status': 'error', 'message': 'hackrf_transfer not found'} + except Exception as e: + self._cleanup_tx_temp_file() + logger.error(f"Failed to start TX: {e}") + return {'status': 'error', 'message': str(e)} + + def _tx_watchdog_kill(self) -> None: + """Kill TX process when max duration is exceeded.""" + logger.warning("SubGHz TX watchdog triggered - killing transmission") + self.stop_transmit() + + def _monitor_tx(self) -> None: + process = self._tx_process + if not process: + return + try: + returncode = process.wait() + except Exception: + returncode = -1 + with self._lock: + # Only emit if this is still the active TX process + if self._tx_process is not process: + return + unregister_process(process) + duration = time.time() - self._tx_start_time if self._tx_start_time else 0 + if returncode and returncode != 0 and returncode != -15: + # Non-zero exit (not SIGTERM) means unexpected death + logger.warning(f"hackrf_transfer TX exited unexpectedly (rc={returncode})") + self._emit({ + 'type': 'error', + 'message': f'Transmission failed (hackrf_transfer exited with code {returncode})', + }) + self._tx_process = None + self._tx_start_time = 0 + self._tx_capture_id = '' + self._emit({ + 'type': 'tx_status', + 'status': 'tx_complete', + 'duration_seconds': round(duration, 1), + }) + if self._tx_watchdog: + self._tx_watchdog.cancel() + self._tx_watchdog = None + self._cleanup_tx_temp_file() + + def stop_transmit(self) -> dict: + with self._lock: + if self._tx_watchdog: + self._tx_watchdog.cancel() + self._tx_watchdog = None + + if not self._tx_process or self._tx_process.poll() is not None: + self._cleanup_tx_temp_file() + return {'status': 'not_running'} + + safe_terminate(self._tx_process) + unregister_process(self._tx_process) + self._tx_process = None + duration = time.time() - self._tx_start_time if self._tx_start_time else 0 + self._tx_start_time = 0 + self._tx_capture_id = '' + self._cleanup_tx_temp_file() + + self._emit({ + 'type': 'tx_status', + 'status': 'tx_stopped', + 'duration_seconds': round(duration, 1), + }) + + return {'status': 'stopped', 'duration_seconds': round(duration, 1)} + + # ------------------------------------------------------------------ + # SWEEP (hackrf_sweep) + # ------------------------------------------------------------------ + + def start_sweep( + self, + freq_start_mhz: float = 300.0, + freq_end_mhz: float = 928.0, + bin_width: int = 100000, + device_serial: str | None = None, + ) -> dict: + with self._lock: + if self.active_mode != 'idle': + return {'status': 'error', 'message': f'Already running: {self.active_mode}'} + + if not self.check_sweep(): + return {'status': 'error', 'message': 'hackrf_sweep not found'} + device_err = self._require_hackrf_device() + if device_err: + return {'status': 'error', 'message': device_err} + + cmd = [ + 'hackrf_sweep', + '-f', f'{int(freq_start_mhz)}:{int(freq_end_mhz)}', + '-w', str(bin_width), + ] + if device_serial: + cmd.extend(['-d', device_serial]) + + logger.info(f"SubGHz sweep: {' '.join(cmd)}") + + # Wait for previous sweep thread to exit + if self._sweep_thread and self._sweep_thread.is_alive(): + self._sweep_thread.join(timeout=2.0) + if self._sweep_thread.is_alive(): + return {'status': 'error', 'message': 'Previous sweep still shutting down'} + + try: + self._sweep_process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + register_process(self._sweep_process) + self._sweep_running = True + + # Sweep reader with auto-restart on USB drops + self._sweep_thread = threading.Thread( + target=self._sweep_loop, + args=(cmd,), + daemon=True, + ) + self._sweep_thread.start() + + self._emit({ + 'type': 'status', + 'mode': 'sweep', + 'status': 'started', + 'freq_start_mhz': freq_start_mhz, + 'freq_end_mhz': freq_end_mhz, + }) + + return { + 'status': 'started', + 'freq_start_mhz': freq_start_mhz, + 'freq_end_mhz': freq_end_mhz, + } + + except FileNotFoundError: + return {'status': 'error', 'message': 'hackrf_sweep not found'} + except Exception as e: + logger.error(f"Failed to start sweep: {e}") + return {'status': 'error', 'message': str(e)} + + def _sweep_loop(self, cmd: list[str]) -> None: + """Run hackrf_sweep with auto-restart on USB drops.""" + RESTART_DELAY = 0.5 + MAX_RESTARTS = 600 + + restarts = 0 + while self._sweep_running: + self._parse_sweep_stdout() + + # Process exited — restart if allowed + if not self._sweep_running: + break + if restarts >= MAX_RESTARTS: + logger.error("hackrf_sweep: max restarts reached") + self._emit({'type': 'error', 'message': 'HackRF sweep: max restarts reached'}) + break + + time.sleep(RESTART_DELAY) + if not self._sweep_running: + break + + try: + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + register_process(proc) + self._sweep_process = proc + restarts += 1 + logger.info(f"hackrf_sweep restarted ({restarts})") + except Exception as e: + logger.error(f"Failed to restart hackrf_sweep: {e}") + break + + self._sweep_running = False + self._emit({ + 'type': 'status', + 'mode': 'idle', + 'status': 'sweep_stopped', + }) + + def _parse_sweep_stdout(self) -> None: + """Parse hackrf_sweep CSV output into SweepPoint events. + + hackrf_sweep CSV format: + date, time, hz_low, hz_high, hz_bin_width, num_samples, dB, dB, dB, ... + """ + process = self._sweep_process + if not process or not process.stdout: + return + try: + for line in iter(process.stdout.readline, b''): + if not self._sweep_running: + break + text = line.decode('utf-8', errors='replace').strip() + if not text: + continue + try: + parts = text.split(',') + if len(parts) < 7: + continue + hz_low = float(parts[2].strip()) + hz_high = float(parts[3].strip()) + hz_bin_width = float(parts[4].strip()) + powers = [float(p.strip()) for p in parts[6:] if p.strip()] + if not powers or hz_bin_width <= 0: + continue + + points = [] + for i, power in enumerate(powers): + freq_hz = hz_low + i * hz_bin_width + points.append({ + 'freq': round(freq_hz / 1_000_000, 4), + 'power': round(power, 1), + }) + + self._emit({ + 'type': 'sweep', + 'points': points, + }) + except Exception as exc: + logger.debug(f"Skipping malformed sweep line: {exc}") + continue + except Exception as e: + logger.error(f"Error reading sweep output: {e}") + + def stop_sweep(self) -> dict: + with self._lock: + self._sweep_running = False + if not self._sweep_process or self._sweep_process.poll() is not None: + return {'status': 'not_running'} + + safe_terminate(self._sweep_process) + unregister_process(self._sweep_process) + self._sweep_process = None + + # Join sweep thread outside the lock to avoid blocking other operations + if self._sweep_thread and self._sweep_thread.is_alive(): + self._sweep_thread.join(timeout=2.0) + + self._emit({ + 'type': 'status', + 'mode': 'idle', + 'status': 'stopped', + }) + + return {'status': 'stopped'} + + # ------------------------------------------------------------------ + # CAPTURE LIBRARY + # ------------------------------------------------------------------ + + def list_captures(self) -> list[SubGhzCapture]: + captures = [] + for meta_path in sorted(self._captures_dir.glob('*.json'), reverse=True): + try: + data = json.loads(meta_path.read_text()) + bursts = data.get('bursts', []) + dominant_fingerprint = data.get('dominant_fingerprint', '') + if not dominant_fingerprint and isinstance(bursts, list): + fp_counts: dict[str, int] = {} + for burst in bursts: + fp = '' + if isinstance(burst, dict): + fp = str(burst.get('fingerprint') or '').strip() + if not fp: + continue + fp_counts[fp] = fp_counts.get(fp, 0) + 1 + if fp_counts: + dominant_fingerprint = max(fp_counts, key=fp_counts.get) + captures.append(SubGhzCapture( + capture_id=data['id'], + filename=data['filename'], + frequency_hz=data['frequency_hz'], + sample_rate=data['sample_rate'], + lna_gain=data.get('lna_gain', 0), + vga_gain=data.get('vga_gain', 0), + timestamp=data['timestamp'], + duration_seconds=data.get('duration_seconds', 0), + size_bytes=data.get('size_bytes', 0), + label=data.get('label', ''), + label_source=data.get('label_source', ''), + decoded_protocols=data.get('decoded_protocols', []), + bursts=bursts, + modulation_hint=data.get('modulation_hint', ''), + modulation_confidence=data.get('modulation_confidence', 0.0), + protocol_hint=data.get('protocol_hint', ''), + dominant_fingerprint=dominant_fingerprint, + fingerprint_group=data.get('fingerprint_group', ''), + fingerprint_group_size=data.get('fingerprint_group_size', 0), + trigger_enabled=bool(data.get('trigger_enabled', False)), + trigger_pre_seconds=data.get('trigger_pre_seconds', 0.0), + trigger_post_seconds=data.get('trigger_post_seconds', 0.0), + )) + except (json.JSONDecodeError, KeyError, OSError) as e: + logger.debug(f"Skipping invalid capture metadata {meta_path}: {e}") + + # Auto-group repeated fingerprints as likely same button/device clusters. + fingerprint_groups: dict[str, list[SubGhzCapture]] = {} + for capture in captures: + fp = (capture.dominant_fingerprint or '').strip().lower() + if not fp: + continue + fingerprint_groups.setdefault(fp, []).append(capture) + for fp, grouped in fingerprint_groups.items(): + group_id = f"SIG-{fp[:6].upper()}" + for capture in grouped: + capture.fingerprint_group = group_id + capture.fingerprint_group_size = len(grouped) + + return captures + + def _load_capture(self, capture_id: str) -> SubGhzCapture | None: + for meta_path in self._captures_dir.glob('*.json'): + try: + data = json.loads(meta_path.read_text()) + if data.get('id') == capture_id: + bursts = data.get('bursts', []) + dominant_fingerprint = data.get('dominant_fingerprint', '') + if not dominant_fingerprint and isinstance(bursts, list): + fp_counts: dict[str, int] = {} + for burst in bursts: + fp = '' + if isinstance(burst, dict): + fp = str(burst.get('fingerprint') or '').strip() + if not fp: + continue + fp_counts[fp] = fp_counts.get(fp, 0) + 1 + if fp_counts: + dominant_fingerprint = max(fp_counts, key=fp_counts.get) + return SubGhzCapture( + capture_id=data['id'], + filename=data['filename'], + frequency_hz=data['frequency_hz'], + sample_rate=data['sample_rate'], + lna_gain=data.get('lna_gain', 0), + vga_gain=data.get('vga_gain', 0), + timestamp=data['timestamp'], + duration_seconds=data.get('duration_seconds', 0), + size_bytes=data.get('size_bytes', 0), + label=data.get('label', ''), + label_source=data.get('label_source', ''), + decoded_protocols=data.get('decoded_protocols', []), + bursts=bursts, + modulation_hint=data.get('modulation_hint', ''), + modulation_confidence=data.get('modulation_confidence', 0.0), + protocol_hint=data.get('protocol_hint', ''), + dominant_fingerprint=dominant_fingerprint, + fingerprint_group=data.get('fingerprint_group', ''), + fingerprint_group_size=data.get('fingerprint_group_size', 0), + trigger_enabled=bool(data.get('trigger_enabled', False)), + trigger_pre_seconds=data.get('trigger_pre_seconds', 0.0), + trigger_post_seconds=data.get('trigger_post_seconds', 0.0), + ) + except (json.JSONDecodeError, KeyError, OSError): + continue + return None + + def get_capture(self, capture_id: str) -> SubGhzCapture | None: + return self._load_capture(capture_id) + + def get_capture_path(self, capture_id: str) -> Path | None: + capture = self._load_capture(capture_id) + if not capture: + return None + path = self._captures_dir / capture.filename + if path.exists(): + return path + return None + + def trim_capture( + self, + capture_id: str, + start_seconds: float | None = None, + duration_seconds: float | None = None, + label: str = '', + ) -> dict: + """Create a trimmed capture from a selected IQ time window. + + If start/duration are omitted and burst markers exist, the strongest burst + window is selected automatically with short padding. + """ + with self._lock: + if self.active_mode != 'idle': + return {'status': 'error', 'message': f'Already running: {self.active_mode}'} + + capture = self._load_capture(capture_id) + if not capture: + return {'status': 'error', 'message': f'Capture not found: {capture_id}'} + + src_path = self._captures_dir / capture.filename + if not src_path.exists(): + return {'status': 'error', 'message': 'IQ file missing'} + + try: + src_size = src_path.stat().st_size + except OSError: + return {'status': 'error', 'message': 'Unable to read capture file'} + if src_size < 2: + return {'status': 'error', 'message': 'Capture file has no IQ data'} + + total_duration = self._estimate_capture_duration_seconds(capture, src_size) + if total_duration <= 0: + return {'status': 'error', 'message': 'Unable to determine capture duration'} + + use_auto_burst = start_seconds is None and duration_seconds is None + auto_pad = 0.06 + if use_auto_burst: + bursts = capture.bursts if isinstance(capture.bursts, list) else [] + best_burst: dict | None = None + for burst in bursts: + if not isinstance(burst, dict): + continue + dur = float(burst.get('duration_seconds', 0.0) or 0.0) + if dur <= 0: + continue + if best_burst is None: + best_burst = burst + continue + best_peak = float(best_burst.get('peak_level', 0.0) or 0.0) + cur_peak = float(burst.get('peak_level', 0.0) or 0.0) + if cur_peak > best_peak: + best_burst = burst + elif cur_peak == best_peak and dur > float(best_burst.get('duration_seconds', 0.0) or 0.0): + best_burst = burst + + if best_burst: + burst_start = max(0.0, float(best_burst.get('start_seconds', 0.0) or 0.0)) + burst_dur = max(0.0, float(best_burst.get('duration_seconds', 0.0) or 0.0)) + start_seconds = max(0.0, burst_start - auto_pad) + end_seconds = min(total_duration, burst_start + burst_dur + auto_pad) + duration_seconds = max(0.0, end_seconds - start_seconds) + else: + return { + 'status': 'error', + 'message': 'No burst markers available. Select a segment manually before trimming.', + } + + try: + start_s = max(0.0, float(start_seconds or 0.0)) + except (TypeError, ValueError): + return {'status': 'error', 'message': 'Invalid start_seconds'} + try: + seg_s = None if duration_seconds is None else float(duration_seconds) + except (TypeError, ValueError): + return {'status': 'error', 'message': 'Invalid duration_seconds'} + + if seg_s is not None and seg_s <= 0: + return {'status': 'error', 'message': 'duration_seconds must be greater than 0'} + if start_s >= total_duration: + return {'status': 'error', 'message': 'start_seconds is beyond end of capture'} + + end_s = total_duration if seg_s is None else min(total_duration, start_s + seg_s) + if end_s <= start_s: + return {'status': 'error', 'message': 'Selected segment is empty'} + + bytes_per_second = max(2, int(capture.sample_rate) * 2) + start_byte = int(start_s * bytes_per_second) & ~1 + end_byte = int(end_s * bytes_per_second) & ~1 + if end_byte <= start_byte: + return {'status': 'error', 'message': 'Selected segment is too short'} + + trim_size = end_byte - start_byte + source_stem = Path(capture.filename).stem + trim_name = f"{source_stem}_trim_{datetime.now().strftime('%H%M%S')}_{uuid.uuid4().hex[:4]}.iq" + trim_path = self._captures_dir / trim_name + try: + with open(src_path, 'rb') as src, open(trim_path, 'wb') as dst: + src.seek(start_byte) + remaining = trim_size + while remaining > 0: + chunk = src.read(min(262144, remaining)) + if not chunk: + break + dst.write(chunk) + remaining -= len(chunk) + written = trim_path.stat().st_size if trim_path.exists() else 0 + except OSError as exc: + logger.error(f"Failed to create trimmed capture: {exc}") + try: + trim_path.unlink(missing_ok=True) # type: ignore[arg-type] + except Exception: + pass + return {'status': 'error', 'message': 'Failed to write trimmed capture'} + + if written < 2: + try: + trim_path.unlink(missing_ok=True) # type: ignore[arg-type] + except Exception: + pass + return {'status': 'error', 'message': 'Trimmed capture has no IQ data'} + + trimmed_duration = round(written / bytes_per_second, 3) + + adjusted_bursts: list[dict] = [] + if isinstance(capture.bursts, list): + for burst in capture.bursts: + if not isinstance(burst, dict): + continue + burst_start = max(0.0, float(burst.get('start_seconds', 0.0) or 0.0)) + burst_dur = max(0.0, float(burst.get('duration_seconds', 0.0) or 0.0)) + burst_end = burst_start + burst_dur + overlap_start = max(start_s, burst_start) + overlap_end = min(end_s, burst_end) + overlap_dur = overlap_end - overlap_start + if overlap_dur <= 0: + continue + adjusted = dict(burst) + adjusted['start_seconds'] = round(overlap_start - start_s, 3) + adjusted['duration_seconds'] = round(overlap_dur, 3) + adjusted_bursts.append(adjusted) + + dominant_fingerprint = '' + fp_counts: dict[str, int] = {} + for burst in adjusted_bursts: + fp = str(burst.get('fingerprint') or '').strip() + if not fp: + continue + fp_counts[fp] = fp_counts.get(fp, 0) + 1 + if fp_counts: + dominant_fingerprint = max(fp_counts, key=fp_counts.get) + elif capture.dominant_fingerprint: + dominant_fingerprint = capture.dominant_fingerprint + + modulation_hint = capture.modulation_hint + modulation_confidence = float(capture.modulation_confidence or 0.0) + if adjusted_bursts: + hint_totals: dict[str, float] = {} + for burst in adjusted_bursts: + hint = str(burst.get('modulation_hint') or '').strip() + conf = float(burst.get('modulation_confidence') or 0.0) + if not hint or hint.lower() == 'unknown': + continue + hint_totals[hint] = hint_totals.get(hint, 0.0) + max(0.05, conf) + if hint_totals: + modulation_hint = max(hint_totals, key=hint_totals.get) + total_score = max(sum(hint_totals.values()), 0.001) + modulation_confidence = min(0.98, hint_totals[modulation_hint] / total_score) + + protocol_hint = self._protocol_hint_from_capture( + capture.frequency_hz, + modulation_hint, + len(adjusted_bursts), + ) + + manual_label = str(label or '').strip() + if manual_label: + capture_label = manual_label + label_source = 'manual' + elif capture.label: + capture_label = f'{capture.label} (Trim)' + label_source = 'auto' + else: + capture_label = self._auto_capture_label( + capture.frequency_hz, + len(adjusted_bursts), + modulation_hint, + protocol_hint, + ) + ' (Trim)' + label_source = 'auto' + + trimmed_capture = SubGhzCapture( + capture_id=uuid.uuid4().hex[:12], + filename=trim_path.name, + frequency_hz=capture.frequency_hz, + sample_rate=capture.sample_rate, + lna_gain=capture.lna_gain, + vga_gain=capture.vga_gain, + timestamp=datetime.now(timezone.utc).isoformat(), + duration_seconds=round(trimmed_duration, 3), + size_bytes=int(written), + label=capture_label, + label_source=label_source, + decoded_protocols=list(capture.decoded_protocols), + bursts=adjusted_bursts, + modulation_hint=modulation_hint, + modulation_confidence=round(modulation_confidence, 3), + protocol_hint=protocol_hint, + dominant_fingerprint=dominant_fingerprint, + trigger_enabled=False, + trigger_pre_seconds=0.0, + trigger_post_seconds=0.0, + ) + + meta_path = trim_path.with_suffix('.json') + try: + meta_path.write_text(json.dumps(trimmed_capture.to_dict(), indent=2)) + except OSError as exc: + logger.error(f"Failed to write trimmed capture metadata: {exc}") + try: + trim_path.unlink(missing_ok=True) # type: ignore[arg-type] + except Exception: + pass + return {'status': 'error', 'message': 'Failed to write trimmed capture metadata'} + + return { + 'status': 'ok', + 'capture': trimmed_capture.to_dict(), + 'source_capture_id': capture_id, + 'segment': { + 'start_seconds': round(start_s, 3), + 'duration_seconds': round(trimmed_duration, 3), + 'auto_selected': bool(use_auto_burst), + }, + } + + def delete_capture(self, capture_id: str) -> bool: + capture = self._load_capture(capture_id) + if not capture: + return False + + iq_path = self._captures_dir / capture.filename + meta_path = iq_path.with_suffix('.json') + + deleted = False + for path in (iq_path, meta_path): + if path.exists(): + try: + path.unlink() + deleted = True + except OSError as e: + logger.error(f"Failed to delete {path}: {e}") + return deleted + + def update_capture_label(self, capture_id: str, label: str) -> bool: + for meta_path in self._captures_dir.glob('*.json'): + try: + data = json.loads(meta_path.read_text()) + if data.get('id') == capture_id: + data['label'] = label + data['label_source'] = 'manual' if label else data.get('label_source', '') + meta_path.write_text(json.dumps(data, indent=2)) + return True + except (json.JSONDecodeError, KeyError, OSError): + continue + return False + + # ------------------------------------------------------------------ + # STOP ALL + # ------------------------------------------------------------------ + + def stop_all(self) -> None: + """Stop any running SubGHz process.""" + rx_thread: threading.Thread | None = None + sweep_thread: threading.Thread | None = None + rx_file_handle: BinaryIO | None = None + + with self._lock: + self._decode_stop = True + self._sweep_running = False + self._rx_stop = True + + if self._tx_watchdog: + self._tx_watchdog.cancel() + self._tx_watchdog = None + + for proc_attr in ( + '_rx_process', + '_decode_hackrf_process', + '_decode_process', + '_tx_process', + '_sweep_process', + ): + process = getattr(self, proc_attr, None) + if process and process.poll() is None: + safe_terminate(process) + unregister_process(process) + setattr(self, proc_attr, None) + + rx_thread = self._rx_thread + self._rx_thread = None + sweep_thread = self._sweep_thread + self._sweep_thread = None + rx_file_handle = self._rx_file_handle + self._rx_file_handle = None + + self._cleanup_tx_temp_file() + self._rx_file = None + self._tx_capture_id = '' + + self._rx_start_time = 0 + self._rx_bytes_written = 0 + self._rx_bursts = [] + self._rx_trigger_enabled = False + self._rx_trigger_first_burst_start = None + self._rx_trigger_last_burst_end = None + self._rx_autostop_pending = False + self._rx_modulation_hint = '' + self._rx_modulation_confidence = 0.0 + self._rx_protocol_hint = '' + self._rx_fingerprint_counts = {} + self._tx_start_time = 0 + self._decode_start_time = 0 + self._decode_frequency_hz = 0 + self._decode_sample_rate = 0 + + if rx_thread and rx_thread.is_alive(): + rx_thread.join(timeout=1.5) + if sweep_thread and sweep_thread.is_alive(): + sweep_thread.join(timeout=1.5) + + if rx_file_handle: + try: + rx_file_handle.close() + except OSError: + pass + + +# Global singleton +_manager: SubGhzManager | None = None +_manager_lock = threading.Lock() + + +def get_subghz_manager() -> SubGhzManager: + """Get or create the global SubGhzManager singleton.""" + global _manager + if _manager is None: + with _manager_lock: + if _manager is None: + _manager = SubGhzManager() + return _manager diff --git a/utils/weather_sat.py b/utils/weather_sat.py index 347c0a9..78fd7e8 100644 --- a/utils/weather_sat.py +++ b/utils/weather_sat.py @@ -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