diff --git a/Dockerfile b/Dockerfile index b5c8904..1d0b789 100644 --- a/Dockerfile +++ b/Dockerfile @@ -200,6 +200,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && make install \ && ldconfig \ && rm -rf /tmp/hackrf \ + # Install radiosonde_auto_rx (weather balloon decoder) + && cd /tmp \ + && git clone --depth 1 https://github.com/projecthorus/radiosonde_auto_rx.git \ + && cd radiosonde_auto_rx/auto_rx \ + && pip install --no-cache-dir -r requirements.txt \ + && bash build.sh \ + && mkdir -p /opt/radiosonde_auto_rx/auto_rx \ + && cp -r . /opt/radiosonde_auto_rx/auto_rx/ \ + && chmod +x /opt/radiosonde_auto_rx/auto_rx/auto_rx.py \ + && cd /tmp \ + && rm -rf /tmp/radiosonde_auto_rx \ # Build rtlamr (utility meter decoder - requires Go) && cd /tmp \ && curl -fsSL "https://go.dev/dl/go1.22.5.linux-$(dpkg --print-architecture).tar.gz" | tar -C /usr/local -xz \ @@ -246,7 +257,7 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . # Create data directory for persistence -RUN mkdir -p /app/data /app/data/weather_sat +RUN mkdir -p /app/data /app/data/weather_sat /app/data/radiosonde/logs # Expose web interface port EXPOSE 5050 diff --git a/app.py b/app.py index 1584e83..9fedb75 100644 --- a/app.py +++ b/app.py @@ -198,6 +198,11 @@ tscm_lock = threading.Lock() subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) subghz_lock = threading.Lock() +# Radiosonde weather balloon tracking +radiosonde_process = None +radiosonde_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) +radiosonde_lock = threading.Lock() + # CW/Morse code decoder morse_process = None morse_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) @@ -257,12 +262,12 @@ cleanup_manager.register(deauth_alerts) # SDR DEVICE REGISTRY # ============================================ # Tracks which mode is using which SDR device to prevent conflicts -# Key: device_index (int), Value: mode_name (str) -sdr_device_registry: dict[int, str] = {} +# Key: "sdr_type:device_index" (str), Value: mode_name (str) +sdr_device_registry: dict[str, str] = {} sdr_device_registry_lock = threading.Lock() -def claim_sdr_device(device_index: int, mode_name: str) -> str | None: +def claim_sdr_device(device_index: int, mode_name: str, sdr_type: str = 'rtlsdr') -> str | None: """Claim an SDR device for a mode. Checks the in-app registry first, then probes the USB device to @@ -272,43 +277,48 @@ def claim_sdr_device(device_index: int, mode_name: str) -> str | None: Args: device_index: The SDR device index to claim mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr') + sdr_type: SDR type string (e.g., 'rtlsdr', 'hackrf', 'limesdr') Returns: Error message if device is in use, None if successfully claimed """ + key = f"{sdr_type}:{device_index}" with sdr_device_registry_lock: - if device_index in sdr_device_registry: - in_use_by = sdr_device_registry[device_index] - return f'SDR device {device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.' + if key in sdr_device_registry: + in_use_by = sdr_device_registry[key] + return f'SDR device {sdr_type}:{device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.' # Probe the USB device to catch external processes holding the handle - try: - from utils.sdr.detection import probe_rtlsdr_device - usb_error = probe_rtlsdr_device(device_index) - if usb_error: - return usb_error - except Exception: - pass # If probe fails, let the caller proceed normally + if sdr_type == 'rtlsdr': + try: + from utils.sdr.detection import probe_rtlsdr_device + usb_error = probe_rtlsdr_device(device_index) + if usb_error: + return usb_error + except Exception: + pass # If probe fails, let the caller proceed normally - sdr_device_registry[device_index] = mode_name + sdr_device_registry[key] = mode_name return None -def release_sdr_device(device_index: int) -> None: +def release_sdr_device(device_index: int, sdr_type: str = 'rtlsdr') -> None: """Release an SDR device from the registry. Args: device_index: The SDR device index to release + sdr_type: SDR type string (e.g., 'rtlsdr', 'hackrf', 'limesdr') """ + key = f"{sdr_type}:{device_index}" with sdr_device_registry_lock: - sdr_device_registry.pop(device_index, None) + sdr_device_registry.pop(key, None) -def get_sdr_device_status() -> dict[int, str]: +def get_sdr_device_status() -> dict[str, str]: """Get current SDR device allocations. Returns: - Dictionary mapping device indices to mode names + Dictionary mapping 'sdr_type:device_index' keys to mode names """ with sdr_device_registry_lock: return dict(sdr_device_registry) @@ -429,8 +439,9 @@ def get_devices_status() -> Response: result = [] for device in devices: d = device.to_dict() - d['in_use'] = device.index in registry - d['used_by'] = registry.get(device.index) + key = f"{device.sdr_type.value}:{device.index}" + d['in_use'] = key in registry + d['used_by'] = registry.get(key) result.append(d) return jsonify(result) @@ -760,6 +771,7 @@ def health_check() -> Response: 'wifi': wifi_active, 'bluetooth': bt_active, 'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False), + 'radiosonde': radiosonde_process is not None and (radiosonde_process.poll() is None if radiosonde_process else False), 'morse': morse_process is not None and (morse_process.poll() is None if morse_process else False), 'subghz': _get_subghz_active(), }, @@ -778,12 +790,13 @@ def health_check() -> Response: def kill_all() -> Response: """Kill all decoder, WiFi, and Bluetooth processes.""" global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process - global vdl2_process, morse_process + global vdl2_process, morse_process, radiosonde_process global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process - # Import adsb and ais modules to reset their state + # Import modules to reset their state from routes import adsb as adsb_module from routes import ais as ais_module + from routes import radiosonde as radiosonde_module from utils.bluetooth import reset_bluetooth_scanner killed = [] @@ -793,7 +806,8 @@ def kill_all() -> Response: 'dump1090', 'acarsdec', 'dumpvdl2', 'direwolf', 'AIS-catcher', 'hcitool', 'bluetoothctl', 'satdump', 'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg', - 'hackrf_transfer', 'hackrf_sweep' + 'hackrf_transfer', 'hackrf_sweep', + 'auto_rx' ] for proc in processes_to_kill: @@ -823,6 +837,11 @@ def kill_all() -> Response: ais_process = None ais_module.ais_running = False + # Reset Radiosonde state + with radiosonde_lock: + radiosonde_process = None + radiosonde_module.radiosonde_running = False + # Reset ACARS state with acars_lock: acars_process = None diff --git a/config.py b/config.py index 76635b8..434d2b1 100644 --- a/config.py +++ b/config.py @@ -355,6 +355,12 @@ 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) +# Radiosonde settings +RADIOSONDE_FREQ_MIN = _get_env_float('RADIOSONDE_FREQ_MIN', 400.0) +RADIOSONDE_FREQ_MAX = _get_env_float('RADIOSONDE_FREQ_MAX', 406.0) +RADIOSONDE_DEFAULT_GAIN = _get_env_float('RADIOSONDE_GAIN', 40.0) +RADIOSONDE_UDP_PORT = _get_env_int('RADIOSONDE_UDP_PORT', 55673) + # 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 3a00b91..61c1ba6 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -19,6 +19,7 @@ def register_blueprints(app): from .morse import morse_bp from .offline import offline_bp from .pager import pager_bp + from .radiosonde import radiosonde_bp from .recordings import recordings_bp from .rtlamr import rtlamr_bp from .satellite import satellite_bp @@ -76,6 +77,7 @@ def register_blueprints(app): app.register_blueprint(signalid_bp) # External signal ID enrichment app.register_blueprint(wefax_bp) # WeFax HF weather fax decoder app.register_blueprint(morse_bp) # CW/Morse code decoder + app.register_blueprint(radiosonde_bp) # Radiosonde weather balloon tracking app.register_blueprint(system_bp) # System health monitoring # Initialize TSCM state with queue and lock from app diff --git a/routes/acars.py b/routes/acars.py index d365a70..9ab5cd5 100644 --- a/routes/acars.py +++ b/routes/acars.py @@ -48,6 +48,7 @@ acars_last_message_time = None # Track which device is being used acars_active_device: int | None = None +acars_active_sdr_type: str | None = None def find_acarsdec(): @@ -164,7 +165,7 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) - logger.error(f"ACARS stream error: {e}") app_module.acars_queue.put({'type': 'error', 'message': str(e)}) finally: - global acars_active_device + global acars_active_device, acars_active_sdr_type # Ensure process is terminated try: process.terminate() @@ -180,8 +181,9 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) - app_module.acars_process = None # Release SDR device if acars_active_device is not None: - app_module.release_sdr_device(acars_active_device) + app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr') acars_active_device = None + acars_active_sdr_type = None @acars_bp.route('/tools') @@ -213,7 +215,7 @@ def acars_status() -> Response: @acars_bp.route('/start', methods=['POST']) def start_acars() -> Response: """Start ACARS decoder.""" - global acars_message_count, acars_last_message_time, acars_active_device + global acars_message_count, acars_last_message_time, acars_active_device, acars_active_sdr_type with app_module.acars_lock: if app_module.acars_process and app_module.acars_process.poll() is None: @@ -240,9 +242,12 @@ def start_acars() -> Response: except ValueError as e: return jsonify({'status': 'error', 'message': str(e)}), 400 + # Resolve SDR type for device selection + sdr_type_str = data.get('sdr_type', 'rtlsdr') + # Check if device is available device_int = int(device) - error = app_module.claim_sdr_device(device_int, 'acars') + error = app_module.claim_sdr_device(device_int, 'acars', sdr_type_str) if error: return jsonify({ 'status': 'error', @@ -251,6 +256,7 @@ def start_acars() -> Response: }), 409 acars_active_device = device_int + acars_active_sdr_type = sdr_type_str # Get frequencies - use provided or defaults frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES) @@ -268,8 +274,6 @@ def start_acars() -> Response: acars_message_count = 0 acars_last_message_time = None - # Resolve SDR type for device selection - sdr_type_str = data.get('sdr_type', 'rtlsdr') try: sdr_type = SDRType(sdr_type_str) except ValueError: @@ -356,8 +360,9 @@ def start_acars() -> Response: if process.poll() is not None: # Process died - release device if acars_active_device is not None: - app_module.release_sdr_device(acars_active_device) + app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr') acars_active_device = None + acars_active_sdr_type = None stderr = '' if process.stderr: stderr = process.stderr.read().decode('utf-8', errors='replace') @@ -388,8 +393,9 @@ def start_acars() -> Response: except Exception as e: # Release device on failure if acars_active_device is not None: - app_module.release_sdr_device(acars_active_device) + app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr') acars_active_device = None + acars_active_sdr_type = None logger.error(f"Failed to start ACARS decoder: {e}") return jsonify({'status': 'error', 'message': str(e)}), 500 @@ -397,7 +403,7 @@ def start_acars() -> Response: @acars_bp.route('/stop', methods=['POST']) def stop_acars() -> Response: """Stop ACARS decoder.""" - global acars_active_device + global acars_active_device, acars_active_sdr_type with app_module.acars_lock: if not app_module.acars_process: @@ -418,8 +424,9 @@ def stop_acars() -> Response: # Release device from registry if acars_active_device is not None: - app_module.release_sdr_device(acars_active_device) + app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr') acars_active_device = None + acars_active_sdr_type = None return jsonify({'status': 'stopped'}) @@ -445,6 +452,7 @@ def stream_acars() -> Response: return response + @acars_bp.route('/messages') def get_acars_messages() -> Response: """Get recent ACARS messages from correlator (for history reload).""" diff --git a/routes/adsb.py b/routes/adsb.py index 5c662cb..9cb75a0 100644 --- a/routes/adsb.py +++ b/routes/adsb.py @@ -72,6 +72,7 @@ adsb_last_message_time = None adsb_bytes_received = 0 adsb_lines_received = 0 adsb_active_device = None # Track which device index is being used +adsb_active_sdr_type: str | None = None _sbs_error_logged = False # Suppress repeated connection error logs # Track ICAOs already looked up in aircraft database (avoid repeated lookups) @@ -674,7 +675,7 @@ def adsb_session(): @adsb_bp.route('/start', methods=['POST']) def start_adsb(): """Start ADS-B tracking.""" - global adsb_using_service, adsb_active_device + global adsb_using_service, adsb_active_device, adsb_active_sdr_type with app_module.adsb_lock: if adsb_using_service: @@ -757,6 +758,7 @@ def start_adsb(): sdr_type = SDRType(sdr_type_str) except ValueError: sdr_type = SDRType.RTL_SDR + sdr_type_str = sdr_type.value # For RTL-SDR, use dump1090. For other hardware, need readsb with SoapySDR if sdr_type == SDRType.RTL_SDR: @@ -787,7 +789,7 @@ def start_adsb(): # Check if device is available before starting local dump1090 device_int = int(device) - error = app_module.claim_sdr_device(device_int, 'adsb') + error = app_module.claim_sdr_device(device_int, 'adsb', sdr_type_str) if error: return jsonify({ 'status': 'error', @@ -795,6 +797,10 @@ def start_adsb(): 'message': error }), 409 + # Track claimed device immediately so stop_adsb() can always release it + adsb_active_device = device + adsb_active_sdr_type = sdr_type_str + # Create device object and build command via abstraction layer sdr_device = SDRFactory.create_default_device(sdr_type, index=device) builder = SDRFactory.get_builder(sdr_type) @@ -821,11 +827,24 @@ def start_adsb(): ) write_dump1090_pid(app_module.adsb_process.pid) - time.sleep(DUMP1090_START_WAIT) + # Poll for dump1090 readiness instead of blind sleep + dump1090_ready = False + poll_interval = 0.1 + elapsed = 0.0 + while elapsed < DUMP1090_START_WAIT: + if app_module.adsb_process.poll() is not None: + break # Process exited early — handle below + if check_dump1090_service(): + dump1090_ready = True + break + time.sleep(poll_interval) + elapsed += poll_interval if app_module.adsb_process.poll() is not None: # Process exited - release device and get error message - app_module.release_sdr_device(device_int) + app_module.release_sdr_device(device_int, sdr_type_str) + adsb_active_device = None + adsb_active_sdr_type = None stderr_output = '' if app_module.adsb_process.stderr: try: @@ -871,7 +890,6 @@ def start_adsb(): }) adsb_using_service = True - adsb_active_device = device # Track which device is being used thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True) thread.start() @@ -891,14 +909,16 @@ def start_adsb(): }) except Exception as e: # Release device on failure - app_module.release_sdr_device(device_int) + app_module.release_sdr_device(device_int, sdr_type_str) + adsb_active_device = None + adsb_active_sdr_type = None return jsonify({'status': 'error', 'message': str(e)}) @adsb_bp.route('/stop', methods=['POST']) def stop_adsb(): """Stop ADS-B tracking.""" - global adsb_using_service, adsb_active_device + global adsb_using_service, adsb_active_device, adsb_active_sdr_type data = request.get_json(silent=True) or {} stop_source = data.get('source') stopped_by = request.remote_addr @@ -923,10 +943,11 @@ def stop_adsb(): # Release device from registry if adsb_active_device is not None: - app_module.release_sdr_device(adsb_active_device) + app_module.release_sdr_device(adsb_active_device, adsb_active_sdr_type or 'rtlsdr') adsb_using_service = False adsb_active_device = None + adsb_active_sdr_type = None app_module.adsb_aircraft.clear() _looked_up_icaos.clear() diff --git a/routes/ais.py b/routes/ais.py index 091d1ae..021fa24 100644 --- a/routes/ais.py +++ b/routes/ais.py @@ -44,6 +44,7 @@ ais_connected = False ais_messages_received = 0 ais_last_message_time = None ais_active_device = None +ais_active_sdr_type: str | None = None _ais_error_logged = True # Common installation paths for AIS-catcher @@ -350,7 +351,7 @@ def ais_status(): @ais_bp.route('/start', methods=['POST']) def start_ais(): """Start AIS tracking.""" - global ais_running, ais_active_device + global ais_running, ais_active_device, ais_active_sdr_type with app_module.ais_lock: if ais_running: @@ -397,7 +398,7 @@ def start_ais(): # Check if device is available device_int = int(device) - error = app_module.claim_sdr_device(device_int, 'ais') + error = app_module.claim_sdr_device(device_int, 'ais', sdr_type_str) if error: return jsonify({ 'status': 'error', @@ -436,7 +437,7 @@ def start_ais(): if app_module.ais_process.poll() is not None: # Release device on failure - app_module.release_sdr_device(device_int) + app_module.release_sdr_device(device_int, sdr_type_str) stderr_output = '' if app_module.ais_process.stderr: try: @@ -450,6 +451,7 @@ def start_ais(): ais_running = True ais_active_device = device + ais_active_sdr_type = sdr_type_str # Start TCP parser thread thread = threading.Thread(target=parse_ais_stream, args=(tcp_port,), daemon=True) @@ -463,7 +465,7 @@ def start_ais(): }) except Exception as e: # Release device on failure - app_module.release_sdr_device(device_int) + app_module.release_sdr_device(device_int, sdr_type_str) logger.error(f"Failed to start AIS-catcher: {e}") return jsonify({'status': 'error', 'message': str(e)}), 500 @@ -471,7 +473,7 @@ def start_ais(): @ais_bp.route('/stop', methods=['POST']) def stop_ais(): """Stop AIS tracking.""" - global ais_running, ais_active_device + global ais_running, ais_active_device, ais_active_sdr_type with app_module.ais_lock: if app_module.ais_process: @@ -490,10 +492,11 @@ def stop_ais(): # Release device from registry if ais_active_device is not None: - app_module.release_sdr_device(ais_active_device) + app_module.release_sdr_device(ais_active_device, ais_active_sdr_type or 'rtlsdr') ais_running = False ais_active_device = None + ais_active_sdr_type = None app_module.ais_vessels.clear() return jsonify({'status': 'stopped'}) diff --git a/routes/aprs.py b/routes/aprs.py index 606f3a4..93ce5e9 100644 --- a/routes/aprs.py +++ b/routes/aprs.py @@ -5,8 +5,10 @@ from __future__ import annotations import csv import json import os +import pty import queue import re +import select import shutil import subprocess import tempfile @@ -35,6 +37,7 @@ aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs') # Track which SDR device is being used aprs_active_device: int | None = None +aprs_active_sdr_type: str | None = None # APRS frequencies by region (MHz) APRS_FREQUENCIES = { @@ -103,6 +106,9 @@ ADEVICE stdin null CHANNEL 0 MYCALL N0CALL MODEM 1200 +FIX_BITS 1 +AGWPORT 0 +KISSPORT 0 """ with open(DIREWOLF_CONFIG_PATH, 'w') as f: f.write(config) @@ -1437,19 +1443,19 @@ def should_send_meter_update(level: int) -> bool: return False -def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subprocess.Popen) -> None: +def stream_aprs_output(master_fd: int, rtl_process: subprocess.Popen, decoder_process: subprocess.Popen) -> None: """Stream decoded APRS packets and audio level meter to queue. - This function reads from the decoder's stdout (text mode, line-buffered). - The decoder's stderr is merged into stdout (STDOUT) to avoid deadlocks. - rtl_fm's stderr is captured via PIPE with a monitor thread. + Reads from a PTY master fd to get line-buffered output from the decoder, + avoiding the 15-minute pipe buffering delay. Uses select() + os.read() + to poll the PTY (same pattern as pager.py). Outputs two types of messages to the queue: - type='aprs': Decoded APRS packets - type='meter': Audio level meter readings (rate-limited) """ global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations - global _last_meter_time, _last_meter_level, aprs_active_device + global _last_meter_time, _last_meter_level, aprs_active_device, aprs_active_sdr_type # Capture the device claimed by THIS session so the finally block only # releases our own device, not one claimed by a subsequent start. @@ -1462,93 +1468,114 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces try: app_module.aprs_queue.put({'type': 'status', 'status': 'started'}) - # Read line-by-line in binary mode. Empty bytes b'' signals EOF. - # Decode with errors='replace' so corrupted radio bytes (e.g. 0xf7) - # never crash the stream. - for raw in iter(decoder_process.stdout.readline, b''): - line = raw.decode('utf-8', errors='replace').strip() - if not line: - continue + # Read from PTY using select() for non-blocking reads. + # PTY forces the decoder to line-buffer, so output arrives immediately + # instead of waiting for a full 4-8KB pipe buffer to fill. + buffer = "" + while True: + try: + ready, _, _ = select.select([master_fd], [], [], 1.0) + except Exception: + break - # Check for audio level line first (for signal meter) - audio_level = parse_audio_level(line) - if audio_level is not None: - if should_send_meter_update(audio_level): - meter_msg = { - 'type': 'meter', - 'level': audio_level, - 'ts': datetime.utcnow().isoformat() + 'Z' - } - app_module.aprs_queue.put(meter_msg) - continue # Audio level lines are not packets + if ready: + try: + data = os.read(master_fd, 1024) + if not data: + break + buffer += data.decode('utf-8', errors='replace') + except OSError: + break - # Normalize decoder prefixes (multimon/direwolf) before parsing. - line = normalize_aprs_output_line(line) + while '\n' in buffer: + line, buffer = buffer.split('\n', 1) + line = line.strip() + if not line: + continue - # Skip non-packet lines (APRS format: CALL>PATH:DATA) - if '>' not in line or ':' not in line: - continue + # Check for audio level line first (for signal meter) + audio_level = parse_audio_level(line) + if audio_level is not None: + if should_send_meter_update(audio_level): + meter_msg = { + 'type': 'meter', + 'level': audio_level, + 'ts': datetime.utcnow().isoformat() + 'Z' + } + app_module.aprs_queue.put(meter_msg) + continue # Audio level lines are not packets - packet = parse_aprs_packet(line) - if packet: - aprs_packet_count += 1 - aprs_last_packet_time = time.time() + # Normalize decoder prefixes (multimon/direwolf) before parsing. + line = normalize_aprs_output_line(line) - # Track unique stations - callsign = packet.get('callsign') - if callsign and callsign not in aprs_stations: - aprs_station_count += 1 + # Skip non-packet lines (APRS format: CALL>PATH:DATA) + if '>' not in line or ':' not in line: + continue - # Update station data, preserving last known coordinates when - # packets do not contain position fields. - if callsign: - existing = aprs_stations.get(callsign, {}) - packet_lat = packet.get('lat') - packet_lon = packet.get('lon') - aprs_stations[callsign] = { - 'callsign': callsign, - 'lat': packet_lat if packet_lat is not None else existing.get('lat'), - 'lon': packet_lon if packet_lon is not None else existing.get('lon'), - 'symbol': packet.get('symbol') or existing.get('symbol'), - 'last_seen': packet.get('timestamp'), - 'packet_type': packet.get('packet_type'), - } - # Geofence check - _aprs_lat = packet_lat - _aprs_lon = packet_lon - if _aprs_lat is not None and _aprs_lon is not None: - try: - from utils.geofence import get_geofence_manager - for _gf_evt in get_geofence_manager().check_position( - callsign, 'aprs_station', _aprs_lat, _aprs_lon, - {'callsign': callsign} - ): - process_event('aprs', _gf_evt, 'geofence') - except Exception: - pass - # Evict oldest stations when limit is exceeded - if len(aprs_stations) > APRS_MAX_STATIONS: - oldest = min( - aprs_stations, - key=lambda k: aprs_stations[k].get('last_seen', ''), - ) - del aprs_stations[oldest] + packet = parse_aprs_packet(line) + if packet: + aprs_packet_count += 1 + aprs_last_packet_time = time.time() - app_module.aprs_queue.put(packet) + # Track unique stations + callsign = packet.get('callsign') + if callsign and callsign not in aprs_stations: + aprs_station_count += 1 - # Log if enabled - if app_module.logging_enabled: - try: - with open(app_module.log_file_path, 'a') as f: - ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S') - f.write(f"{ts} | APRS | {json.dumps(packet)}\n") - except Exception: - pass + # Update station data, preserving last known coordinates when + # packets do not contain position fields. + if callsign: + existing = aprs_stations.get(callsign, {}) + packet_lat = packet.get('lat') + packet_lon = packet.get('lon') + aprs_stations[callsign] = { + 'callsign': callsign, + 'lat': packet_lat if packet_lat is not None else existing.get('lat'), + 'lon': packet_lon if packet_lon is not None else existing.get('lon'), + 'symbol': packet.get('symbol') or existing.get('symbol'), + 'last_seen': packet.get('timestamp'), + 'packet_type': packet.get('packet_type'), + } + # Geofence check + _aprs_lat = packet_lat + _aprs_lon = packet_lon + if _aprs_lat is not None and _aprs_lon is not None: + try: + from utils.geofence import get_geofence_manager + for _gf_evt in get_geofence_manager().check_position( + callsign, 'aprs_station', _aprs_lat, _aprs_lon, + {'callsign': callsign} + ): + process_event('aprs', _gf_evt, 'geofence') + except Exception: + pass + # Evict oldest stations when limit is exceeded + if len(aprs_stations) > APRS_MAX_STATIONS: + oldest = min( + aprs_stations, + key=lambda k: aprs_stations[k].get('last_seen', ''), + ) + del aprs_stations[oldest] + + app_module.aprs_queue.put(packet) + + # Log if enabled + if app_module.logging_enabled: + try: + with open(app_module.log_file_path, 'a') as f: + ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + f.write(f"{ts} | APRS | {json.dumps(packet)}\n") + except Exception: + pass except Exception as e: logger.error(f"APRS stream error: {e}") app_module.aprs_queue.put({'type': 'error', 'message': str(e)}) finally: + try: + os.close(master_fd) + except OSError: + pass app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'}) # Cleanup processes for proc in [rtl_process, decoder_process]: @@ -1562,8 +1589,9 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces pass # Release SDR device — only if it's still ours (not reclaimed by a new start) if my_device is not None and aprs_active_device == my_device: - app_module.release_sdr_device(my_device) + app_module.release_sdr_device(my_device, aprs_active_sdr_type or 'rtlsdr') aprs_active_device = None + aprs_active_sdr_type = None @aprs_bp.route('/tools') @@ -1632,7 +1660,7 @@ def aprs_data() -> Response: def start_aprs() -> Response: """Start APRS decoder.""" global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations - global aprs_active_device + global aprs_active_device, aprs_active_sdr_type with app_module.aprs_lock: if app_module.aprs_process and app_module.aprs_process.poll() is None: @@ -1681,7 +1709,7 @@ def start_aprs() -> Response: }), 400 # Reserve SDR device to prevent conflicts with other modes - error = app_module.claim_sdr_device(device, 'aprs') + error = app_module.claim_sdr_device(device, 'aprs', sdr_type_str) if error: return jsonify({ 'status': 'error', @@ -1689,6 +1717,7 @@ def start_aprs() -> Response: 'message': error }), 409 aprs_active_device = device + aprs_active_sdr_type = sdr_type_str # Get frequency for region region = data.get('region', 'north_america') @@ -1730,8 +1759,9 @@ def start_aprs() -> Response: 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) + app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr') aprs_active_device = None + aprs_active_sdr_type = None return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500 # Build decoder command @@ -1785,19 +1815,25 @@ def start_aprs() -> Response: rtl_stderr_thread = threading.Thread(target=monitor_rtl_stderr, daemon=True) rtl_stderr_thread.start() + # Create a pseudo-terminal for decoder output. PTY forces the + # decoder to line-buffer its stdout, avoiding the 15-minute delay + # caused by full pipe buffering (~4-8KB) on small APRS packets. + master_fd, slave_fd = pty.openpty() + # Start decoder with stdin wired to rtl_fm's stdout. - # Use binary mode to avoid UnicodeDecodeError on raw/corrupted bytes - # from the radio decoder (e.g. 0xf7). Lines are decoded manually - # in stream_aprs_output with errors='replace'. - # Merge stderr into stdout to avoid blocking on unbuffered stderr. + # stdout/stderr go to the PTY slave so output is line-buffered. decoder_process = subprocess.Popen( decoder_cmd, stdin=rtl_process.stdout, - stdout=PIPE, - stderr=STDOUT, + stdout=slave_fd, + stderr=slave_fd, + close_fds=True, start_new_session=True ) + # Close slave fd in parent — decoder owns it now. + os.close(slave_fd) + # Close rtl_fm's stdout in parent so decoder owns it exclusively. # This ensures proper EOF propagation when rtl_fm terminates. rtl_process.stdout.close() @@ -1818,40 +1854,57 @@ def start_aprs() -> Response: if stderr_output: error_msg += f': {stderr_output[:200]}' logger.error(error_msg) + try: + os.close(master_fd) + except OSError: + pass try: decoder_process.kill() except Exception: pass if aprs_active_device is not None: - app_module.release_sdr_device(aprs_active_device) + app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr') aprs_active_device = None + aprs_active_sdr_type = None return jsonify({'status': 'error', 'message': error_msg}), 500 if decoder_process.poll() is not None: - # Decoder exited early - capture any output - raw_output = decoder_process.stdout.read()[:500] if decoder_process.stdout else b'' - error_output = raw_output.decode('utf-8', errors='replace') if raw_output else '' + # Decoder exited early - capture any output from PTY + error_output = '' + try: + ready, _, _ = select.select([master_fd], [], [], 0.5) + if ready: + raw = os.read(master_fd, 500) + error_output = raw.decode('utf-8', errors='replace') + except Exception: + pass error_msg = f'{decoder_name} failed to start' if error_output: error_msg += f': {error_output}' logger.error(error_msg) + try: + os.close(master_fd) + except OSError: + pass try: rtl_process.kill() except Exception: pass if aprs_active_device is not None: - app_module.release_sdr_device(aprs_active_device) + app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr') aprs_active_device = None + aprs_active_sdr_type = None return jsonify({'status': 'error', 'message': error_msg}), 500 # Store references for status checks and cleanup app_module.aprs_process = decoder_process app_module.aprs_rtl_process = rtl_process + app_module.aprs_master_fd = master_fd # Start background thread to read decoder output and push to queue thread = threading.Thread( target=stream_aprs_output, - args=(rtl_process, decoder_process), + args=(master_fd, rtl_process, decoder_process), daemon=True ) thread.start() @@ -1868,15 +1921,16 @@ def start_aprs() -> Response: except Exception as e: logger.error(f"Failed to start APRS decoder: {e}") if aprs_active_device is not None: - app_module.release_sdr_device(aprs_active_device) + app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr') aprs_active_device = None + aprs_active_sdr_type = None return jsonify({'status': 'error', 'message': str(e)}), 500 @aprs_bp.route('/stop', methods=['POST']) def stop_aprs() -> Response: """Stop APRS decoder.""" - global aprs_active_device + global aprs_active_device, aprs_active_sdr_type with app_module.aprs_lock: processes_to_stop = [] @@ -1902,14 +1956,23 @@ def stop_aprs() -> Response: except Exception as e: logger.error(f"Error stopping APRS process: {e}") + # Close PTY master fd + if hasattr(app_module, 'aprs_master_fd') and app_module.aprs_master_fd is not None: + try: + os.close(app_module.aprs_master_fd) + except OSError: + pass + app_module.aprs_master_fd = None + app_module.aprs_process = None if hasattr(app_module, 'aprs_rtl_process'): app_module.aprs_rtl_process = None # Release SDR device if aprs_active_device is not None: - app_module.release_sdr_device(aprs_active_device) + app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr') aprs_active_device = None + aprs_active_sdr_type = None return jsonify({'status': 'stopped'}) diff --git a/routes/dsc.py b/routes/dsc.py index cbdbe33..5ef13d1 100644 --- a/routes/dsc.py +++ b/routes/dsc.py @@ -51,6 +51,7 @@ dsc_running = False # Track which device is being used dsc_active_device: int | None = None +dsc_active_sdr_type: str | None = None def _get_dsc_decoder_path() -> str | None: @@ -171,7 +172,7 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non 'error': str(e) }) finally: - global dsc_active_device + global dsc_active_device, dsc_active_sdr_type try: os.close(master_fd) except OSError: @@ -197,8 +198,9 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non app_module.dsc_rtl_process = None # Release SDR device if dsc_active_device is not None: - app_module.release_sdr_device(dsc_active_device) + app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr') dsc_active_device = None + dsc_active_sdr_type = None def _store_critical_alert(msg: dict) -> None: @@ -331,10 +333,13 @@ def start_decoding() -> Response: 'message': str(e) }), 400 + # Get SDR type from request + sdr_type_str = data.get('sdr_type', 'rtlsdr') + # Check if device is available using centralized registry - global dsc_active_device + global dsc_active_device, dsc_active_sdr_type device_int = int(device) - error = app_module.claim_sdr_device(device_int, 'dsc') + error = app_module.claim_sdr_device(device_int, 'dsc', sdr_type_str) if error: return jsonify({ 'status': 'error', @@ -343,6 +348,7 @@ def start_decoding() -> Response: }), 409 dsc_active_device = device_int + dsc_active_sdr_type = sdr_type_str # Clear queue while not app_module.dsc_queue.empty(): @@ -440,8 +446,9 @@ def start_decoding() -> Response: pass # Release device on failure if dsc_active_device is not None: - app_module.release_sdr_device(dsc_active_device) + app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr') dsc_active_device = None + dsc_active_sdr_type = None return jsonify({ 'status': 'error', 'message': f'Tool not found: {e.filename}' @@ -458,8 +465,9 @@ def start_decoding() -> Response: pass # Release device on failure if dsc_active_device is not None: - app_module.release_sdr_device(dsc_active_device) + app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr') dsc_active_device = None + dsc_active_sdr_type = None logger.error(f"Failed to start DSC decoder: {e}") return jsonify({ 'status': 'error', @@ -470,7 +478,7 @@ def start_decoding() -> Response: @dsc_bp.route('/stop', methods=['POST']) def stop_decoding() -> Response: """Stop DSC decoder.""" - global dsc_running, dsc_active_device + global dsc_running, dsc_active_device, dsc_active_sdr_type with app_module.dsc_lock: if not app_module.dsc_process: @@ -509,8 +517,9 @@ def stop_decoding() -> Response: # Release device from registry if dsc_active_device is not None: - app_module.release_sdr_device(dsc_active_device) + app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr') dsc_active_device = None + dsc_active_sdr_type = None return jsonify({'status': 'stopped'}) diff --git a/routes/listening_post.py b/routes/listening_post.py index 1ae8428..b59bb40 100644 --- a/routes/listening_post.py +++ b/routes/listening_post.py @@ -55,7 +55,9 @@ scanner_lock = threading.Lock() scanner_paused = False scanner_current_freq = 0.0 scanner_active_device: Optional[int] = None +scanner_active_sdr_type: str = 'rtlsdr' receiver_active_device: Optional[int] = None +receiver_active_sdr_type: str = 'rtlsdr' scanner_power_process: Optional[subprocess.Popen] = None scanner_config = { 'start_freq': 88.0, @@ -996,7 +998,7 @@ def check_tools() -> Response: @receiver_bp.route('/scanner/start', methods=['POST']) def start_scanner() -> Response: """Start the frequency scanner.""" - global scanner_thread, scanner_running, scanner_config, scanner_active_device, receiver_active_device + global scanner_thread, scanner_running, scanner_config, scanner_active_device, scanner_active_sdr_type, receiver_active_device, receiver_active_sdr_type with scanner_lock: if scanner_running: @@ -1063,10 +1065,11 @@ def start_scanner() -> Response: }), 503 # Release listening device if active if receiver_active_device is not None: - app_module.release_sdr_device(receiver_active_device) + app_module.release_sdr_device(receiver_active_device, receiver_active_sdr_type) receiver_active_device = None + receiver_active_sdr_type = 'rtlsdr' # Claim device for scanner - error = app_module.claim_sdr_device(scanner_config['device'], 'scanner') + error = app_module.claim_sdr_device(scanner_config['device'], 'scanner', scanner_config['sdr_type']) if error: return jsonify({ 'status': 'error', @@ -1074,6 +1077,7 @@ def start_scanner() -> Response: 'message': error }), 409 scanner_active_device = scanner_config['device'] + scanner_active_sdr_type = scanner_config['sdr_type'] scanner_running = True scanner_thread = threading.Thread(target=scanner_loop_power, daemon=True) scanner_thread.start() @@ -1091,9 +1095,10 @@ def start_scanner() -> Response: 'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.' }), 503 if receiver_active_device is not None: - app_module.release_sdr_device(receiver_active_device) + app_module.release_sdr_device(receiver_active_device, receiver_active_sdr_type) receiver_active_device = None - error = app_module.claim_sdr_device(scanner_config['device'], 'scanner') + receiver_active_sdr_type = 'rtlsdr' + error = app_module.claim_sdr_device(scanner_config['device'], 'scanner', scanner_config['sdr_type']) if error: return jsonify({ 'status': 'error', @@ -1101,6 +1106,7 @@ def start_scanner() -> Response: 'message': error }), 409 scanner_active_device = scanner_config['device'] + scanner_active_sdr_type = scanner_config['sdr_type'] scanner_running = True scanner_thread = threading.Thread(target=scanner_loop, daemon=True) @@ -1115,7 +1121,7 @@ def start_scanner() -> Response: @receiver_bp.route('/scanner/stop', methods=['POST']) def stop_scanner() -> Response: """Stop the frequency scanner.""" - global scanner_running, scanner_active_device, scanner_power_process + global scanner_running, scanner_active_device, scanner_active_sdr_type, scanner_power_process scanner_running = False _stop_audio_stream() @@ -1130,8 +1136,9 @@ def stop_scanner() -> Response: pass scanner_power_process = None if scanner_active_device is not None: - app_module.release_sdr_device(scanner_active_device) + app_module.release_sdr_device(scanner_active_device, scanner_active_sdr_type) scanner_active_device = None + scanner_active_sdr_type = 'rtlsdr' return jsonify({'status': 'stopped'}) @@ -1296,7 +1303,7 @@ def get_presets() -> Response: @receiver_bp.route('/audio/start', methods=['POST']) def start_audio() -> Response: """Start audio at specific frequency (manual mode).""" - global scanner_running, scanner_active_device, receiver_active_device, scanner_power_process, scanner_thread + global scanner_running, scanner_active_device, scanner_active_sdr_type, receiver_active_device, receiver_active_sdr_type, scanner_power_process, scanner_thread global audio_running, audio_frequency, audio_modulation, audio_source, audio_start_token data = request.json or {} @@ -1356,8 +1363,9 @@ def start_audio() -> Response: if scanner_running: scanner_running = False if scanner_active_device is not None: - app_module.release_sdr_device(scanner_active_device) + app_module.release_sdr_device(scanner_active_device, scanner_active_sdr_type) scanner_active_device = None + scanner_active_sdr_type = 'rtlsdr' scanner_thread_ref = scanner_thread scanner_proc_ref = scanner_power_process scanner_power_process = None @@ -1419,8 +1427,9 @@ def start_audio() -> Response: audio_source = 'waterfall' # Shared monitor uses the waterfall's existing SDR claim. if receiver_active_device is not None: - app_module.release_sdr_device(receiver_active_device) + app_module.release_sdr_device(receiver_active_device, receiver_active_sdr_type) receiver_active_device = None + receiver_active_sdr_type = 'rtlsdr' return jsonify({ 'status': 'started', 'frequency': frequency, @@ -1443,13 +1452,14 @@ def start_audio() -> Response: # to give the USB device time to be fully released. if receiver_active_device is None or receiver_active_device != device: if receiver_active_device is not None: - app_module.release_sdr_device(receiver_active_device) + app_module.release_sdr_device(receiver_active_device, receiver_active_sdr_type) receiver_active_device = None + receiver_active_sdr_type = 'rtlsdr' error = None max_claim_attempts = 6 for attempt in range(max_claim_attempts): - error = app_module.claim_sdr_device(device, 'receiver') + error = app_module.claim_sdr_device(device, 'receiver', sdr_type) if not error: break if attempt < max_claim_attempts - 1: @@ -1466,6 +1476,7 @@ def start_audio() -> Response: 'message': error }), 409 receiver_active_device = device + receiver_active_sdr_type = sdr_type _start_audio_stream( frequency, @@ -1489,8 +1500,9 @@ def start_audio() -> Response: # Avoid leaving a stale device claim after startup failure. if receiver_active_device is not None: - app_module.release_sdr_device(receiver_active_device) + app_module.release_sdr_device(receiver_active_device, receiver_active_sdr_type) receiver_active_device = None + receiver_active_sdr_type = 'rtlsdr' start_error = '' for log_path in ('/tmp/rtl_fm_stderr.log', '/tmp/ffmpeg_stderr.log'): @@ -1515,11 +1527,12 @@ def start_audio() -> Response: @receiver_bp.route('/audio/stop', methods=['POST']) def stop_audio() -> Response: """Stop audio.""" - global receiver_active_device + global receiver_active_device, receiver_active_sdr_type _stop_audio_stream() if receiver_active_device is not None: - app_module.release_sdr_device(receiver_active_device) + app_module.release_sdr_device(receiver_active_device, receiver_active_sdr_type) receiver_active_device = None + receiver_active_sdr_type = 'rtlsdr' return jsonify({'status': 'stopped'}) @@ -1825,6 +1838,7 @@ waterfall_running = False waterfall_lock = threading.Lock() waterfall_queue: queue.Queue = queue.Queue(maxsize=200) waterfall_active_device: Optional[int] = None +waterfall_active_sdr_type: str = 'rtlsdr' waterfall_config = { 'start_freq': 88.0, 'end_freq': 108.0, @@ -2033,7 +2047,7 @@ def _waterfall_loop(): def _stop_waterfall_internal() -> None: """Stop the waterfall display and release resources.""" - global waterfall_running, waterfall_process, waterfall_active_device + global waterfall_running, waterfall_process, waterfall_active_device, waterfall_active_sdr_type waterfall_running = False if waterfall_process and waterfall_process.poll() is None: @@ -2048,14 +2062,15 @@ def _stop_waterfall_internal() -> None: waterfall_process = None if waterfall_active_device is not None: - app_module.release_sdr_device(waterfall_active_device) + app_module.release_sdr_device(waterfall_active_device, waterfall_active_sdr_type) waterfall_active_device = None + waterfall_active_sdr_type = 'rtlsdr' @receiver_bp.route('/waterfall/start', methods=['POST']) def start_waterfall() -> Response: """Start the waterfall/spectrogram display.""" - global waterfall_thread, waterfall_running, waterfall_config, waterfall_active_device + global waterfall_thread, waterfall_running, waterfall_config, waterfall_active_device, waterfall_active_sdr_type with waterfall_lock: if waterfall_running: @@ -2101,11 +2116,12 @@ def start_waterfall() -> Response: pass # Claim SDR device - error = app_module.claim_sdr_device(waterfall_config['device'], 'waterfall') + error = app_module.claim_sdr_device(waterfall_config['device'], 'waterfall', 'rtlsdr') if error: return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409 waterfall_active_device = waterfall_config['device'] + waterfall_active_sdr_type = 'rtlsdr' waterfall_running = True waterfall_thread = threading.Thread(target=_waterfall_loop, daemon=True) waterfall_thread.start() diff --git a/routes/morse.py b/routes/morse.py index 5491979..42c1818 100644 --- a/routes/morse.py +++ b/routes/morse.py @@ -51,6 +51,7 @@ class _FilteredQueue: # Track which device is being used morse_active_device: int | None = None +morse_active_sdr_type: str | None = None # Runtime lifecycle state. MORSE_IDLE = 'idle' @@ -231,7 +232,7 @@ def _snapshot_live_resources() -> list[str]: @morse_bp.route('/morse/start', methods=['POST']) def start_morse() -> Response: - global morse_active_device, morse_decoder_worker, morse_stderr_worker + global morse_active_device, morse_active_sdr_type, morse_decoder_worker, morse_stderr_worker global morse_stop_event, morse_control_queue, morse_runtime_config global morse_last_error, morse_session_id @@ -261,6 +262,8 @@ def start_morse() -> Response: except ValueError as e: return jsonify({'status': 'error', 'message': str(e)}), 400 + sdr_type_str = data.get('sdr_type', 'rtlsdr') + with app_module.morse_lock: if morse_state in {MORSE_STARTING, MORSE_RUNNING, MORSE_STOPPING}: return jsonify({ @@ -270,7 +273,7 @@ def start_morse() -> Response: }), 409 device_int = int(device) - error = app_module.claim_sdr_device(device_int, 'morse') + error = app_module.claim_sdr_device(device_int, 'morse', sdr_type_str) if error: return jsonify({ 'status': 'error', @@ -279,6 +282,7 @@ def start_morse() -> Response: }), 409 morse_active_device = device_int + morse_active_sdr_type = sdr_type_str morse_last_error = '' morse_session_id += 1 @@ -288,7 +292,6 @@ def start_morse() -> Response: sample_rate = 22050 bias_t = _bool_value(data.get('bias_t', False), False) - sdr_type_str = data.get('sdr_type', 'rtlsdr') try: sdr_type = SDRType(sdr_type_str) except ValueError: @@ -408,7 +411,7 @@ def start_morse() -> Response: for device_pos, candidate_device_index in enumerate(candidate_device_indices, start=1): if candidate_device_index != active_device_index: prev_device = active_device_index - claim_error = app_module.claim_sdr_device(candidate_device_index, 'morse') + claim_error = app_module.claim_sdr_device(candidate_device_index, 'morse', sdr_type_str) if claim_error: msg = f'{_device_label(candidate_device_index)} unavailable: {claim_error}' attempt_errors.append(msg) @@ -417,7 +420,7 @@ def start_morse() -> Response: continue if prev_device is not None: - app_module.release_sdr_device(prev_device) + app_module.release_sdr_device(prev_device, morse_active_sdr_type or 'rtlsdr') active_device_index = candidate_device_index with app_module.morse_lock: morse_active_device = active_device_index @@ -634,8 +637,9 @@ def start_morse() -> Response: logger.error('Morse startup failed: %s', msg) with app_module.morse_lock: if morse_active_device is not None: - app_module.release_sdr_device(morse_active_device) + app_module.release_sdr_device(morse_active_device, morse_active_sdr_type or 'rtlsdr') morse_active_device = None + morse_active_sdr_type = None morse_last_error = msg _set_state(MORSE_ERROR, msg) _set_state(MORSE_IDLE, 'Idle') @@ -675,8 +679,9 @@ def start_morse() -> Response: ) with app_module.morse_lock: if morse_active_device is not None: - app_module.release_sdr_device(morse_active_device) + app_module.release_sdr_device(morse_active_device, morse_active_sdr_type or 'rtlsdr') morse_active_device = None + morse_active_sdr_type = None morse_last_error = f'Tool not found: {e.filename}' _set_state(MORSE_ERROR, morse_last_error) _set_state(MORSE_IDLE, 'Idle') @@ -692,8 +697,9 @@ def start_morse() -> Response: ) with app_module.morse_lock: if morse_active_device is not None: - app_module.release_sdr_device(morse_active_device) + app_module.release_sdr_device(morse_active_device, morse_active_sdr_type or 'rtlsdr') morse_active_device = None + morse_active_sdr_type = None morse_last_error = str(e) _set_state(MORSE_ERROR, morse_last_error) _set_state(MORSE_IDLE, 'Idle') @@ -702,7 +708,7 @@ def start_morse() -> Response: @morse_bp.route('/morse/stop', methods=['POST']) def stop_morse() -> Response: - global morse_active_device, morse_decoder_worker, morse_stderr_worker + global morse_active_device, morse_active_sdr_type, morse_decoder_worker, morse_stderr_worker global morse_stop_event, morse_control_queue stop_started = time.perf_counter() @@ -717,6 +723,7 @@ def stop_morse() -> Response: stderr_thread = morse_stderr_worker or getattr(rtl_proc, '_stderr_thread', None) control_queue = morse_control_queue or getattr(rtl_proc, '_control_queue', None) active_device = morse_active_device + active_sdr_type = morse_active_sdr_type if ( not rtl_proc @@ -768,7 +775,7 @@ def stop_morse() -> Response: _mark(f'stderr thread joined={stderr_joined}') if active_device is not None: - app_module.release_sdr_device(active_device) + app_module.release_sdr_device(active_device, active_sdr_type or 'rtlsdr') _mark(f'SDR device {active_device} released') stop_ms = round((time.perf_counter() - stop_started) * 1000.0, 1) @@ -782,6 +789,7 @@ def stop_morse() -> Response: with app_module.morse_lock: morse_active_device = None + morse_active_sdr_type = None _set_state(MORSE_IDLE, 'Stopped', extra={ 'stop_ms': stop_ms, 'cleanup_steps': cleanup_steps, diff --git a/routes/pager.py b/routes/pager.py index 6dfcc3b..7777ced 100644 --- a/routes/pager.py +++ b/routes/pager.py @@ -24,7 +24,7 @@ from utils.validation import ( validate_frequency, validate_device_index, validate_gain, validate_ppm, validate_rtl_tcp_host, validate_rtl_tcp_port ) -from utils.sse import sse_stream_fanout +from utils.sse import sse_stream_fanout from utils.event_pipeline import process_event from utils.process import safe_terminate, register_process, unregister_process from utils.sdr import SDRFactory, SDRType, SDRValidationError @@ -34,6 +34,7 @@ pager_bp = Blueprint('pager', __name__) # Track which device is being used pager_active_device: int | None = None +pager_active_sdr_type: str | None = None def parse_multimon_output(line: str) -> dict[str, str] | None: @@ -96,7 +97,7 @@ def parse_multimon_output(line: str) -> dict[str, str] | None: return None -def log_message(msg: dict[str, Any]) -> None: +def log_message(msg: dict[str, Any]) -> None: """Log a message to file if logging is enabled.""" if not app_module.logging_enabled: return @@ -104,39 +105,39 @@ def log_message(msg: dict[str, Any]) -> None: with open(app_module.log_file_path, 'a') as f: timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') f.write(f"{timestamp} | {msg.get('protocol', 'UNKNOWN')} | {msg.get('address', '')} | {msg.get('message', '')}\n") - except Exception as e: - logger.error(f"Failed to log message: {e}") - - -def _encode_scope_waveform(samples: tuple[int, ...], window_size: int = 256) -> list[int]: - """Compress recent PCM samples into a signed 8-bit waveform for SSE.""" - if not samples: - return [] - - window = samples[-window_size:] if len(samples) > window_size else samples - waveform: list[int] = [] - for sample in window: - # Convert int16 PCM to int8 range for lightweight transport. - packed = int(round(sample / 256)) - waveform.append(max(-127, min(127, packed))) - return waveform - - -def audio_relay_thread( - rtl_stdout, - multimon_stdin, - output_queue: queue.Queue, - stop_event: threading.Event, -) -> None: - """Relay audio from rtl_fm to multimon-ng while computing signal levels. - - Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight - through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope - event plus a compact waveform sample onto *output_queue*. - """ - CHUNK = 4096 # bytes – 2048 samples at 16-bit mono - INTERVAL = 0.1 # seconds between scope updates - last_scope = time.monotonic() + except Exception as e: + logger.error(f"Failed to log message: {e}") + + +def _encode_scope_waveform(samples: tuple[int, ...], window_size: int = 256) -> list[int]: + """Compress recent PCM samples into a signed 8-bit waveform for SSE.""" + if not samples: + return [] + + window = samples[-window_size:] if len(samples) > window_size else samples + waveform: list[int] = [] + for sample in window: + # Convert int16 PCM to int8 range for lightweight transport. + packed = int(round(sample / 256)) + waveform.append(max(-127, min(127, packed))) + return waveform + + +def audio_relay_thread( + rtl_stdout, + multimon_stdin, + output_queue: queue.Queue, + stop_event: threading.Event, +) -> None: + """Relay audio from rtl_fm to multimon-ng while computing signal levels. + + Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight + through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope + event plus a compact waveform sample onto *output_queue*. + """ + CHUNK = 4096 # bytes – 2048 samples at 16-bit mono + INTERVAL = 0.1 # seconds between scope updates + last_scope = time.monotonic() try: while not stop_event.is_set(): @@ -160,16 +161,16 @@ def audio_relay_thread( if n_samples == 0: continue samples = struct.unpack(f'<{n_samples}h', data[:n_samples * 2]) - peak = max(abs(s) for s in samples) - rms = int(math.sqrt(sum(s * s for s in samples) / n_samples)) - output_queue.put_nowait({ - 'type': 'scope', - 'rms': rms, - 'peak': peak, - 'waveform': _encode_scope_waveform(samples), - }) - except (struct.error, ValueError, queue.Full): - pass + peak = max(abs(s) for s in samples) + rms = int(math.sqrt(sum(s * s for s in samples) / n_samples)) + output_queue.put_nowait({ + 'type': 'scope', + 'rms': rms, + 'peak': peak, + 'waveform': _encode_scope_waveform(samples), + }) + except (struct.error, ValueError, queue.Full): + pass except Exception as e: logger.debug(f"Audio relay error: {e}") finally: @@ -220,7 +221,7 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None: except Exception as e: app_module.output_queue.put({'type': 'error', 'text': str(e)}) finally: - global pager_active_device + global pager_active_device, pager_active_sdr_type try: os.close(master_fd) except OSError: @@ -249,13 +250,14 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None: app_module.current_process = None # Release SDR device if pager_active_device is not None: - app_module.release_sdr_device(pager_active_device) + app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr') pager_active_device = None + pager_active_sdr_type = None @pager_bp.route('/start', methods=['POST']) def start_decoding() -> Response: - global pager_active_device + global pager_active_device, pager_active_sdr_type with app_module.process_lock: if app_module.current_process: @@ -284,10 +286,13 @@ def start_decoding() -> Response: rtl_tcp_host = data.get('rtl_tcp_host') rtl_tcp_port = data.get('rtl_tcp_port', 1234) + # Get SDR type early so we can pass it to claim/release + sdr_type_str = data.get('sdr_type', 'rtlsdr') + # Claim local device if not using remote rtl_tcp if not rtl_tcp_host: device_int = int(device) - error = app_module.claim_sdr_device(device_int, 'pager') + error = app_module.claim_sdr_device(device_int, 'pager', sdr_type_str) if error: return jsonify({ 'status': 'error', @@ -295,14 +300,16 @@ def start_decoding() -> Response: 'message': error }), 409 pager_active_device = device_int + pager_active_sdr_type = sdr_type_str # Validate protocols valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX'] protocols = data.get('protocols', valid_protocols) if not isinstance(protocols, list): if pager_active_device is not None: - app_module.release_sdr_device(pager_active_device) + app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr') pager_active_device = None + pager_active_sdr_type = None return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400 protocols = [p for p in protocols if p in valid_protocols] if not protocols: @@ -327,8 +334,7 @@ def start_decoding() -> Response: elif proto == 'FLEX': decoders.extend(['-a', 'FLEX']) - # Get SDR type and build command via abstraction layer - sdr_type_str = data.get('sdr_type', 'rtlsdr') + # Build command via SDR abstraction layer try: sdr_type = SDRType(sdr_type_str) except ValueError: @@ -443,8 +449,9 @@ def start_decoding() -> Response: pass # Release device on failure if pager_active_device is not None: - app_module.release_sdr_device(pager_active_device) + app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr') pager_active_device = None + pager_active_sdr_type = None return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'}) except Exception as e: # Kill orphaned rtl_fm process if it was started @@ -458,14 +465,15 @@ def start_decoding() -> Response: pass # Release device on failure if pager_active_device is not None: - app_module.release_sdr_device(pager_active_device) + app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr') pager_active_device = None + pager_active_sdr_type = None return jsonify({'status': 'error', 'message': str(e)}) @pager_bp.route('/stop', methods=['POST']) def stop_decoding() -> Response: - global pager_active_device + global pager_active_device, pager_active_sdr_type with app_module.process_lock: if app_module.current_process: @@ -502,8 +510,9 @@ def stop_decoding() -> Response: # Release device from registry if pager_active_device is not None: - app_module.release_sdr_device(pager_active_device) + app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr') pager_active_device = None + pager_active_sdr_type = None return jsonify({'status': 'stopped'}) @@ -553,22 +562,22 @@ def toggle_logging() -> Response: return jsonify({'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path}) -@pager_bp.route('/stream') -def stream() -> Response: - def _on_msg(msg: dict[str, Any]) -> None: - process_event('pager', msg, msg.get('type')) - - response = Response( - sse_stream_fanout( - source_queue=app_module.output_queue, - channel_key='pager', - timeout=1.0, - keepalive_interval=30.0, - on_message=_on_msg, - ), - mimetype='text/event-stream', - ) - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - response.headers['Connection'] = 'keep-alive' - return response +@pager_bp.route('/stream') +def stream() -> Response: + def _on_msg(msg: dict[str, Any]) -> None: + process_event('pager', msg, msg.get('type')) + + response = Response( + sse_stream_fanout( + source_queue=app_module.output_queue, + channel_key='pager', + timeout=1.0, + keepalive_interval=30.0, + on_message=_on_msg, + ), + 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/routes/radiosonde.py b/routes/radiosonde.py new file mode 100644 index 0000000..25f68fb --- /dev/null +++ b/routes/radiosonde.py @@ -0,0 +1,655 @@ +"""Radiosonde weather balloon tracking routes. + +Uses radiosonde_auto_rx to automatically scan for and decode radiosonde +telemetry (position, altitude, temperature, humidity, pressure) on the +400-406 MHz band. Telemetry arrives as JSON over UDP. +""" + +from __future__ import annotations + +import json +import os +import queue +import shutil +import socket +import sys +import subprocess +import threading +import time +from typing import Any + +from flask import Blueprint, Response, jsonify, request + +import app as app_module +from utils.constants import ( + MAX_RADIOSONDE_AGE_SECONDS, + PROCESS_TERMINATE_TIMEOUT, + RADIOSONDE_TERMINATE_TIMEOUT, + RADIOSONDE_UDP_PORT, + SSE_KEEPALIVE_INTERVAL, + SSE_QUEUE_TIMEOUT, +) +from utils.logging import get_logger +from utils.sdr import SDRFactory, SDRType +from utils.sse import sse_stream_fanout +from utils.validation import validate_device_index, validate_gain + +logger = get_logger('intercept.radiosonde') + +radiosonde_bp = Blueprint('radiosonde', __name__, url_prefix='/radiosonde') + +# Track radiosonde state +radiosonde_running = False +radiosonde_active_device: int | None = None +radiosonde_active_sdr_type: str | None = None + +# Active balloon data: serial -> telemetry dict +radiosonde_balloons: dict[str, dict[str, Any]] = {} +_balloons_lock = threading.Lock() + +# UDP listener socket reference (so /stop can close it) +_udp_socket: socket.socket | None = None + +# Common installation paths for radiosonde_auto_rx +AUTO_RX_PATHS = [ + '/opt/radiosonde_auto_rx/auto_rx/auto_rx.py', + '/usr/local/bin/radiosonde_auto_rx', + '/opt/auto_rx/auto_rx.py', +] + + +def find_auto_rx() -> str | None: + """Find radiosonde_auto_rx script/binary.""" + # Check PATH first + path = shutil.which('radiosonde_auto_rx') + if path: + return path + # Check common locations + for p in AUTO_RX_PATHS: + if os.path.isfile(p) and os.access(p, os.X_OK): + return p + # Check for Python script (not executable but runnable) + for p in AUTO_RX_PATHS: + if os.path.isfile(p): + return p + return None + + +def generate_station_cfg( + freq_min: float = 400.0, + freq_max: float = 406.0, + gain: float = 40.0, + device_index: int = 0, + ppm: int = 0, + bias_t: bool = False, + udp_port: int = RADIOSONDE_UDP_PORT, +) -> str: + """Generate a station.cfg for radiosonde_auto_rx and return the file path.""" + cfg_dir = os.path.abspath(os.path.join('data', 'radiosonde')) + log_dir = os.path.join(cfg_dir, 'logs') + os.makedirs(log_dir, exist_ok=True) + cfg_path = os.path.join(cfg_dir, 'station.cfg') + + # Full station.cfg based on radiosonde_auto_rx v1.8+ example config. + # All sections and keys included to avoid missing-key crashes. + cfg = f"""# Auto-generated by INTERCEPT for radiosonde_auto_rx + +[sdr] +sdr_type = RTLSDR +sdr_quantity = 1 +sdr_hostname = localhost +sdr_port = 5555 + +[sdr_1] +device_idx = {device_index} +ppm = {ppm} +gain = {gain} +bias = {str(bias_t)} + +[search_params] +min_freq = {freq_min} +max_freq = {freq_max} +rx_timeout = 180 +only_scan = [] +never_scan = [] +always_scan = [] +always_decode = [] + +[location] +station_lat = 0.0 +station_lon = 0.0 +station_alt = 0.0 +gpsd_enabled = False +gpsd_host = localhost +gpsd_port = 2947 + +[habitat] +uploader_callsign = INTERCEPT +upload_listener_position = False +uploader_antenna = unknown + +[sondehub] +sondehub_enabled = False +sondehub_upload_rate = 15 +sondehub_contact_email = none@none.com + +[aprs] +aprs_enabled = False +aprs_user = N0CALL +aprs_pass = 00000 +upload_rate = 30 +aprs_server = radiosondy.info +aprs_port = 14580 +station_beacon_enabled = False +station_beacon_rate = 30 +station_beacon_comment = radiosonde_auto_rx +station_beacon_icon = /` +aprs_object_id = +aprs_use_custom_object_id = False +aprs_custom_comment = + +[oziplotter] +ozi_enabled = False +ozi_update_rate = 5 +ozi_host = 127.0.0.1 +ozi_port = 8942 +payload_summary_enabled = True +payload_summary_host = 127.0.0.1 +payload_summary_port = {udp_port} + +[email] +email_enabled = False +launch_notifications = True +landing_notifications = True +encrypted_sonde_notifications = True +landing_range_threshold = 30 +landing_altitude_threshold = 1000 +error_notifications = False +smtp_server = localhost +smtp_port = 25 +smtp_authentication = None +smtp_login = None +smtp_password = None +from = sonde@localhost +to = none@none.com +subject = Sonde launch detected + +[rotator] +rotator_enabled = False +update_rate = 30 +rotation_threshold = 5.0 +rotator_hostname = 127.0.0.1 +rotator_port = 4533 +rotator_homing_enabled = False +rotator_homing_delay = 10 +rotator_home_azimuth = 0.0 +rotator_home_elevation = 0.0 +azimuth_only = False + +[logging] +per_sonde_log = True +save_system_log = False +enable_debug_logging = False +save_cal_data = False + +[web] +web_host = 127.0.0.1 +web_port = 0 +archive_age = 120 +web_control = False +web_password = none +kml_refresh_rate = 10 + +[debugging] +save_detection_audio = False +save_decode_audio = False +save_decode_iq = False +save_raw_hex = False + +[advanced] +search_step = 800 +snr_threshold = 10 +max_peaks = 10 +min_distance = 1000 +scan_dwell_time = 20 +detect_dwell_time = 5 +scan_delay = 10 +quantization = 10000 +decoder_spacing_limit = 15000 +temporary_block_time = 120 +max_async_scan_workers = 4 +synchronous_upload = True +payload_id_valid = 3 +sdr_fm_path = rtl_fm +sdr_power_path = rtl_power +ss_iq_path = ./ss_iq +ss_power_path = ./ss_power + +[filtering] +max_altitude = 50000 +max_radius_km = 1000 +min_radius_km = 0 +radius_temporary_block = False +sonde_time_threshold = 3 +""" + + with open(cfg_path, 'w') as f: + f.write(cfg) + + logger.info(f"Generated station.cfg at {cfg_path}") + return cfg_path + + +def parse_radiosonde_udp(udp_port: int) -> None: + """Thread function: listen for radiosonde_auto_rx UDP JSON telemetry.""" + global radiosonde_running, _udp_socket + + logger.info(f"Radiosonde UDP listener started on port {udp_port}") + + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(('0.0.0.0', udp_port)) + sock.settimeout(2.0) + _udp_socket = sock + except OSError as e: + logger.error(f"Failed to bind UDP port {udp_port}: {e}") + return + + while radiosonde_running: + try: + data, _addr = sock.recvfrom(4096) + except socket.timeout: + # Clean up stale balloons + _cleanup_stale_balloons() + continue + except OSError: + break + + try: + msg = json.loads(data.decode('utf-8', errors='ignore')) + except (json.JSONDecodeError, UnicodeDecodeError): + continue + + balloon = _process_telemetry(msg) + if balloon: + serial = balloon.get('id', '') + if serial: + with _balloons_lock: + radiosonde_balloons[serial] = balloon + try: + app_module.radiosonde_queue.put_nowait({ + 'type': 'balloon', + **balloon, + }) + except queue.Full: + pass + + try: + sock.close() + except OSError: + pass + _udp_socket = None + logger.info("Radiosonde UDP listener stopped") + + +def _process_telemetry(msg: dict) -> dict | None: + """Extract relevant fields from a radiosonde_auto_rx UDP telemetry packet.""" + # auto_rx broadcasts packets with a 'type' field + # Telemetry packets have type 'payload_summary' or individual sonde data + serial = msg.get('id') or msg.get('serial') + if not serial: + return None + + balloon: dict[str, Any] = {'id': str(serial)} + + # Sonde type (RS41, RS92, DFM, M10, etc.) + if 'type' in msg: + balloon['sonde_type'] = msg['type'] + if 'subtype' in msg: + balloon['sonde_type'] = msg['subtype'] + + # Timestamp + if 'datetime' in msg: + balloon['datetime'] = msg['datetime'] + + # Position + for key in ('lat', 'latitude'): + if key in msg: + try: + balloon['lat'] = float(msg[key]) + except (ValueError, TypeError): + pass + break + for key in ('lon', 'longitude'): + if key in msg: + try: + balloon['lon'] = float(msg[key]) + except (ValueError, TypeError): + pass + break + + # Altitude (metres) + if 'alt' in msg: + try: + balloon['alt'] = float(msg['alt']) + except (ValueError, TypeError): + pass + + # Meteorological data + for field in ('temp', 'humidity', 'pressure'): + if field in msg: + try: + balloon[field] = float(msg[field]) + except (ValueError, TypeError): + pass + + # Velocity + if 'vel_h' in msg: + try: + balloon['vel_h'] = float(msg['vel_h']) + except (ValueError, TypeError): + pass + if 'vel_v' in msg: + try: + balloon['vel_v'] = float(msg['vel_v']) + except (ValueError, TypeError): + pass + if 'heading' in msg: + try: + balloon['heading'] = float(msg['heading']) + except (ValueError, TypeError): + pass + + # GPS satellites + if 'sats' in msg: + try: + balloon['sats'] = int(msg['sats']) + except (ValueError, TypeError): + pass + + # Battery voltage + if 'batt' in msg: + try: + balloon['batt'] = float(msg['batt']) + except (ValueError, TypeError): + pass + + # Frequency + if 'freq' in msg: + try: + balloon['freq'] = float(msg['freq']) + except (ValueError, TypeError): + pass + + balloon['last_seen'] = time.time() + return balloon + + +def _cleanup_stale_balloons() -> None: + """Remove balloons not seen within the retention window.""" + now = time.time() + with _balloons_lock: + stale = [ + k for k, v in radiosonde_balloons.items() + if now - v.get('last_seen', 0) > MAX_RADIOSONDE_AGE_SECONDS + ] + for k in stale: + del radiosonde_balloons[k] + + +@radiosonde_bp.route('/tools') +def check_tools(): + """Check for radiosonde decoding tools and hardware.""" + auto_rx_path = find_auto_rx() + devices = SDRFactory.detect_devices() + has_rtlsdr = any(d.sdr_type == SDRType.RTL_SDR for d in devices) + + return jsonify({ + 'auto_rx': auto_rx_path is not None, + 'auto_rx_path': auto_rx_path, + 'has_rtlsdr': has_rtlsdr, + 'device_count': len(devices), + }) + + +@radiosonde_bp.route('/status') +def radiosonde_status(): + """Get radiosonde tracking status.""" + process_running = False + if app_module.radiosonde_process: + process_running = app_module.radiosonde_process.poll() is None + + with _balloons_lock: + balloon_count = len(radiosonde_balloons) + balloons_snapshot = dict(radiosonde_balloons) + + return jsonify({ + 'tracking_active': radiosonde_running, + 'active_device': radiosonde_active_device, + 'balloon_count': balloon_count, + 'balloons': balloons_snapshot, + 'queue_size': app_module.radiosonde_queue.qsize(), + 'auto_rx_path': find_auto_rx(), + 'process_running': process_running, + }) + + +@radiosonde_bp.route('/start', methods=['POST']) +def start_radiosonde(): + """Start radiosonde tracking.""" + global radiosonde_running, radiosonde_active_device, radiosonde_active_sdr_type + + with app_module.radiosonde_lock: + if radiosonde_running: + return jsonify({ + 'status': 'already_running', + 'message': 'Radiosonde tracking already active', + }), 409 + + data = request.json or {} + + # Validate inputs + try: + gain = float(validate_gain(data.get('gain', '40'))) + device = validate_device_index(data.get('device', '0')) + except ValueError as e: + return jsonify({'status': 'error', 'message': str(e)}), 400 + + freq_min = data.get('freq_min', 400.0) + freq_max = data.get('freq_max', 406.0) + try: + freq_min = float(freq_min) + freq_max = float(freq_max) + if not (380.0 <= freq_min <= 410.0) or not (380.0 <= freq_max <= 410.0): + raise ValueError("Frequency out of range") + if freq_min >= freq_max: + raise ValueError("Min frequency must be less than max") + except (ValueError, TypeError) as e: + return jsonify({'status': 'error', 'message': f'Invalid frequency range: {e}'}), 400 + + bias_t = data.get('bias_t', False) + ppm = int(data.get('ppm', 0)) + + # Find auto_rx + auto_rx_path = find_auto_rx() + if not auto_rx_path: + return jsonify({ + 'status': 'error', + 'message': 'radiosonde_auto_rx not found. Install from https://github.com/projecthorus/radiosonde_auto_rx', + }), 400 + + # Get SDR type + sdr_type_str = data.get('sdr_type', 'rtlsdr') + + # Kill any existing process + if app_module.radiosonde_process: + try: + pgid = os.getpgid(app_module.radiosonde_process.pid) + os.killpg(pgid, 15) + app_module.radiosonde_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT) + except (subprocess.TimeoutExpired, ProcessLookupError, OSError): + try: + pgid = os.getpgid(app_module.radiosonde_process.pid) + os.killpg(pgid, 9) + except (ProcessLookupError, OSError): + pass + app_module.radiosonde_process = None + logger.info("Killed existing radiosonde process") + + # Claim SDR device + device_int = int(device) + error = app_module.claim_sdr_device(device_int, 'radiosonde', sdr_type_str) + if error: + return jsonify({ + 'status': 'error', + 'error_type': 'DEVICE_BUSY', + 'message': error, + }), 409 + + # Generate config + cfg_path = generate_station_cfg( + freq_min=freq_min, + freq_max=freq_max, + gain=gain, + device_index=device_int, + ppm=ppm, + bias_t=bias_t, + ) + + # Build command - auto_rx -c expects a file path, not a directory + cfg_abs = os.path.abspath(cfg_path) + if auto_rx_path.endswith('.py'): + cmd = [sys.executable, auto_rx_path, '-c', cfg_abs] + else: + cmd = [auto_rx_path, '-c', cfg_abs] + + # Set cwd to the auto_rx directory so 'from autorx.scan import ...' works + auto_rx_dir = os.path.dirname(os.path.abspath(auto_rx_path)) + + try: + logger.info(f"Starting radiosonde_auto_rx: {' '.join(cmd)}") + app_module.radiosonde_process = subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + start_new_session=True, + cwd=auto_rx_dir, + ) + + # Wait briefly for process to start + time.sleep(2.0) + + if app_module.radiosonde_process.poll() is not None: + app_module.release_sdr_device(device_int, sdr_type_str) + stderr_output = '' + if app_module.radiosonde_process.stderr: + try: + stderr_output = app_module.radiosonde_process.stderr.read().decode( + 'utf-8', errors='ignore' + ).strip() + except Exception: + pass + error_msg = 'radiosonde_auto_rx failed to start. Check SDR device connection.' + if stderr_output: + error_msg += f' Error: {stderr_output[:200]}' + return jsonify({'status': 'error', 'message': error_msg}), 500 + + radiosonde_running = True + radiosonde_active_device = device_int + radiosonde_active_sdr_type = sdr_type_str + + # Clear stale data + with _balloons_lock: + radiosonde_balloons.clear() + + # Start UDP listener thread + udp_thread = threading.Thread( + target=parse_radiosonde_udp, + args=(RADIOSONDE_UDP_PORT,), + daemon=True, + ) + udp_thread.start() + + return jsonify({ + 'status': 'started', + 'message': 'Radiosonde tracking started', + 'device': device, + }) + except Exception as e: + app_module.release_sdr_device(device_int, sdr_type_str) + logger.error(f"Failed to start radiosonde_auto_rx: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@radiosonde_bp.route('/stop', methods=['POST']) +def stop_radiosonde(): + """Stop radiosonde tracking.""" + global radiosonde_running, radiosonde_active_device, radiosonde_active_sdr_type, _udp_socket + + with app_module.radiosonde_lock: + if app_module.radiosonde_process: + try: + pgid = os.getpgid(app_module.radiosonde_process.pid) + os.killpg(pgid, 15) + app_module.radiosonde_process.wait(timeout=RADIOSONDE_TERMINATE_TIMEOUT) + except (subprocess.TimeoutExpired, ProcessLookupError, OSError): + try: + pgid = os.getpgid(app_module.radiosonde_process.pid) + os.killpg(pgid, 9) + except (ProcessLookupError, OSError): + pass + app_module.radiosonde_process = None + logger.info("Radiosonde process stopped") + + # Close UDP socket to unblock listener thread + if _udp_socket: + try: + _udp_socket.close() + except OSError: + pass + _udp_socket = None + + # Release SDR device + if radiosonde_active_device is not None: + app_module.release_sdr_device( + radiosonde_active_device, + radiosonde_active_sdr_type or 'rtlsdr', + ) + + radiosonde_running = False + radiosonde_active_device = None + radiosonde_active_sdr_type = None + + with _balloons_lock: + radiosonde_balloons.clear() + + return jsonify({'status': 'stopped'}) + + +@radiosonde_bp.route('/stream') +def stream_radiosonde(): + """SSE stream for radiosonde telemetry.""" + response = Response( + sse_stream_fanout( + source_queue=app_module.radiosonde_queue, + channel_key='radiosonde', + timeout=SSE_QUEUE_TIMEOUT, + keepalive_interval=SSE_KEEPALIVE_INTERVAL, + ), + mimetype='text/event-stream', + ) + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + return response + + +@radiosonde_bp.route('/balloons') +def get_balloons(): + """Get current balloon data.""" + with _balloons_lock: + return jsonify({ + 'status': 'success', + 'count': len(radiosonde_balloons), + 'balloons': dict(radiosonde_balloons), + }) diff --git a/routes/sensor.py b/routes/sensor.py index ab34c8e..29026fa 100644 --- a/routes/sensor.py +++ b/routes/sensor.py @@ -1,5 +1,5 @@ -"""RTL_433 sensor monitoring routes.""" - +"""RTL_433 sensor monitoring routes.""" + from __future__ import annotations import json @@ -10,25 +10,26 @@ import threading import time from datetime import datetime from typing import Any, Generator - -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_frequency, validate_device_index, validate_gain, validate_ppm, - validate_rtl_tcp_host, validate_rtl_tcp_port -) + +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_frequency, validate_device_index, validate_gain, validate_ppm, + validate_rtl_tcp_host, validate_rtl_tcp_port +) from utils.sse import sse_stream_fanout -from utils.event_pipeline import process_event -from utils.process import safe_terminate, register_process, unregister_process -from utils.sdr import SDRFactory, SDRType - -sensor_bp = Blueprint('sensor', __name__) - -# Track which device is being used -sensor_active_device: int | None = None - +from utils.event_pipeline import process_event +from utils.process import safe_terminate, register_process, unregister_process +from utils.sdr import SDRFactory, SDRType + +sensor_bp = Blueprint('sensor', __name__) + +# Track which device is being used +sensor_active_device: int | None = None +sensor_active_sdr_type: str | None = None + # RSSI history per device (model_id -> list of (timestamp, rssi)) sensor_rssi_history: dict[str, list[tuple[float, float]]] = {} _MAX_RSSI_HISTORY = 60 @@ -65,36 +66,36 @@ def _build_scope_waveform(rssi: float, snr: float, noise: float, points: int = 2 def stream_sensor_output(process: subprocess.Popen[bytes]) -> None: - """Stream rtl_433 JSON output to queue.""" - try: - app_module.sensor_queue.put({'type': 'status', 'text': 'started'}) - - for line in iter(process.stdout.readline, b''): - line = line.decode('utf-8', errors='replace').strip() - if not line: - continue - - try: - # rtl_433 outputs JSON objects, one per line - data = json.loads(line) - data['type'] = 'sensor' - app_module.sensor_queue.put(data) - - # Track RSSI history per device - _model = data.get('model', '') - _dev_id = data.get('id', '') - _rssi_val = data.get('rssi') - if _rssi_val is not None and _model: - _hist_key = f"{_model}_{_dev_id}" - hist = sensor_rssi_history.setdefault(_hist_key, []) - hist.append((time.time(), float(_rssi_val))) - if len(hist) > _MAX_RSSI_HISTORY: - del hist[: len(hist) - _MAX_RSSI_HISTORY] - - # Push scope event when signal level data is present - rssi = data.get('rssi') - snr = data.get('snr') - noise = data.get('noise') + """Stream rtl_433 JSON output to queue.""" + try: + app_module.sensor_queue.put({'type': 'status', 'text': 'started'}) + + for line in iter(process.stdout.readline, b''): + line = line.decode('utf-8', errors='replace').strip() + if not line: + continue + + try: + # rtl_433 outputs JSON objects, one per line + data = json.loads(line) + data['type'] = 'sensor' + app_module.sensor_queue.put(data) + + # Track RSSI history per device + _model = data.get('model', '') + _dev_id = data.get('id', '') + _rssi_val = data.get('rssi') + if _rssi_val is not None and _model: + _hist_key = f"{_model}_{_dev_id}" + hist = sensor_rssi_history.setdefault(_hist_key, []) + hist.append((time.time(), float(_rssi_val))) + if len(hist) > _MAX_RSSI_HISTORY: + del hist[: len(hist) - _MAX_RSSI_HISTORY] + + # Push scope event when signal level data is present + rssi = data.get('rssi') + snr = data.get('snr') + noise = data.get('noise') if rssi is not None or snr is not None: try: rssi_value = float(rssi) if rssi is not None else 0.0 @@ -113,204 +114,211 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None: }) except (TypeError, ValueError, queue.Full): pass - - # Log if enabled - if app_module.logging_enabled: - try: - with open(app_module.log_file_path, 'a') as f: - timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') - f.write(f"{timestamp} | {data.get('model', 'Unknown')} | {json.dumps(data)}\n") - except Exception: - pass - except json.JSONDecodeError: - # Not JSON, send as raw - app_module.sensor_queue.put({'type': 'raw', 'text': line}) - - except Exception as e: - app_module.sensor_queue.put({'type': 'error', 'text': str(e)}) - finally: - global sensor_active_device - # Ensure process is terminated - try: - process.terminate() - process.wait(timeout=2) - except Exception: - try: - process.kill() - except Exception: - pass - unregister_process(process) - app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'}) - with app_module.sensor_lock: - app_module.sensor_process = None - # Release SDR device - if sensor_active_device is not None: - app_module.release_sdr_device(sensor_active_device) - sensor_active_device = None - - -@sensor_bp.route('/sensor/status') -def sensor_status() -> Response: - """Check if sensor decoder is currently running.""" - with app_module.sensor_lock: - running = app_module.sensor_process is not None and app_module.sensor_process.poll() is None - return jsonify({'running': running}) - - -@sensor_bp.route('/start_sensor', methods=['POST']) -def start_sensor() -> Response: - global sensor_active_device - - with app_module.sensor_lock: - if app_module.sensor_process: - return jsonify({'status': 'error', 'message': 'Sensor already running'}), 409 - - data = request.json or {} - - # Validate inputs - try: - freq = validate_frequency(data.get('frequency', '433.92')) - gain = validate_gain(data.get('gain', '0')) - ppm = validate_ppm(data.get('ppm', '0')) - device = validate_device_index(data.get('device', '0')) - except ValueError as e: - return jsonify({'status': 'error', 'message': str(e)}), 400 - - # Check for rtl_tcp (remote SDR) connection - rtl_tcp_host = data.get('rtl_tcp_host') - rtl_tcp_port = data.get('rtl_tcp_port', 1234) - - # Claim local device if not using remote rtl_tcp - if not rtl_tcp_host: - device_int = int(device) - error = app_module.claim_sdr_device(device_int, 'sensor') - if error: - return jsonify({ - 'status': 'error', - 'error_type': 'DEVICE_BUSY', - 'message': error - }), 409 - sensor_active_device = device_int - - # Clear queue - while not app_module.sensor_queue.empty(): - try: - app_module.sensor_queue.get_nowait() - except queue.Empty: - break - - # Get SDR type and build command via abstraction layer - sdr_type_str = data.get('sdr_type', 'rtlsdr') - try: - sdr_type = SDRType(sdr_type_str) - except ValueError: - sdr_type = SDRType.RTL_SDR - - if rtl_tcp_host: - # Validate and create network device - try: - rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host) - rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port) - except ValueError as e: - return jsonify({'status': 'error', 'message': str(e)}), 400 - - sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port) - logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}") - else: - # Create local device object - sdr_device = SDRFactory.create_default_device(sdr_type, index=device) - - builder = SDRFactory.get_builder(sdr_device.sdr_type) - - # Build ISM band decoder command - bias_t = data.get('bias_t', False) - cmd = builder.build_ism_command( - device=sdr_device, - frequency_mhz=freq, - gain=float(gain) if gain and gain != 0 else None, - ppm=int(ppm) if ppm and ppm != 0 else None, - bias_t=bias_t - ) - - full_cmd = ' '.join(cmd) - logger.info(f"Running: {full_cmd}") - - # Add signal level metadata so the frontend scope can display RSSI/SNR - # Disable stats reporting to suppress "row count limit 50 reached" warnings - cmd.extend(['-M', 'level', '-M', 'stats:0']) - - try: - app_module.sensor_process = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ) - register_process(app_module.sensor_process) - - # Start output thread - thread = threading.Thread(target=stream_sensor_output, args=(app_module.sensor_process,)) - thread.daemon = True - thread.start() - - # Monitor stderr - # Filter noisy rtl_433 diagnostics that aren't useful to display - _stderr_noise = ( - 'bitbuffer_add_bit', - 'row count limit', - ) - - def monitor_stderr(): - for line in app_module.sensor_process.stderr: - err = line.decode('utf-8', errors='replace').strip() - if err and not any(noise in err for noise in _stderr_noise): - logger.debug(f"[rtl_433] {err}") - app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'}) - - stderr_thread = threading.Thread(target=monitor_stderr) - stderr_thread.daemon = True - stderr_thread.start() - - app_module.sensor_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'}) - - return jsonify({'status': 'started', 'command': full_cmd}) - - except FileNotFoundError: - # Release device on failure - if sensor_active_device is not None: - app_module.release_sdr_device(sensor_active_device) - sensor_active_device = None - return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'}) - except Exception as e: - # Release device on failure - if sensor_active_device is not None: - app_module.release_sdr_device(sensor_active_device) - sensor_active_device = None - return jsonify({'status': 'error', 'message': str(e)}) - - -@sensor_bp.route('/stop_sensor', methods=['POST']) -def stop_sensor() -> Response: - global sensor_active_device - - with app_module.sensor_lock: - if app_module.sensor_process: - app_module.sensor_process.terminate() - try: - app_module.sensor_process.wait(timeout=2) - except subprocess.TimeoutExpired: - app_module.sensor_process.kill() - app_module.sensor_process = None - - # Release device from registry - if sensor_active_device is not None: - app_module.release_sdr_device(sensor_active_device) - sensor_active_device = None - - return jsonify({'status': 'stopped'}) - - return jsonify({'status': 'not_running'}) - - + + # Log if enabled + if app_module.logging_enabled: + try: + with open(app_module.log_file_path, 'a') as f: + timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + f.write(f"{timestamp} | {data.get('model', 'Unknown')} | {json.dumps(data)}\n") + except Exception: + pass + except json.JSONDecodeError: + # Not JSON, send as raw + app_module.sensor_queue.put({'type': 'raw', 'text': line}) + + except Exception as e: + app_module.sensor_queue.put({'type': 'error', 'text': str(e)}) + finally: + global sensor_active_device, sensor_active_sdr_type + # Ensure process is terminated + try: + process.terminate() + process.wait(timeout=2) + except Exception: + try: + process.kill() + except Exception: + pass + unregister_process(process) + app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'}) + with app_module.sensor_lock: + app_module.sensor_process = None + # Release SDR device + if sensor_active_device is not None: + app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr') + sensor_active_device = None + sensor_active_sdr_type = None + + +@sensor_bp.route('/sensor/status') +def sensor_status() -> Response: + """Check if sensor decoder is currently running.""" + with app_module.sensor_lock: + running = app_module.sensor_process is not None and app_module.sensor_process.poll() is None + return jsonify({'running': running}) + + +@sensor_bp.route('/start_sensor', methods=['POST']) +def start_sensor() -> Response: + global sensor_active_device, sensor_active_sdr_type + + with app_module.sensor_lock: + if app_module.sensor_process: + return jsonify({'status': 'error', 'message': 'Sensor already running'}), 409 + + data = request.json or {} + + # Validate inputs + try: + freq = validate_frequency(data.get('frequency', '433.92')) + gain = validate_gain(data.get('gain', '0')) + ppm = validate_ppm(data.get('ppm', '0')) + device = validate_device_index(data.get('device', '0')) + except ValueError as e: + return jsonify({'status': 'error', 'message': str(e)}), 400 + + # Check for rtl_tcp (remote SDR) connection + rtl_tcp_host = data.get('rtl_tcp_host') + rtl_tcp_port = data.get('rtl_tcp_port', 1234) + + # Get SDR type early so we can pass it to claim/release + sdr_type_str = data.get('sdr_type', 'rtlsdr') + + # Claim local device if not using remote rtl_tcp + if not rtl_tcp_host: + device_int = int(device) + error = app_module.claim_sdr_device(device_int, 'sensor', sdr_type_str) + if error: + return jsonify({ + 'status': 'error', + 'error_type': 'DEVICE_BUSY', + 'message': error + }), 409 + sensor_active_device = device_int + sensor_active_sdr_type = sdr_type_str + + # Clear queue + while not app_module.sensor_queue.empty(): + try: + app_module.sensor_queue.get_nowait() + except queue.Empty: + break + + # Build command via SDR abstraction layer + try: + sdr_type = SDRType(sdr_type_str) + except ValueError: + sdr_type = SDRType.RTL_SDR + + if rtl_tcp_host: + # Validate and create network device + try: + rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host) + rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port) + except ValueError as e: + return jsonify({'status': 'error', 'message': str(e)}), 400 + + sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port) + logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}") + else: + # Create local device object + sdr_device = SDRFactory.create_default_device(sdr_type, index=device) + + builder = SDRFactory.get_builder(sdr_device.sdr_type) + + # Build ISM band decoder command + bias_t = data.get('bias_t', False) + cmd = builder.build_ism_command( + device=sdr_device, + frequency_mhz=freq, + gain=float(gain) if gain and gain != 0 else None, + ppm=int(ppm) if ppm and ppm != 0 else None, + bias_t=bias_t + ) + + full_cmd = ' '.join(cmd) + logger.info(f"Running: {full_cmd}") + + # Add signal level metadata so the frontend scope can display RSSI/SNR + # Disable stats reporting to suppress "row count limit 50 reached" warnings + cmd.extend(['-M', 'level', '-M', 'stats:0']) + + try: + app_module.sensor_process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + register_process(app_module.sensor_process) + + # Start output thread + thread = threading.Thread(target=stream_sensor_output, args=(app_module.sensor_process,)) + thread.daemon = True + thread.start() + + # Monitor stderr + # Filter noisy rtl_433 diagnostics that aren't useful to display + _stderr_noise = ( + 'bitbuffer_add_bit', + 'row count limit', + ) + + def monitor_stderr(): + for line in app_module.sensor_process.stderr: + err = line.decode('utf-8', errors='replace').strip() + if err and not any(noise in err for noise in _stderr_noise): + logger.debug(f"[rtl_433] {err}") + app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'}) + + stderr_thread = threading.Thread(target=monitor_stderr) + stderr_thread.daemon = True + stderr_thread.start() + + app_module.sensor_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'}) + + return jsonify({'status': 'started', 'command': full_cmd}) + + except FileNotFoundError: + # Release device on failure + if sensor_active_device is not None: + app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr') + sensor_active_device = None + sensor_active_sdr_type = None + return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'}) + except Exception as e: + # Release device on failure + if sensor_active_device is not None: + app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr') + sensor_active_device = None + sensor_active_sdr_type = None + return jsonify({'status': 'error', 'message': str(e)}) + + +@sensor_bp.route('/stop_sensor', methods=['POST']) +def stop_sensor() -> Response: + global sensor_active_device, sensor_active_sdr_type + + with app_module.sensor_lock: + if app_module.sensor_process: + app_module.sensor_process.terminate() + try: + app_module.sensor_process.wait(timeout=2) + except subprocess.TimeoutExpired: + app_module.sensor_process.kill() + app_module.sensor_process = None + + # Release device from registry + if sensor_active_device is not None: + app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr') + sensor_active_device = None + sensor_active_sdr_type = None + + return jsonify({'status': 'stopped'}) + + return jsonify({'status': 'not_running'}) + + @sensor_bp.route('/stream_sensor') def stream_sensor() -> Response: def _on_msg(msg: dict[str, Any]) -> None: @@ -330,12 +338,12 @@ def stream_sensor() -> Response: response.headers['X-Accel-Buffering'] = 'no' response.headers['Connection'] = 'keep-alive' return response - - -@sensor_bp.route('/sensor/rssi_history') -def get_rssi_history() -> Response: - """Return RSSI history for all tracked sensor devices.""" - result = {} - for key, entries in sensor_rssi_history.items(): - result[key] = [{'t': round(t, 1), 'rssi': rssi} for t, rssi in entries] - return jsonify({'status': 'success', 'devices': result}) + + +@sensor_bp.route('/sensor/rssi_history') +def get_rssi_history() -> Response: + """Return RSSI history for all tracked sensor devices.""" + result = {} + for key, entries in sensor_rssi_history.items(): + result[key] = [{'t': round(t, 1), 'rssi': rssi} for t, rssi in entries] + return jsonify({'status': 'success', 'devices': result}) diff --git a/routes/system.py b/routes/system.py index 839899d..4a96aad 100644 --- a/routes/system.py +++ b/routes/system.py @@ -1,7 +1,8 @@ """System Health monitoring blueprint. -Provides real-time system metrics (CPU, memory, disk, temperatures), -active process status, and SDR device enumeration via SSE streaming. +Provides real-time system metrics (CPU, memory, disk, temperatures, +network, battery, fans), active process status, SDR device enumeration, +location, and weather data via SSE streaming and REST endpoints. """ from __future__ import annotations @@ -11,11 +12,13 @@ import os import platform import queue import socket +import subprocess import threading import time +from pathlib import Path from typing import Any -from flask import Blueprint, Response, jsonify +from flask import Blueprint, Response, jsonify, request from utils.constants import SSE_KEEPALIVE_INTERVAL, SSE_QUEUE_TIMEOUT from utils.logging import sensor_logger as logger @@ -29,6 +32,11 @@ except ImportError: psutil = None # type: ignore[assignment] _HAS_PSUTIL = False +try: + import requests as _requests +except ImportError: + _requests = None # type: ignore[assignment] + system_bp = Blueprint('system', __name__, url_prefix='/system') # --------------------------------------------------------------------------- @@ -40,6 +48,11 @@ _collector_started = False _collector_lock = threading.Lock() _app_start_time: float | None = None +# Weather cache +_weather_cache: dict[str, Any] = {} +_weather_cache_time: float = 0.0 +_WEATHER_CACHE_TTL = 600 # 10 minutes + def _get_app_start_time() -> float: """Return the application start timestamp from the main app module.""" @@ -138,6 +151,38 @@ def _collect_process_status() -> dict[str, bool]: return {} +def _collect_throttle_flags() -> str | None: + """Read Raspberry Pi throttle flags via vcgencmd (Linux/Pi only).""" + try: + result = subprocess.run( + ['vcgencmd', 'get_throttled'], + capture_output=True, + text=True, + timeout=2, + ) + if result.returncode == 0 and 'throttled=' in result.stdout: + return result.stdout.strip().split('=', 1)[1] + except Exception: + pass + return None + + +def _collect_power_draw() -> float | None: + """Read power draw in watts from sysfs (Linux only).""" + try: + power_supply = Path('/sys/class/power_supply') + if not power_supply.exists(): + return None + for supply_dir in power_supply.iterdir(): + power_file = supply_dir / 'power_now' + if power_file.exists(): + val = int(power_file.read_text().strip()) + return round(val / 1_000_000, 2) # microwatts to watts + except Exception: + pass + return None + + def _collect_metrics() -> dict[str, Any]: """Gather a snapshot of system metrics.""" now = time.time() @@ -159,7 +204,7 @@ def _collect_metrics() -> dict[str, Any]: } if _HAS_PSUTIL: - # CPU + # CPU — overall + per-core + frequency cpu_percent = psutil.cpu_percent(interval=None) cpu_count = psutil.cpu_count() or 1 try: @@ -167,12 +212,28 @@ def _collect_metrics() -> dict[str, Any]: except (OSError, AttributeError): load_1 = load_5 = load_15 = 0.0 + per_core = [] + with contextlib.suppress(Exception): + per_core = psutil.cpu_percent(interval=None, percpu=True) + + freq_data = None + with contextlib.suppress(Exception): + freq = psutil.cpu_freq() + if freq: + freq_data = { + 'current': round(freq.current, 0), + 'min': round(freq.min, 0), + 'max': round(freq.max, 0), + } + metrics['cpu'] = { 'percent': cpu_percent, 'count': cpu_count, 'load_1': round(load_1, 2), 'load_5': round(load_5, 2), 'load_15': round(load_15, 2), + 'per_core': per_core, + 'freq': freq_data, } # Memory @@ -191,7 +252,7 @@ def _collect_metrics() -> dict[str, Any]: 'percent': swap.percent, } - # Disk + # Disk — usage + I/O counters try: disk = psutil.disk_usage('/') metrics['disk'] = { @@ -204,6 +265,18 @@ def _collect_metrics() -> dict[str, Any]: except Exception: metrics['disk'] = None + disk_io = None + with contextlib.suppress(Exception): + dio = psutil.disk_io_counters() + if dio: + disk_io = { + 'read_bytes': dio.read_bytes, + 'write_bytes': dio.write_bytes, + 'read_count': dio.read_count, + 'write_count': dio.write_count, + } + metrics['disk_io'] = disk_io + # Temperatures try: temps = psutil.sensors_temperatures() @@ -224,12 +297,102 @@ def _collect_metrics() -> dict[str, Any]: metrics['temperatures'] = None except (AttributeError, Exception): metrics['temperatures'] = None + + # Fans + fans_data = None + with contextlib.suppress(Exception): + fans = psutil.sensors_fans() + if fans: + fans_data = {} + for chip, entries in fans.items(): + fans_data[chip] = [ + {'label': e.label or chip, 'current': e.current} + for e in entries + ] + metrics['fans'] = fans_data + + # Battery + battery_data = None + with contextlib.suppress(Exception): + bat = psutil.sensors_battery() + if bat: + battery_data = { + 'percent': bat.percent, + 'plugged': bat.power_plugged, + 'secs_left': bat.secsleft if bat.secsleft != psutil.POWER_TIME_UNLIMITED else None, + } + metrics['battery'] = battery_data + + # Network interfaces + net_ifaces: list[dict[str, Any]] = [] + with contextlib.suppress(Exception): + addrs = psutil.net_if_addrs() + stats = psutil.net_if_stats() + for iface_name in sorted(addrs.keys()): + if iface_name == 'lo': + continue + iface_info: dict[str, Any] = {'name': iface_name} + # Get addresses + for addr in addrs[iface_name]: + if addr.family == socket.AF_INET: + iface_info['ipv4'] = addr.address + elif addr.family == socket.AF_INET6: + iface_info.setdefault('ipv6', addr.address) + elif addr.family == psutil.AF_LINK: + iface_info['mac'] = addr.address + # Get stats + if iface_name in stats: + st = stats[iface_name] + iface_info['is_up'] = st.isup + iface_info['speed'] = st.speed # Mbps + iface_info['mtu'] = st.mtu + net_ifaces.append(iface_info) + metrics['network'] = {'interfaces': net_ifaces} + + # Network I/O counters (raw — JS computes deltas) + net_io = None + with contextlib.suppress(Exception): + counters = psutil.net_io_counters(pernic=True) + if counters: + net_io = {} + for nic, c in counters.items(): + if nic == 'lo': + continue + net_io[nic] = { + 'bytes_sent': c.bytes_sent, + 'bytes_recv': c.bytes_recv, + } + metrics['network']['io'] = net_io + + # Connection count + conn_count = 0 + with contextlib.suppress(Exception): + conn_count = len(psutil.net_connections()) + metrics['network']['connections'] = conn_count + + # Boot time + boot_ts = None + with contextlib.suppress(Exception): + boot_ts = psutil.boot_time() + metrics['boot_time'] = boot_ts + + # Power / throttle (Pi-specific) + metrics['power'] = { + 'throttled': _collect_throttle_flags(), + 'draw_watts': _collect_power_draw(), + } else: metrics['cpu'] = None metrics['memory'] = None metrics['swap'] = None metrics['disk'] = None + metrics['disk_io'] = None metrics['temperatures'] = None + metrics['fans'] = None + metrics['battery'] = None + metrics['network'] = None + metrics['boot_time'] = None + metrics['power'] = None return metrics @@ -270,6 +433,47 @@ def _ensure_collector() -> None: logger.info('System metrics collector started') +def _get_observer_location() -> dict[str, Any]: + """Get observer location from GPS state or config defaults.""" + lat, lon, source = None, None, 'none' + gps_meta: dict[str, Any] = {} + + # Try GPS via utils.gps + with contextlib.suppress(Exception): + from utils.gps import get_current_position + + pos = get_current_position() + if pos and pos.fix_quality >= 2: + lat, lon, source = pos.latitude, pos.longitude, 'gps' + gps_meta['fix_quality'] = pos.fix_quality + gps_meta['satellites'] = pos.satellites + if pos.epx is not None and pos.epy is not None: + gps_meta['accuracy'] = round(max(pos.epx, pos.epy), 1) + if pos.altitude is not None: + gps_meta['altitude'] = round(pos.altitude, 1) + + # Fall back to config env vars + if lat is None: + with contextlib.suppress(Exception): + from config import DEFAULT_LATITUDE, DEFAULT_LONGITUDE + + if DEFAULT_LATITUDE != 0.0 or DEFAULT_LONGITUDE != 0.0: + lat, lon, source = DEFAULT_LATITUDE, DEFAULT_LONGITUDE, 'config' + + # Fall back to hardcoded constants (London) + if lat is None: + with contextlib.suppress(Exception): + from utils.constants import DEFAULT_LATITUDE as CONST_LAT + from utils.constants import DEFAULT_LONGITUDE as CONST_LON + + lat, lon, source = CONST_LAT, CONST_LON, 'default' + + result: dict[str, Any] = {'lat': lat, 'lon': lon, 'source': source} + if gps_meta: + result['gps'] = gps_meta + return result + + # --------------------------------------------------------------------------- # Routes # --------------------------------------------------------------------------- @@ -321,3 +525,59 @@ def get_sdr_devices() -> Response: except Exception as exc: logger.warning('SDR device detection failed: %s', exc) return jsonify({'devices': [], 'error': str(exc)}) + + +@system_bp.route('/location') +def get_location() -> Response: + """Return observer location from GPS or config.""" + return jsonify(_get_observer_location()) + + +@system_bp.route('/weather') +def get_weather() -> Response: + """Proxy weather from wttr.in, cached for 10 minutes.""" + global _weather_cache, _weather_cache_time + + now = time.time() + if _weather_cache and (now - _weather_cache_time) < _WEATHER_CACHE_TTL: + return jsonify(_weather_cache) + + lat = request.args.get('lat', type=float) + lon = request.args.get('lon', type=float) + if lat is None or lon is None: + loc = _get_observer_location() + lat, lon = loc.get('lat'), loc.get('lon') + + if lat is None or lon is None: + return jsonify({'error': 'No location available'}) + + if _requests is None: + return jsonify({'error': 'requests library not available'}) + + try: + resp = _requests.get( + f'https://wttr.in/{lat},{lon}?format=j1', + timeout=5, + headers={'User-Agent': 'INTERCEPT-SystemHealth/1.0'}, + ) + resp.raise_for_status() + data = resp.json() + + current = data.get('current_condition', [{}])[0] + weather = { + 'temp_c': current.get('temp_C'), + 'temp_f': current.get('temp_F'), + 'condition': current.get('weatherDesc', [{}])[0].get('value', ''), + 'humidity': current.get('humidity'), + 'wind_mph': current.get('windspeedMiles'), + 'wind_dir': current.get('winddir16Point'), + 'feels_like_c': current.get('FeelsLikeC'), + 'visibility': current.get('visibility'), + 'pressure': current.get('pressure'), + } + _weather_cache = weather + _weather_cache_time = now + return jsonify(weather) + except Exception as exc: + logger.debug('Weather fetch failed: %s', exc) + return jsonify({'error': str(exc)}) diff --git a/routes/vdl2.py b/routes/vdl2.py index 1714f34..abe9ca6 100644 --- a/routes/vdl2.py +++ b/routes/vdl2.py @@ -48,6 +48,7 @@ vdl2_last_message_time = None # Track which device is being used vdl2_active_device: int | None = None +vdl2_active_sdr_type: str | None = None def find_dumpvdl2(): @@ -126,7 +127,7 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) -> logger.error(f"VDL2 stream error: {e}") app_module.vdl2_queue.put({'type': 'error', 'message': str(e)}) finally: - global vdl2_active_device + global vdl2_active_device, vdl2_active_sdr_type # Ensure process is terminated try: process.terminate() @@ -142,8 +143,9 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) -> app_module.vdl2_process = None # Release SDR device if vdl2_active_device is not None: - app_module.release_sdr_device(vdl2_active_device) + app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr') vdl2_active_device = None + vdl2_active_sdr_type = None @vdl2_bp.route('/tools') @@ -175,7 +177,7 @@ def vdl2_status() -> Response: @vdl2_bp.route('/start', methods=['POST']) def start_vdl2() -> Response: """Start VDL2 decoder.""" - global vdl2_message_count, vdl2_last_message_time, vdl2_active_device + global vdl2_message_count, vdl2_last_message_time, vdl2_active_device, vdl2_active_sdr_type with app_module.vdl2_lock: if app_module.vdl2_process and app_module.vdl2_process.poll() is None: @@ -202,9 +204,16 @@ def start_vdl2() -> Response: except ValueError as e: return jsonify({'status': 'error', 'message': str(e)}), 400 + # Resolve SDR type for device selection + sdr_type_str = data.get('sdr_type', 'rtlsdr') + try: + sdr_type = SDRType(sdr_type_str) + except ValueError: + sdr_type = SDRType.RTL_SDR + # Check if device is available device_int = int(device) - error = app_module.claim_sdr_device(device_int, 'vdl2') + error = app_module.claim_sdr_device(device_int, 'vdl2', sdr_type_str) if error: return jsonify({ 'status': 'error', @@ -213,6 +222,7 @@ def start_vdl2() -> Response: }), 409 vdl2_active_device = device_int + vdl2_active_sdr_type = sdr_type_str # Get frequencies - use provided or defaults # dumpvdl2 expects frequencies in Hz (integers) @@ -231,13 +241,6 @@ def start_vdl2() -> Response: vdl2_message_count = 0 vdl2_last_message_time = None - # Resolve SDR type for device selection - sdr_type_str = data.get('sdr_type', 'rtlsdr') - try: - sdr_type = SDRType(sdr_type_str) - except ValueError: - sdr_type = SDRType.RTL_SDR - is_soapy = sdr_type not in (SDRType.RTL_SDR,) # Build dumpvdl2 command @@ -297,8 +300,9 @@ def start_vdl2() -> Response: if process.poll() is not None: # Process died - release device if vdl2_active_device is not None: - app_module.release_sdr_device(vdl2_active_device) + app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr') vdl2_active_device = None + vdl2_active_sdr_type = None stderr = '' if process.stderr: stderr = process.stderr.read().decode('utf-8', errors='replace') @@ -329,8 +333,9 @@ def start_vdl2() -> Response: except Exception as e: # Release device on failure if vdl2_active_device is not None: - app_module.release_sdr_device(vdl2_active_device) + app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr') vdl2_active_device = None + vdl2_active_sdr_type = None logger.error(f"Failed to start VDL2 decoder: {e}") return jsonify({'status': 'error', 'message': str(e)}), 500 @@ -338,7 +343,7 @@ def start_vdl2() -> Response: @vdl2_bp.route('/stop', methods=['POST']) def stop_vdl2() -> Response: """Stop VDL2 decoder.""" - global vdl2_active_device + global vdl2_active_device, vdl2_active_sdr_type with app_module.vdl2_lock: if not app_module.vdl2_process: @@ -359,8 +364,9 @@ def stop_vdl2() -> Response: # Release device from registry if vdl2_active_device is not None: - app_module.release_sdr_device(vdl2_active_device) + app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr') vdl2_active_device = None + vdl2_active_sdr_type = None return jsonify({'status': 'stopped'}) @@ -386,6 +392,7 @@ def stream_vdl2() -> Response: return response + @vdl2_bp.route('/messages') def get_vdl2_messages() -> Response: """Get recent VDL2 messages from correlator (for history reload).""" diff --git a/routes/waterfall_websocket.py b/routes/waterfall_websocket.py index de31227..99ceab3 100644 --- a/routes/waterfall_websocket.py +++ b/routes/waterfall_websocket.py @@ -367,6 +367,7 @@ def init_waterfall_websocket(app: Flask): reader_thread = None stop_event = threading.Event() claimed_device = None + claimed_sdr_type = 'rtlsdr' my_generation = None # tracks which capture generation this handler owns capture_center_mhz = 0.0 capture_start_freq = 0.0 @@ -430,8 +431,9 @@ def init_waterfall_websocket(app: Flask): unregister_process(iq_process) iq_process = None if claimed_device is not None: - app_module.release_sdr_device(claimed_device) + app_module.release_sdr_device(claimed_device, claimed_sdr_type) claimed_device = None + claimed_sdr_type = 'rtlsdr' _set_shared_capture_state(running=False, generation=my_generation) my_generation = None stop_event.clear() @@ -513,7 +515,7 @@ def init_waterfall_websocket(app: Flask): max_claim_attempts = 4 if was_restarting else 1 claim_err = None for _claim_attempt in range(max_claim_attempts): - claim_err = app_module.claim_sdr_device(device_index, 'waterfall') + claim_err = app_module.claim_sdr_device(device_index, 'waterfall', sdr_type_str) if not claim_err: break if _claim_attempt < max_claim_attempts - 1: @@ -526,6 +528,7 @@ def init_waterfall_websocket(app: Flask): })) continue claimed_device = device_index + claimed_sdr_type = sdr_type_str # Build I/Q capture command try: @@ -539,8 +542,9 @@ def init_waterfall_websocket(app: Flask): bias_t=bias_t, ) except NotImplementedError as e: - app_module.release_sdr_device(device_index) + app_module.release_sdr_device(device_index, sdr_type_str) claimed_device = None + claimed_sdr_type = 'rtlsdr' ws.send(json.dumps({ 'status': 'error', 'message': str(e), @@ -549,8 +553,9 @@ def init_waterfall_websocket(app: Flask): # Pre-flight: check the capture binary exists if not shutil.which(iq_cmd[0]): - app_module.release_sdr_device(device_index) + app_module.release_sdr_device(device_index, sdr_type_str) claimed_device = None + claimed_sdr_type = 'rtlsdr' ws.send(json.dumps({ 'status': 'error', 'message': f'Required tool "{iq_cmd[0]}" not found. Install SoapySDR tools (rx_sdr).', @@ -602,8 +607,9 @@ def init_waterfall_websocket(app: Flask): safe_terminate(iq_process) unregister_process(iq_process) iq_process = None - app_module.release_sdr_device(device_index) + app_module.release_sdr_device(device_index, sdr_type_str) claimed_device = None + claimed_sdr_type = 'rtlsdr' ws.send(json.dumps({ 'status': 'error', 'message': f'Failed to start I/Q capture: {e}', @@ -806,8 +812,9 @@ def init_waterfall_websocket(app: Flask): unregister_process(iq_process) iq_process = None if claimed_device is not None: - app_module.release_sdr_device(claimed_device) + app_module.release_sdr_device(claimed_device, claimed_sdr_type) claimed_device = None + claimed_sdr_type = 'rtlsdr' _set_shared_capture_state(running=False, generation=my_generation) my_generation = None stop_event.clear() @@ -825,7 +832,7 @@ def init_waterfall_websocket(app: Flask): safe_terminate(iq_process) unregister_process(iq_process) if claimed_device is not None: - app_module.release_sdr_device(claimed_device) + app_module.release_sdr_device(claimed_device, claimed_sdr_type) _set_shared_capture_state(running=False, generation=my_generation) # Complete WebSocket close handshake, then shut down the # raw socket so Werkzeug cannot write its HTTP 200 response diff --git a/routes/wefax.py b/routes/wefax.py index 497cdf0..401672a 100644 --- a/routes/wefax.py +++ b/routes/wefax.py @@ -33,11 +33,12 @@ _wefax_queue: queue.Queue = queue.Queue(maxsize=100) # Track active SDR device wefax_active_device: int | None = None +wefax_active_sdr_type: str | None = None def _progress_callback(data: dict) -> None: """Callback to queue progress updates for SSE stream.""" - global wefax_active_device + global wefax_active_device, wefax_active_sdr_type try: _wefax_queue.put_nowait(data) @@ -56,8 +57,9 @@ def _progress_callback(data: dict) -> None: and data.get('status') in ('complete', 'error', 'stopped') and wefax_active_device is not None ): - app_module.release_sdr_device(wefax_active_device) + app_module.release_sdr_device(wefax_active_device, wefax_active_sdr_type or 'rtlsdr') wefax_active_device = None + wefax_active_sdr_type = None @wefax_bp.route('/status') @@ -169,9 +171,9 @@ def start_decoder(): }), 400 # Claim SDR device - global wefax_active_device + global wefax_active_device, wefax_active_sdr_type device_int = int(device_index) - error = app_module.claim_sdr_device(device_int, 'wefax') + error = app_module.claim_sdr_device(device_int, 'wefax', sdr_type_str) if error: return jsonify({ 'status': 'error', @@ -194,6 +196,7 @@ def start_decoder(): if success: wefax_active_device = device_int + wefax_active_sdr_type = sdr_type_str return jsonify({ 'status': 'started', 'frequency_khz': frequency_khz, @@ -209,7 +212,7 @@ def start_decoder(): 'device': device_int, }) else: - app_module.release_sdr_device(device_int) + app_module.release_sdr_device(device_int, sdr_type_str) return jsonify({ 'status': 'error', 'message': 'Failed to start decoder', @@ -219,13 +222,14 @@ def start_decoder(): @wefax_bp.route('/stop', methods=['POST']) def stop_decoder(): """Stop WeFax decoder.""" - global wefax_active_device + global wefax_active_device, wefax_active_sdr_type decoder = get_wefax_decoder() decoder.stop() if wefax_active_device is not None: - app_module.release_sdr_device(wefax_active_device) + app_module.release_sdr_device(wefax_active_device, wefax_active_sdr_type or 'rtlsdr') wefax_active_device = None + wefax_active_sdr_type = None return jsonify({'status': 'stopped'}) diff --git a/setup.sh b/setup.sh index 148fd6f..a31cb04 100755 --- a/setup.sh +++ b/setup.sh @@ -229,6 +229,7 @@ check_tools() { check_optional "dumpvdl2" "VDL2 decoder" dumpvdl2 check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher check_optional "satdump" "Weather satellite decoder (NOAA/Meteor)" satdump + check_optional "auto_rx.py" "Radiosonde weather balloon decoder" auto_rx.py echo info "GPS:" check_required "gpsd" "GPS daemon" gpsd @@ -816,6 +817,53 @@ WRAPPER ) } +install_radiosonde_auto_rx() { + info "Installing radiosonde_auto_rx (weather balloon decoder)..." + local install_dir="/opt/radiosonde_auto_rx" + local project_dir="$(pwd)" + + ( + tmp_dir="$(mktemp -d)" + trap 'rm -rf "$tmp_dir"' EXIT + + info "Cloning radiosonde_auto_rx..." + if ! git clone --depth 1 https://github.com/projecthorus/radiosonde_auto_rx.git "$tmp_dir/radiosonde_auto_rx"; then + warn "Failed to clone radiosonde_auto_rx" + exit 1 + fi + + info "Installing Python dependencies..." + cd "$tmp_dir/radiosonde_auto_rx/auto_rx" + # Use project venv pip to avoid PEP 668 externally-managed-environment errors + if [ -x "$project_dir/venv/bin/pip" ]; then + "$project_dir/venv/bin/pip" install --quiet -r requirements.txt || { + warn "Failed to install radiosonde_auto_rx Python dependencies" + exit 1 + } + else + pip3 install --quiet --break-system-packages -r requirements.txt 2>/dev/null \ + || pip3 install --quiet -r requirements.txt || { + warn "Failed to install radiosonde_auto_rx Python dependencies" + exit 1 + } + fi + + info "Building radiosonde_auto_rx C decoders..." + if ! bash build.sh; then + warn "Failed to build radiosonde_auto_rx decoders" + exit 1 + fi + + info "Installing to ${install_dir}..." + refresh_sudo + $SUDO mkdir -p "$install_dir/auto_rx" + $SUDO cp -r . "$install_dir/auto_rx/" + $SUDO chmod +x "$install_dir/auto_rx/auto_rx.py" + + ok "radiosonde_auto_rx installed to ${install_dir}" + ) +} + install_macos_packages() { need_sudo @@ -825,7 +873,7 @@ install_macos_packages() { sudo -v || { fail "sudo authentication failed"; exit 1; } fi - TOTAL_STEPS=21 + TOTAL_STEPS=22 CURRENT_STEP=0 progress "Checking Homebrew" @@ -912,6 +960,20 @@ install_macos_packages() { ok "SatDump already installed" fi + progress "Installing radiosonde_auto_rx (optional)" + if ! cmd_exists auto_rx.py && [ ! -f /opt/radiosonde_auto_rx/auto_rx/auto_rx.py ] \ + || { [ -f /opt/radiosonde_auto_rx/auto_rx/auto_rx.py ] && [ ! -f /opt/radiosonde_auto_rx/auto_rx/dft_detect ]; }; then + echo + info "radiosonde_auto_rx is used for weather balloon (radiosonde) tracking." + if ask_yes_no "Do you want to install radiosonde_auto_rx?"; then + install_radiosonde_auto_rx || warn "radiosonde_auto_rx installation failed. Radiosonde tracking will not be available." + else + warn "Skipping radiosonde_auto_rx. You can install it later if needed." + fi + else + ok "radiosonde_auto_rx already installed" + fi + progress "Installing aircrack-ng" brew_install aircrack-ng @@ -1303,7 +1365,7 @@ install_debian_packages() { export NEEDRESTART_MODE=a fi - TOTAL_STEPS=27 + TOTAL_STEPS=28 CURRENT_STEP=0 progress "Updating APT package lists" @@ -1485,6 +1547,20 @@ install_debian_packages() { ok "SatDump already installed" fi + progress "Installing radiosonde_auto_rx (optional)" + if ! cmd_exists auto_rx.py && [ ! -f /opt/radiosonde_auto_rx/auto_rx/auto_rx.py ] \ + || { [ -f /opt/radiosonde_auto_rx/auto_rx/auto_rx.py ] && [ ! -f /opt/radiosonde_auto_rx/auto_rx/dft_detect ]; }; then + echo + info "radiosonde_auto_rx is used for weather balloon (radiosonde) tracking." + if ask_yes_no "Do you want to install radiosonde_auto_rx?"; then + install_radiosonde_auto_rx || warn "radiosonde_auto_rx installation failed. Radiosonde tracking will not be available." + else + warn "Skipping radiosonde_auto_rx. You can install it later if needed." + fi + else + ok "radiosonde_auto_rx already installed" + fi + progress "Configuring udev rules" setup_udev_rules_debian diff --git a/static/css/modes/radiosonde.css b/static/css/modes/radiosonde.css new file mode 100644 index 0000000..9cb503a --- /dev/null +++ b/static/css/modes/radiosonde.css @@ -0,0 +1,152 @@ +/* ============================================ + RADIOSONDE MODE — Scoped Styles + ============================================ */ + +/* Visuals container */ +.radiosonde-visuals-container { + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; + min-height: 0; + overflow: hidden; + padding: 8px; +} + +/* Map container */ +#radiosondeMapContainer { + flex: 1; + min-height: 300px; + border-radius: 6px; + border: 1px solid var(--border-color); + background: var(--bg-primary); +} + +/* Card container below map */ +.radiosonde-card-container { + display: flex; + flex-wrap: wrap; + gap: 8px; + max-height: 200px; + overflow-y: auto; + padding: 4px 0; +} + +/* Individual balloon card */ +.radiosonde-card { + background: var(--bg-card, #1a1e2e); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 10px 12px; + cursor: pointer; + flex: 1 1 280px; + min-width: 260px; + max-width: 400px; + transition: border-color 0.2s ease, background 0.2s ease; +} + +.radiosonde-card:hover { + border-color: var(--accent-cyan); + background: rgba(0, 204, 255, 0.04); +} + +.radiosonde-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + padding-bottom: 6px; + border-bottom: 1px solid var(--border-color); +} + +.radiosonde-serial { + font-family: var(--font-mono, 'JetBrains Mono', monospace); + font-size: 13px; + font-weight: 600; + color: var(--accent-cyan); + letter-spacing: 0.5px; +} + +.radiosonde-type { + font-family: var(--font-mono, 'JetBrains Mono', monospace); + font-size: 10px; + font-weight: 600; + color: var(--text-dim); + background: rgba(255, 255, 255, 0.06); + padding: 2px 6px; + border-radius: 3px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Telemetry stat grid */ +.radiosonde-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; +} + +.radiosonde-stat { + display: flex; + flex-direction: column; + align-items: center; + padding: 4px; +} + +.radiosonde-stat-value { + font-family: var(--font-mono, 'JetBrains Mono', monospace); + font-size: 12px; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; +} + +.radiosonde-stat-label { + font-size: 9px; + font-weight: 600; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.8px; + margin-top: 2px; +} + +/* Leaflet popup overrides for radiosonde */ +#radiosondeMapContainer .leaflet-popup-content-wrapper { + background: var(--bg-card, #1a1e2e); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 6px; + font-family: var(--font-mono, 'JetBrains Mono', monospace); + font-size: 11px; +} + +#radiosondeMapContainer .leaflet-popup-tip { + background: var(--bg-card, #1a1e2e); + border: 1px solid var(--border-color); +} + +/* Scrollbar for card container */ +.radiosonde-card-container::-webkit-scrollbar { + width: 4px; +} + +.radiosonde-card-container::-webkit-scrollbar-track { + background: transparent; +} + +.radiosonde-card-container::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 2px; +} + +/* Responsive: stack cards on narrow screens */ +@media (max-width: 600px) { + .radiosonde-card { + flex: 1 1 100%; + max-width: 100%; + } + + .radiosonde-stats { + grid-template-columns: repeat(2, 1fr); + } +} diff --git a/static/css/modes/system.css b/static/css/modes/system.css index 3efd245..60b66e4 100644 --- a/static/css/modes/system.css +++ b/static/css/modes/system.css @@ -1,20 +1,45 @@ -/* System Health Mode Styles */ +/* System Health Mode Styles — Enhanced Dashboard */ .sys-dashboard { display: grid; - grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + grid-template-columns: repeat(3, 1fr); gap: 16px; padding: 16px; width: 100%; box-sizing: border-box; } +/* Group headers span full width */ +.sys-group-header { + grid-column: 1 / -1; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--accent-cyan, #00d4ff); + border-bottom: 1px solid rgba(0, 212, 255, 0.2); + padding-bottom: 6px; + margin-top: 8px; +} + +.sys-group-header:first-child { + margin-top: 0; +} + +/* Cards */ .sys-card { background: var(--bg-card, #1a1a2e); border: 1px solid var(--border-color, #2a2a4a); border-radius: 6px; padding: 16px; - min-height: 120px; +} + +.sys-card-wide { + grid-column: span 2; +} + +.sys-card-full { + grid-column: 1 / -1; } .sys-card-header { @@ -99,7 +124,285 @@ font-size: 11px; } -/* Process items */ +/* SVG Arc Gauge */ +.sys-gauge-wrap { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 12px; +} + +.sys-gauge-arc { + position: relative; + width: 110px; + height: 110px; + flex-shrink: 0; +} + +.sys-gauge-arc svg { + width: 100%; + height: 100%; +} + +.sys-gauge-arc .arc-bg { + fill: none; + stroke: var(--bg-primary, #0d0d1a); + stroke-width: 8; + stroke-linecap: round; +} + +.sys-gauge-arc .arc-fill { + fill: none; + stroke-width: 8; + stroke-linecap: round; + transition: stroke-dashoffset 0.6s ease, stroke 0.3s ease; + filter: drop-shadow(0 0 4px currentColor); +} + +.sys-gauge-arc .arc-fill.ok { stroke: var(--accent-green, #00ff88); } +.sys-gauge-arc .arc-fill.warn { stroke: var(--accent-yellow, #ffcc00); } +.sys-gauge-arc .arc-fill.crit { stroke: var(--accent-red, #ff3366); } + +.sys-gauge-label { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 22px; + font-weight: 700; + font-family: var(--font-mono, 'JetBrains Mono', monospace); + color: var(--text-primary, #e0e0ff); +} + +.sys-gauge-details { + flex: 1; + font-size: 12px; +} + +/* Per-core bars */ +.sys-core-bars { + display: flex; + gap: 4px; + align-items: flex-end; + height: 48px; + margin-top: 12px; +} + +.sys-core-bar { + flex: 1; + background: var(--bg-primary, #0d0d1a); + border-radius: 3px; + position: relative; + min-width: 6px; + max-width: 32px; + height: 100%; +} + +.sys-core-bar-fill { + position: absolute; + bottom: 0; + left: 0; + right: 0; + border-radius: 2px; + transition: height 0.4s ease, background 0.3s ease; +} + +/* Temperature sparkline */ +.sys-sparkline-wrap { + margin: 8px 0; +} + +.sys-sparkline-wrap svg { + width: 100%; + height: 40px; +} + +.sys-sparkline-line { + fill: none; + stroke: var(--accent-cyan, #00d4ff); + stroke-width: 1.5; + filter: drop-shadow(0 0 2px rgba(0, 212, 255, 0.4)); +} + +.sys-sparkline-area { + fill: url(#sparkGradient); + opacity: 0.3; +} + +.sys-temp-big { + font-size: 28px; + font-weight: 700; + font-family: var(--font-mono, 'JetBrains Mono', monospace); + color: var(--text-primary, #e0e0ff); + margin-bottom: 4px; +} + +/* Network interface rows */ +.sys-net-iface { + padding: 6px 0; + border-bottom: 1px solid var(--border-color, #2a2a4a); +} + +.sys-net-iface:last-child { + border-bottom: none; +} + +.sys-net-iface-name { + font-weight: 700; + color: var(--accent-cyan, #00d4ff); + font-size: 11px; +} + +.sys-net-iface-ip { + font-family: var(--font-mono, 'JetBrains Mono', monospace); + font-size: 12px; + color: var(--text-primary, #e0e0ff); +} + +.sys-net-iface-detail { + font-size: 10px; + color: var(--text-dim, #8888aa); +} + +/* Bandwidth arrows */ +.sys-bandwidth { + display: flex; + gap: 12px; + margin-top: 4px; + font-family: var(--font-mono, 'JetBrains Mono', monospace); + font-size: 11px; +} + +.sys-bw-up { + color: var(--accent-green, #00ff88); +} + +.sys-bw-down { + color: var(--accent-cyan, #00d4ff); +} + +/* Globe container — compact vertical layout */ +.sys-location-inner { + display: flex; + flex-direction: column; + gap: 10px; + align-items: center; +} + +.sys-globe-wrap { + width: 200px; + height: 200px; + flex-shrink: 0; + background: #000; + border-radius: 8px; + overflow: hidden; + position: relative; +} + +.sys-location-details { + width: 100%; + display: flex; + flex-direction: column; + gap: 6px; +} + +/* GPS status indicator */ +.sys-gps-status { + display: flex; + align-items: center; + gap: 6px; + font-size: 10px; + color: var(--text-dim, #8888aa); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.sys-gps-dot { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; +} + +.sys-gps-dot.fix-3d { + background: var(--accent-green, #00ff88); + box-shadow: 0 0 4px rgba(0, 255, 136, 0.4); +} + +.sys-gps-dot.fix-2d { + background: var(--accent-yellow, #ffcc00); + box-shadow: 0 0 4px rgba(255, 204, 0, 0.4); +} + +.sys-gps-dot.no-fix { + background: var(--text-dim, #555); +} + +.sys-location-coords { + font-family: var(--font-mono, 'JetBrains Mono', monospace); + font-size: 13px; + color: var(--text-primary, #e0e0ff); +} + +.sys-location-source { + font-size: 10px; + color: var(--text-dim, #8888aa); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* Weather overlay */ +.sys-weather { + margin-top: auto; + padding: 10px; + background: rgba(0, 0, 0, 0.3); + border-radius: 6px; + border: 1px solid var(--border-color, #2a2a4a); +} + +.sys-weather-temp { + font-size: 24px; + font-weight: 700; + font-family: var(--font-mono, 'JetBrains Mono', monospace); + color: var(--text-primary, #e0e0ff); +} + +.sys-weather-condition { + font-size: 12px; + color: var(--text-dim, #8888aa); + margin-top: 2px; +} + +.sys-weather-detail { + font-size: 10px; + color: var(--text-dim, #8888aa); + margin-top: 2px; +} + +/* Disk I/O indicators */ +.sys-disk-io { + display: flex; + gap: 16px; + margin-top: 8px; + font-family: var(--font-mono, 'JetBrains Mono', monospace); + font-size: 11px; +} + +.sys-disk-io-read { + color: var(--accent-cyan, #00d4ff); +} + +.sys-disk-io-write { + color: var(--accent-green, #00ff88); +} + +/* Process grid — dot-matrix style */ +.sys-process-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 4px 12px; +} + .sys-process-item { display: flex; align-items: center; @@ -128,6 +431,12 @@ background: var(--text-dim, #555); } +.sys-process-summary { + margin-top: 8px; + font-size: 11px; + color: var(--text-dim, #8888aa); +} + /* SDR Devices */ .sys-sdr-device { padding: 6px 0; @@ -154,6 +463,39 @@ background: var(--bg-primary, #0d0d1a); } +/* System info — vertical layout to fill card */ +.sys-info-grid { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 12px; + color: var(--text-dim, #8888aa); +} + +.sys-info-item { + display: flex; + justify-content: space-between; + padding: 2px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); +} + +.sys-info-item:last-child { + border-bottom: none; +} + +.sys-info-item strong { + color: var(--text-primary, #e0e0ff); + font-weight: 600; +} + +/* Battery indicator */ +.sys-battery-inline { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; +} + /* Sidebar Quick Grid */ .sys-quick-grid { display: grid; @@ -206,10 +548,32 @@ padding: 8px; gap: 10px; } + + .sys-card-wide, + .sys-card-full { + grid-column: 1; + } + + .sys-globe-wrap { + width: 100%; + height: 180px; + } + + .sys-process-grid { + grid-template-columns: 1fr; + } } @media (max-width: 1024px) and (min-width: 769px) { .sys-dashboard { grid-template-columns: repeat(2, 1fr); } + + .sys-card-wide { + grid-column: span 2; + } + + .sys-card-full { + grid-column: 1 / -1; + } } diff --git a/static/js/modes/bluetooth.js b/static/js/modes/bluetooth.js index 2a7c856..f432e98 100644 --- a/static/js/modes/bluetooth.js +++ b/static/js/modes/bluetooth.js @@ -27,22 +27,22 @@ const BluetoothMode = (function() { trackers: [] }; - // Zone counts for proximity display - let zoneCounts = { immediate: 0, near: 0, far: 0 }; + // Zone counts for proximity display + let zoneCounts = { immediate: 0, near: 0, far: 0 }; // New visualization components let radarInitialized = false; let radarPaused = false; - // Device list filter - let currentDeviceFilter = 'all'; - let currentSearchTerm = ''; - let visibleDeviceCount = 0; - let pendingDeviceFlush = false; - let selectedDeviceNeedsRefresh = false; - let filterListenersBound = false; - let listListenersBound = false; - const pendingDeviceIds = new Set(); + // Device list filter + let currentDeviceFilter = 'all'; + let currentSearchTerm = ''; + let visibleDeviceCount = 0; + let pendingDeviceFlush = false; + let selectedDeviceNeedsRefresh = false; + let filterListenersBound = false; + let listListenersBound = false; + const pendingDeviceIds = new Set(); // Agent support let showAllAgentsMode = false; @@ -116,9 +116,9 @@ const BluetoothMode = (function() { // Initialize legacy heatmap (zone counts) initHeatmap(); - // Initialize device list filters - initDeviceFilters(); - initListInteractions(); + // Initialize device list filters + initDeviceFilters(); + initListInteractions(); // Set initial panel states updateVisualizationPanels(); @@ -127,133 +127,133 @@ const BluetoothMode = (function() { /** * Initialize device list filter buttons */ - function initDeviceFilters() { - if (filterListenersBound) return; - const filterContainer = document.getElementById('btDeviceFilters'); - if (filterContainer) { - filterContainer.addEventListener('click', (e) => { - const btn = e.target.closest('.bt-filter-btn'); - if (!btn) return; - - const filter = btn.dataset.filter; - if (!filter) return; - - // Update active state - filterContainer.querySelectorAll('.bt-filter-btn').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - - // Apply filter - currentDeviceFilter = filter; - applyDeviceFilter(); - }); - } - - const searchInput = document.getElementById('btDeviceSearch'); - if (searchInput) { - searchInput.addEventListener('input', () => { - currentSearchTerm = searchInput.value.trim().toLowerCase(); - applyDeviceFilter(); - }); - } - filterListenersBound = true; - } - - function initListInteractions() { - if (listListenersBound) return; - if (deviceContainer) { - deviceContainer.addEventListener('click', (event) => { - const locateBtn = event.target.closest('.bt-locate-btn[data-locate-id]'); - if (locateBtn) { - event.preventDefault(); - locateById(locateBtn.dataset.locateId); - return; - } - - const row = event.target.closest('.bt-device-row[data-bt-device-id]'); - if (!row) return; - selectDevice(row.dataset.btDeviceId); - }); - } - - const trackerList = document.getElementById('btTrackerList'); - if (trackerList) { - trackerList.addEventListener('click', (event) => { - const row = event.target.closest('.bt-tracker-item[data-device-id]'); - if (!row) return; - selectDevice(row.dataset.deviceId); - }); - } - listListenersBound = true; - } + function initDeviceFilters() { + if (filterListenersBound) return; + const filterContainer = document.getElementById('btDeviceFilters'); + if (filterContainer) { + filterContainer.addEventListener('click', (e) => { + const btn = e.target.closest('.bt-filter-btn'); + if (!btn) return; + + const filter = btn.dataset.filter; + if (!filter) return; + + // Update active state + filterContainer.querySelectorAll('.bt-filter-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + + // Apply filter + currentDeviceFilter = filter; + applyDeviceFilter(); + }); + } + + const searchInput = document.getElementById('btDeviceSearch'); + if (searchInput) { + searchInput.addEventListener('input', () => { + currentSearchTerm = searchInput.value.trim().toLowerCase(); + applyDeviceFilter(); + }); + } + filterListenersBound = true; + } + + function initListInteractions() { + if (listListenersBound) return; + if (deviceContainer) { + deviceContainer.addEventListener('click', (event) => { + const locateBtn = event.target.closest('.bt-locate-btn[data-locate-id]'); + if (locateBtn) { + event.preventDefault(); + locateById(locateBtn.dataset.locateId); + return; + } + + const row = event.target.closest('.bt-device-row[data-bt-device-id]'); + if (!row) return; + selectDevice(row.dataset.btDeviceId); + }); + } + + const trackerList = document.getElementById('btTrackerList'); + if (trackerList) { + trackerList.addEventListener('click', (event) => { + const row = event.target.closest('.bt-tracker-item[data-device-id]'); + if (!row) return; + selectDevice(row.dataset.deviceId); + }); + } + listListenersBound = true; + } /** * Apply current filter to device list */ - function applyDeviceFilter() { - if (!deviceContainer) return; - - const cards = deviceContainer.querySelectorAll('[data-bt-device-id]'); - let visibleCount = 0; - cards.forEach(card => { - const isNew = card.dataset.isNew === 'true'; - const hasName = card.dataset.hasName === 'true'; - const rssi = parseInt(card.dataset.rssi) || -100; - const isTracker = card.dataset.isTracker === 'true'; - const searchHaystack = (card.dataset.search || '').toLowerCase(); - - let matchesFilter = true; - switch (currentDeviceFilter) { - case 'new': - matchesFilter = isNew; - break; - case 'named': - matchesFilter = hasName; - break; - case 'strong': - matchesFilter = rssi >= -70; - break; - case 'trackers': - matchesFilter = isTracker; - break; - case 'all': - default: - matchesFilter = true; - } - - const matchesSearch = !currentSearchTerm || searchHaystack.includes(currentSearchTerm); - const visible = matchesFilter && matchesSearch; - card.style.display = visible ? '' : 'none'; - if (visible) visibleCount++; - }); - - visibleDeviceCount = visibleCount; - - let stateEl = deviceContainer.querySelector('.bt-device-filter-state'); - if (visibleCount === 0 && devices.size > 0) { - if (!stateEl) { - stateEl = document.createElement('div'); - stateEl.className = 'bt-device-filter-state app-collection-state is-empty'; - deviceContainer.appendChild(stateEl); - } - stateEl.textContent = 'No devices match current filters'; - } else if (stateEl) { - stateEl.remove(); - } - - // Update visible count - updateFilteredCount(); - } + function applyDeviceFilter() { + if (!deviceContainer) return; + + const cards = deviceContainer.querySelectorAll('[data-bt-device-id]'); + let visibleCount = 0; + cards.forEach(card => { + const isNew = card.dataset.isNew === 'true'; + const hasName = card.dataset.hasName === 'true'; + const rssi = parseInt(card.dataset.rssi) || -100; + const isTracker = card.dataset.isTracker === 'true'; + const searchHaystack = (card.dataset.search || '').toLowerCase(); + + let matchesFilter = true; + switch (currentDeviceFilter) { + case 'new': + matchesFilter = isNew; + break; + case 'named': + matchesFilter = hasName; + break; + case 'strong': + matchesFilter = rssi >= -70; + break; + case 'trackers': + matchesFilter = isTracker; + break; + case 'all': + default: + matchesFilter = true; + } + + const matchesSearch = !currentSearchTerm || searchHaystack.includes(currentSearchTerm); + const visible = matchesFilter && matchesSearch; + card.style.display = visible ? '' : 'none'; + if (visible) visibleCount++; + }); + + visibleDeviceCount = visibleCount; + + let stateEl = deviceContainer.querySelector('.bt-device-filter-state'); + if (visibleCount === 0 && devices.size > 0) { + if (!stateEl) { + stateEl = document.createElement('div'); + stateEl.className = 'bt-device-filter-state app-collection-state is-empty'; + deviceContainer.appendChild(stateEl); + } + stateEl.textContent = 'No devices match current filters'; + } else if (stateEl) { + stateEl.remove(); + } + + // Update visible count + updateFilteredCount(); + } /** * Update the device count display based on visible devices */ - function updateFilteredCount() { - const countEl = document.getElementById('btDeviceListCount'); - if (!countEl || !deviceContainer) return; - - const hasFilter = currentDeviceFilter !== 'all' || currentSearchTerm.length > 0; - countEl.textContent = hasFilter ? `${visibleDeviceCount}/${devices.size}` : devices.size; - } + function updateFilteredCount() { + const countEl = document.getElementById('btDeviceListCount'); + if (!countEl || !deviceContainer) return; + + const hasFilter = currentDeviceFilter !== 'all' || currentSearchTerm.length > 0; + countEl.textContent = hasFilter ? `${visibleDeviceCount}/${devices.size}` : devices.size; + } /** * Initialize the new proximity radar component @@ -369,20 +369,20 @@ const BluetoothMode = (function() { /** * Update proximity zone counts (simple HTML, no canvas) */ - function updateProximityZones() { - zoneCounts = { immediate: 0, near: 0, far: 0 }; - - devices.forEach(device => { - const rssi = device.rssi_current; - if (rssi == null) return; - - if (rssi >= -50) zoneCounts.immediate++; - else if (rssi >= -70) zoneCounts.near++; - else zoneCounts.far++; - }); - - updateProximityZoneCounts(zoneCounts); - } + function updateProximityZones() { + zoneCounts = { immediate: 0, near: 0, far: 0 }; + + devices.forEach(device => { + const rssi = device.rssi_current; + if (rssi == null) return; + + if (rssi >= -50) zoneCounts.immediate++; + else if (rssi >= -70) zoneCounts.near++; + else zoneCounts.far++; + }); + + updateProximityZoneCounts(zoneCounts); + } // Currently selected device let selectedDeviceId = null; @@ -944,59 +944,59 @@ const BluetoothMode = (function() { } } - async function stopScan() { - const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; - const timeoutMs = isAgentMode ? 8000 : 2200; - const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null; - const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null; - - // Optimistic UI teardown keeps mode changes responsive. - setScanning(false); - stopEventStream(); - - try { - if (isAgentMode) { - await fetch(`/controller/agents/${currentAgent}/bluetooth/stop`, { - method: 'POST', - ...(controller ? { signal: controller.signal } : {}), - }); - } else { - await fetch('/api/bluetooth/scan/stop', { - method: 'POST', - ...(controller ? { signal: controller.signal } : {}), - }); - } - } catch (err) { - console.error('Failed to stop scan:', err); - } finally { - if (timeoutId) { - clearTimeout(timeoutId); - } - } - } + async function stopScan() { + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + const timeoutMs = isAgentMode ? 8000 : 2200; + const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null; + const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null; + + // Optimistic UI teardown keeps mode changes responsive. + setScanning(false); + stopEventStream(); + + try { + if (isAgentMode) { + await fetch(`/controller/agents/${currentAgent}/bluetooth/stop`, { + method: 'POST', + ...(controller ? { signal: controller.signal } : {}), + }); + } else { + await fetch('/api/bluetooth/scan/stop', { + method: 'POST', + ...(controller ? { signal: controller.signal } : {}), + }); + } + } catch (err) { + console.error('Failed to stop scan:', err); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } + } function setScanning(scanning) { isScanning = scanning; - if (startBtn) startBtn.style.display = scanning ? 'none' : 'block'; - if (stopBtn) stopBtn.style.display = scanning ? 'block' : 'none'; - - if (scanning && deviceContainer) { - pendingDeviceIds.clear(); - selectedDeviceNeedsRefresh = false; - pendingDeviceFlush = false; - if (typeof renderCollectionState === 'function') { - renderCollectionState(deviceContainer, { type: 'loading', message: 'Scanning for Bluetooth devices...' }); - } else { - deviceContainer.innerHTML = ''; - } - devices.clear(); - resetStats(); - } else if (!scanning && deviceContainer && devices.size === 0) { - if (typeof renderCollectionState === 'function') { - renderCollectionState(deviceContainer, { type: 'empty', message: 'Start scanning to discover Bluetooth devices' }); - } - } + if (startBtn) startBtn.style.display = scanning ? 'none' : 'block'; + if (stopBtn) stopBtn.style.display = scanning ? 'block' : 'none'; + + if (scanning && deviceContainer) { + pendingDeviceIds.clear(); + selectedDeviceNeedsRefresh = false; + pendingDeviceFlush = false; + if (typeof renderCollectionState === 'function') { + renderCollectionState(deviceContainer, { type: 'loading', message: 'Scanning for Bluetooth devices...' }); + } else { + deviceContainer.innerHTML = ''; + } + devices.clear(); + resetStats(); + } else if (!scanning && deviceContainer && devices.size === 0) { + if (typeof renderCollectionState === 'function') { + renderCollectionState(deviceContainer, { type: 'empty', message: 'Start scanning to discover Bluetooth devices' }); + } + } const statusDot = document.getElementById('statusDot'); const statusText = document.getElementById('statusText'); @@ -1004,22 +1004,22 @@ const BluetoothMode = (function() { if (statusText) statusText.textContent = scanning ? 'Scanning...' : 'Idle'; } - function resetStats() { - deviceStats = { - strong: 0, - medium: 0, - weak: 0, - trackers: [] - }; - visibleDeviceCount = 0; - updateVisualizationPanels(); - updateProximityZones(); - updateFilteredCount(); - - // Clear radar - if (radarInitialized && typeof ProximityRadar !== 'undefined') { - ProximityRadar.clear(); - } + function resetStats() { + deviceStats = { + strong: 0, + medium: 0, + weak: 0, + trackers: [] + }; + visibleDeviceCount = 0; + updateVisualizationPanels(); + updateProximityZones(); + updateFilteredCount(); + + // Clear radar + if (radarInitialized && typeof ProximityRadar !== 'undefined') { + ProximityRadar.clear(); + } } function startEventStream() { @@ -1161,43 +1161,43 @@ const BluetoothMode = (function() { }, pollInterval); } - function handleDeviceUpdate(device) { - devices.set(device.device_id, device); - pendingDeviceIds.add(device.device_id); - if (selectedDeviceId === device.device_id) { - selectedDeviceNeedsRefresh = true; - } - scheduleDeviceFlush(); - } - - function scheduleDeviceFlush() { - if (pendingDeviceFlush) return; - pendingDeviceFlush = true; - - requestAnimationFrame(() => { - pendingDeviceFlush = false; - - pendingDeviceIds.forEach((deviceId) => { - const device = devices.get(deviceId); - if (device) { - renderDevice(device, false); - } - }); - pendingDeviceIds.clear(); - - applyDeviceFilter(); - updateDeviceCount(); - updateStatsFromDevices(); - updateVisualizationPanels(); - updateProximityZones(); - updateRadar(); - - if (selectedDeviceNeedsRefresh && selectedDeviceId && devices.has(selectedDeviceId)) { - showDeviceDetail(selectedDeviceId); - } - selectedDeviceNeedsRefresh = false; - }); - } + function handleDeviceUpdate(device) { + devices.set(device.device_id, device); + pendingDeviceIds.add(device.device_id); + if (selectedDeviceId === device.device_id) { + selectedDeviceNeedsRefresh = true; + } + scheduleDeviceFlush(); + } + + function scheduleDeviceFlush() { + if (pendingDeviceFlush) return; + pendingDeviceFlush = true; + + requestAnimationFrame(() => { + pendingDeviceFlush = false; + + pendingDeviceIds.forEach((deviceId) => { + const device = devices.get(deviceId); + if (device) { + renderDevice(device, false); + } + }); + pendingDeviceIds.clear(); + + applyDeviceFilter(); + updateDeviceCount(); + updateStatsFromDevices(); + updateVisualizationPanels(); + updateProximityZones(); + updateRadar(); + + if (selectedDeviceNeedsRefresh && selectedDeviceId && devices.has(selectedDeviceId)) { + showDeviceDetail(selectedDeviceId); + } + selectedDeviceNeedsRefresh = false; + }); + } /** * Update stats from all devices @@ -1232,9 +1232,9 @@ const BluetoothMode = (function() { /** * Update visualization panels */ - function updateVisualizationPanels() { - // Signal Distribution - const total = devices.size || 1; + function updateVisualizationPanels() { + // Signal Distribution + const total = devices.size || 1; const strongBar = document.getElementById('btSignalStrong'); const mediumBar = document.getElementById('btSignalMedium'); const weakBar = document.getElementById('btSignalWeak'); @@ -1245,120 +1245,120 @@ const BluetoothMode = (function() { if (strongBar) strongBar.style.width = (deviceStats.strong / total * 100) + '%'; if (mediumBar) mediumBar.style.width = (deviceStats.medium / total * 100) + '%'; if (weakBar) weakBar.style.width = (deviceStats.weak / total * 100) + '%'; - if (strongCount) strongCount.textContent = deviceStats.strong; - if (mediumCount) mediumCount.textContent = deviceStats.medium; - if (weakCount) weakCount.textContent = deviceStats.weak; - - // Device summary strip - const totalEl = document.getElementById('btSummaryTotal'); - const newEl = document.getElementById('btSummaryNew'); - const trackersEl = document.getElementById('btSummaryTrackers'); - const strongestEl = document.getElementById('btSummaryStrongest'); - if (totalEl || newEl || trackersEl || strongestEl) { - let newCount = 0; - let strongest = null; - devices.forEach(d => { - if (!d.in_baseline) newCount++; - if (d.rssi_current != null) { - strongest = strongest == null ? d.rssi_current : Math.max(strongest, d.rssi_current); - } - }); - if (totalEl) totalEl.textContent = devices.size; - if (newEl) newEl.textContent = newCount; - if (trackersEl) trackersEl.textContent = deviceStats.trackers.length; - if (strongestEl) strongestEl.textContent = strongest == null ? '--' : `${strongest} dBm`; - } - - // Tracker Detection - Enhanced display with confidence and evidence - const trackerList = document.getElementById('btTrackerList'); - if (trackerList) { - if (devices.size === 0) { - if (typeof renderCollectionState === 'function') { - renderCollectionState(trackerList, { type: 'empty', message: 'Start scanning to detect trackers' }); - } else { - trackerList.innerHTML = '
Start scanning to detect trackers
'; - } - } else if (deviceStats.trackers.length === 0) { - if (typeof renderCollectionState === 'function') { - renderCollectionState(trackerList, { type: 'empty', message: 'No trackers detected' }); - } else { - trackerList.innerHTML = '
No trackers detected
'; - } - } else { - // Sort by risk score (highest first), then confidence - const sortedTrackers = [...deviceStats.trackers].sort((a, b) => { - const riskA = a.risk_score || 0; - const riskB = b.risk_score || 0; - if (riskB !== riskA) return riskB - riskA; - const confA = a.tracker_confidence_score || 0; - const confB = b.tracker_confidence_score || 0; - return confB - confA; - }); - - trackerList.innerHTML = sortedTrackers.map((t) => { - const confidence = t.tracker_confidence || 'low'; - const riskScore = t.risk_score || 0; - const trackerType = t.tracker_name || t.tracker_type || 'Unknown Tracker'; - const evidence = (t.tracker_evidence || []).slice(0, 2); - const evidenceHtml = evidence.length > 0 - ? `
${evidence.map((e) => `• ${escapeHtml(e)}`).join('
')}
` - : ''; - const riskClass = riskScore >= 0.5 ? 'high' : riskScore >= 0.3 ? 'medium' : 'low'; - const riskHtml = riskScore >= 0.3 - ? `RISK ${Math.round(riskScore * 100)}%` - : ''; - - return ` -
-
-
- ${escapeHtml(confidence.toUpperCase())} - ${escapeHtml(trackerType)} -
-
- ${riskHtml} - ${t.rssi_current != null ? t.rssi_current : '--'} dBm -
-
-
- ${escapeHtml(t.address_type === 'uuid' ? formatAddress(t) : (t.address || '--'))} - Seen ${t.seen_count || 0}x -
- ${evidenceHtml} -
- `; - }).join(''); - } - } - - } + if (strongCount) strongCount.textContent = deviceStats.strong; + if (mediumCount) mediumCount.textContent = deviceStats.medium; + if (weakCount) weakCount.textContent = deviceStats.weak; + + // Device summary strip + const totalEl = document.getElementById('btSummaryTotal'); + const newEl = document.getElementById('btSummaryNew'); + const trackersEl = document.getElementById('btSummaryTrackers'); + const strongestEl = document.getElementById('btSummaryStrongest'); + if (totalEl || newEl || trackersEl || strongestEl) { + let newCount = 0; + let strongest = null; + devices.forEach(d => { + if (!d.in_baseline) newCount++; + if (d.rssi_current != null) { + strongest = strongest == null ? d.rssi_current : Math.max(strongest, d.rssi_current); + } + }); + if (totalEl) totalEl.textContent = devices.size; + if (newEl) newEl.textContent = newCount; + if (trackersEl) trackersEl.textContent = deviceStats.trackers.length; + if (strongestEl) strongestEl.textContent = strongest == null ? '--' : `${strongest} dBm`; + } + + // Tracker Detection - Enhanced display with confidence and evidence + const trackerList = document.getElementById('btTrackerList'); + if (trackerList) { + if (devices.size === 0) { + if (typeof renderCollectionState === 'function') { + renderCollectionState(trackerList, { type: 'empty', message: 'Start scanning to detect trackers' }); + } else { + trackerList.innerHTML = '
Start scanning to detect trackers
'; + } + } else if (deviceStats.trackers.length === 0) { + if (typeof renderCollectionState === 'function') { + renderCollectionState(trackerList, { type: 'empty', message: 'No trackers detected' }); + } else { + trackerList.innerHTML = '
No trackers detected
'; + } + } else { + // Sort by risk score (highest first), then confidence + const sortedTrackers = [...deviceStats.trackers].sort((a, b) => { + const riskA = a.risk_score || 0; + const riskB = b.risk_score || 0; + if (riskB !== riskA) return riskB - riskA; + const confA = a.tracker_confidence_score || 0; + const confB = b.tracker_confidence_score || 0; + return confB - confA; + }); + + trackerList.innerHTML = sortedTrackers.map((t) => { + const confidence = t.tracker_confidence || 'low'; + const riskScore = t.risk_score || 0; + const trackerType = t.tracker_name || t.tracker_type || 'Unknown Tracker'; + const evidence = (t.tracker_evidence || []).slice(0, 2); + const evidenceHtml = evidence.length > 0 + ? `
${evidence.map((e) => `• ${escapeHtml(e)}`).join('
')}
` + : ''; + const riskClass = riskScore >= 0.5 ? 'high' : riskScore >= 0.3 ? 'medium' : 'low'; + const riskHtml = riskScore >= 0.3 + ? `RISK ${Math.round(riskScore * 100)}%` + : ''; + + return ` +
+
+
+ ${escapeHtml(confidence.toUpperCase())} + ${escapeHtml(trackerType)} +
+
+ ${riskHtml} + ${t.rssi_current != null ? t.rssi_current : '--'} dBm +
+
+
+ ${escapeHtml(t.address_type === 'uuid' ? formatAddress(t) : (t.address || '--'))} + Seen ${t.seen_count || 0}x +
+ ${evidenceHtml} +
+ `; + }).join(''); + } + } + + } function updateDeviceCount() { updateFilteredCount(); } - function renderDevice(device, reapplyFilter = true) { - if (!deviceContainer) { - deviceContainer = document.getElementById('btDeviceListContent'); - if (!deviceContainer) return; - } - - deviceContainer.querySelectorAll('.app-collection-state, .bt-device-filter-state').forEach((el) => el.remove()); - - const escapedId = CSS.escape(device.device_id); - const existingCard = deviceContainer.querySelector('[data-bt-device-id="' + escapedId + '"]'); - const cardHtml = createSimpleDeviceCard(device); + function renderDevice(device, reapplyFilter = true) { + if (!deviceContainer) { + deviceContainer = document.getElementById('btDeviceListContent'); + if (!deviceContainer) return; + } - if (existingCard) { - existingCard.outerHTML = cardHtml; - } else { - deviceContainer.insertAdjacentHTML('afterbegin', cardHtml); - } - - if (reapplyFilter) { - applyDeviceFilter(); - } - } + deviceContainer.querySelectorAll('.app-collection-state, .bt-device-filter-state').forEach((el) => el.remove()); + + const escapedId = CSS.escape(device.device_id); + const existingCard = deviceContainer.querySelector('[data-bt-device-id="' + escapedId + '"]'); + const cardHtml = createSimpleDeviceCard(device); + + if (existingCard) { + existingCard.outerHTML = cardHtml; + } else { + deviceContainer.insertAdjacentHTML('afterbegin', cardHtml); + } + + if (reapplyFilter) { + applyDeviceFilter(); + } + } function createSimpleDeviceCard(device) { const protocol = device.protocol || 'ble'; @@ -1378,19 +1378,19 @@ const BluetoothMode = (function() { // RSSI typically ranges from -100 (weak) to -30 (very strong) const rssiPercent = rssi != null ? Math.max(0, Math.min(100, ((rssi + 100) / 70) * 100)) : 0; - const displayName = device.name || formatDeviceId(device.address); - const name = escapeHtml(displayName); - const addr = escapeHtml(isUuidAddress(device) ? formatAddress(device) : (device.address || 'Unknown')); - const mfr = device.manufacturer_name ? escapeHtml(device.manufacturer_name) : ''; - const seenCount = device.seen_count || 0; - const searchIndex = [ - displayName, - device.address, - device.manufacturer_name, - device.tracker_name, - device.tracker_type, - agentName - ].filter(Boolean).join(' ').toLowerCase(); + const displayName = device.name || formatDeviceId(device.address); + const name = escapeHtml(displayName); + const addr = escapeHtml(isUuidAddress(device) ? formatAddress(device) : (device.address || 'Unknown')); + const mfr = device.manufacturer_name ? escapeHtml(device.manufacturer_name) : ''; + const seenCount = device.seen_count || 0; + const searchIndex = [ + displayName, + device.address, + device.manufacturer_name, + device.tracker_name, + device.tracker_type, + agentName + ].filter(Boolean).join(' ').toLowerCase(); // Protocol badge - compact const protoBadge = protocol === 'ble' @@ -1473,14 +1473,14 @@ const BluetoothMode = (function() { } const secondaryInfo = secondaryParts.join(' · '); - // Row border color - highlight trackers in red/orange - const borderColor = isTracker && trackerConfidence === 'high' ? '#ef4444' : - isTracker ? '#f97316' : rssiColor; - - return '
' + - '
' + - '
' + - protoBadge + + // Row border color - highlight trackers in red/orange + const borderColor = isTracker && trackerConfidence === 'high' ? '#ef4444' : + isTracker ? '#f97316' : rssiColor; + + return '
' + + '
' + + '
' + + protoBadge + '' + name + '' + trackerBadge + irkBadge + @@ -1495,13 +1495,13 @@ const BluetoothMode = (function() { '
' + statusDot + '
' + - '
' + - '
' + secondaryInfo + '
' + - '
' + - '' + - '
' + + '
' + + '
' + secondaryInfo + '
' + + '
' + + '' + + '
' + '
'; } @@ -1514,16 +1514,16 @@ const BluetoothMode = (function() { return '#ef4444'; } - function escapeHtml(text) { - if (!text) return ''; - const div = document.createElement('div'); - div.textContent = String(text); - return div.innerHTML; - } - - function escapeAttr(text) { - return escapeHtml(text).replace(/"/g, '"').replace(/'/g, '''); - } + function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = String(text); + return div.innerHTML; + } + + function escapeAttr(text) { + return escapeHtml(text).replace(/"/g, '"').replace(/'/g, '''); + } async function setBaseline() { try { @@ -1632,22 +1632,22 @@ const BluetoothMode = (function() { /** * Clear all collected data. */ - function clearData() { - devices.clear(); - pendingDeviceIds.clear(); - pendingDeviceFlush = false; - selectedDeviceNeedsRefresh = false; - resetStats(); - clearSelection(); - - if (deviceContainer) { - if (typeof renderCollectionState === 'function') { - renderCollectionState(deviceContainer, { type: 'empty', message: 'Start scanning to discover Bluetooth devices' }); - } else { - deviceContainer.innerHTML = ''; - } - } - } + function clearData() { + devices.clear(); + pendingDeviceIds.clear(); + pendingDeviceFlush = false; + selectedDeviceNeedsRefresh = false; + resetStats(); + clearSelection(); + + if (deviceContainer) { + if (typeof renderCollectionState === 'function') { + renderCollectionState(deviceContainer, { type: 'empty', message: 'Start scanning to discover Bluetooth devices' }); + } else { + deviceContainer.innerHTML = ''; + } + } + } /** * Toggle "Show All Agents" mode. @@ -1682,27 +1682,27 @@ const BluetoothMode = (function() { } }); - toRemove.forEach(deviceId => devices.delete(deviceId)); - - // Re-render device list - if (deviceContainer) { - deviceContainer.innerHTML = ''; - devices.forEach(device => renderDevice(device, false)); - applyDeviceFilter(); - if (devices.size === 0 && typeof renderCollectionState === 'function') { - renderCollectionState(deviceContainer, { type: 'empty', message: 'No devices for current agent' }); - } - } - - if (selectedDeviceId && !devices.has(selectedDeviceId)) { - clearSelection(); - } - - updateDeviceCount(); - updateStatsFromDevices(); - updateVisualizationPanels(); - updateProximityZones(); - updateRadar(); + toRemove.forEach(deviceId => devices.delete(deviceId)); + + // Re-render device list + if (deviceContainer) { + deviceContainer.innerHTML = ''; + devices.forEach(device => renderDevice(device, false)); + applyDeviceFilter(); + if (devices.size === 0 && typeof renderCollectionState === 'function') { + renderCollectionState(deviceContainer, { type: 'empty', message: 'No devices for current agent' }); + } + } + + if (selectedDeviceId && !devices.has(selectedDeviceId)) { + clearSelection(); + } + + updateDeviceCount(); + updateStatsFromDevices(); + updateVisualizationPanels(); + updateProximityZones(); + updateRadar(); } /** @@ -1730,23 +1730,23 @@ const BluetoothMode = (function() { function doLocateHandoff(device) { console.log('[BT] doLocateHandoff, BtLocate defined:', typeof BtLocate !== 'undefined'); - if (typeof BtLocate !== 'undefined') { - BtLocate.handoff({ - device_id: device.device_id, - device_key: device.device_key || null, - mac_address: device.address, - address_type: device.address_type || null, - irk_hex: device.irk_hex || null, - known_name: device.name || null, - known_manufacturer: device.manufacturer_name || null, - last_known_rssi: device.rssi_current, - tx_power: device.tx_power || null, - appearance_name: device.appearance_name || null, - fingerprint_id: device.fingerprint_id || device.fingerprint?.id || null, - mac_cluster_count: device.mac_cluster_count || 0 - }); - } - } + if (typeof BtLocate !== 'undefined') { + BtLocate.handoff({ + device_id: device.device_id, + device_key: device.device_key || null, + mac_address: device.address, + address_type: device.address_type || null, + irk_hex: device.irk_hex || null, + known_name: device.name || null, + known_manufacturer: device.manufacturer_name || null, + last_known_rssi: device.rssi_current, + tx_power: device.tx_power || null, + appearance_name: device.appearance_name || null, + fingerprint_id: device.fingerprint_id || device.fingerprint?.id || null, + mac_cluster_count: device.mac_cluster_count || 0 + }); + } + } // Public API return { @@ -1773,8 +1773,18 @@ const BluetoothMode = (function() { // Getters getDevices: () => Array.from(devices.values()), isScanning: () => isScanning, - isShowAllAgents: () => showAllAgentsMode + isShowAllAgents: () => showAllAgentsMode, + + // Lifecycle + destroy }; + + /** + * Destroy — close SSE stream and clear polling timers for clean mode switching. + */ + function destroy() { + stopEventStream(); + } })(); // Global functions for onclick handlers diff --git a/static/js/modes/bt_locate.js b/static/js/modes/bt_locate.js index 7187c45..a52d127 100644 --- a/static/js/modes/bt_locate.js +++ b/static/js/modes/bt_locate.js @@ -1909,7 +1909,42 @@ const BtLocate = (function() { handleDetection, invalidateMap, fetchPairedIrks, + destroy, }; + + /** + * Destroy — close SSE stream and clear all timers for clean mode switching. + */ + function destroy() { + if (eventSource) { + eventSource.close(); + eventSource = null; + } + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + if (durationTimer) { + clearInterval(durationTimer); + durationTimer = null; + } + if (mapStabilizeTimer) { + clearInterval(mapStabilizeTimer); + mapStabilizeTimer = null; + } + if (queuedDetectionTimer) { + clearTimeout(queuedDetectionTimer); + queuedDetectionTimer = null; + } + if (crosshairResetTimer) { + clearTimeout(crosshairResetTimer); + crosshairResetTimer = null; + } + if (beepTimer) { + clearInterval(beepTimer); + beepTimer = null; + } + } })(); window.BtLocate = BtLocate; diff --git a/static/js/modes/meshtastic.js b/static/js/modes/meshtastic.js index 6f6a093..939037e 100644 --- a/static/js/modes/meshtastic.js +++ b/static/js/modes/meshtastic.js @@ -117,13 +117,13 @@ const Meshtastic = (function() { Settings.createTileLayer().addTo(meshMap); Settings.registerMap(meshMap); } else { - L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', { - attribution: '© OSM © CARTO', - maxZoom: 19, - subdomains: 'abcd', - className: 'tile-layer-cyan' - }).addTo(meshMap); - } + L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', { + attribution: '© OSM © CARTO', + maxZoom: 19, + subdomains: 'abcd', + className: 'tile-layer-cyan' + }).addTo(meshMap); + } // Handle resize setTimeout(() => { @@ -401,10 +401,10 @@ const Meshtastic = (function() { // Position is nested in the response const pos = info.position; - if (pos && pos.latitude !== undefined && pos.latitude !== null && pos.longitude !== undefined && pos.longitude !== null) { - if (posRow) posRow.style.display = 'flex'; - if (posEl) posEl.textContent = `${pos.latitude.toFixed(5)}, ${pos.longitude.toFixed(5)}`; - } else { + if (pos && pos.latitude !== undefined && pos.latitude !== null && pos.longitude !== undefined && pos.longitude !== null) { + if (posRow) posRow.style.display = 'flex'; + if (posEl) posEl.textContent = `${pos.latitude.toFixed(5)}, ${pos.longitude.toFixed(5)}`; + } else { if (posRow) posRow.style.display = 'none'; } } @@ -2295,7 +2295,8 @@ const Meshtastic = (function() { // Store & Forward showStoreForwardModal, requestStoreForward, - closeStoreForwardModal + closeStoreForwardModal, + destroy }; /** @@ -2306,6 +2307,13 @@ const Meshtastic = (function() { setTimeout(() => meshMap.invalidateSize(), 100); } } + + /** + * Destroy — tear down SSE, timers, and event listeners for clean mode switching. + */ + function destroy() { + stopStream(); + } })(); // Initialize when DOM is ready (will be called by selectMode) diff --git a/static/js/modes/spy-stations.js b/static/js/modes/spy-stations.js index a6176f6..09b4955 100644 --- a/static/js/modes/spy-stations.js +++ b/static/js/modes/spy-stations.js @@ -515,6 +515,13 @@ const SpyStations = (function() { } } + /** + * Destroy — no-op placeholder for consistent lifecycle interface. + */ + function destroy() { + // SpyStations has no background timers or streams to clean up. + } + // Public API return { init, @@ -524,7 +531,8 @@ const SpyStations = (function() { showDetails, closeDetails, showHelp, - closeHelp + closeHelp, + destroy }; })(); diff --git a/static/js/modes/sstv-general.js b/static/js/modes/sstv-general.js index c16791d..3bec33d 100644 --- a/static/js/modes/sstv-general.js +++ b/static/js/modes/sstv-general.js @@ -858,6 +858,13 @@ const SSTVGeneral = (function() { } } + /** + * Destroy — close SSE stream and stop scope animation for clean mode switching. + */ + function destroy() { + stopStream(); + } + // Public API return { init, @@ -869,6 +876,7 @@ const SSTVGeneral = (function() { deleteImage, deleteAllImages, downloadImage, - selectPreset + selectPreset, + destroy }; })(); diff --git a/static/js/modes/sstv.js b/static/js/modes/sstv.js index 24e2f29..bb60d1d 100644 --- a/static/js/modes/sstv.js +++ b/static/js/modes/sstv.js @@ -12,12 +12,12 @@ const SSTV = (function() { let progress = 0; let issMap = null; let issMarker = null; - let issTrackLine = null; - let issPosition = null; - let issUpdateInterval = null; - let countdownInterval = null; - let nextPassData = null; - let pendingMapInvalidate = false; + let issTrackLine = null; + let issPosition = null; + let issUpdateInterval = null; + let countdownInterval = null; + let nextPassData = null; + let pendingMapInvalidate = false; // ISS frequency const ISS_FREQ = 145.800; @@ -38,31 +38,31 @@ const SSTV = (function() { /** * Initialize the SSTV mode */ - function init() { - checkStatus(); - loadImages(); - loadLocationInputs(); - loadIssSchedule(); - initMap(); - startIssTracking(); - startCountdown(); - // Ensure Leaflet recomputes dimensions after the SSTV pane becomes visible. - setTimeout(() => invalidateMap(), 80); - setTimeout(() => invalidateMap(), 260); - } - - function isMapContainerVisible() { - if (!issMap || typeof issMap.getContainer !== 'function') return false; - const container = issMap.getContainer(); - if (!container) return false; - if (container.offsetWidth <= 0 || container.offsetHeight <= 0) return false; - if (container.style && container.style.display === 'none') return false; - if (typeof window.getComputedStyle === 'function') { - const style = window.getComputedStyle(container); - if (style.display === 'none' || style.visibility === 'hidden') return false; - } - return true; - } + function init() { + checkStatus(); + loadImages(); + loadLocationInputs(); + loadIssSchedule(); + initMap(); + startIssTracking(); + startCountdown(); + // Ensure Leaflet recomputes dimensions after the SSTV pane becomes visible. + setTimeout(() => invalidateMap(), 80); + setTimeout(() => invalidateMap(), 260); + } + + function isMapContainerVisible() { + if (!issMap || typeof issMap.getContainer !== 'function') return false; + const container = issMap.getContainer(); + if (!container) return false; + if (container.offsetWidth <= 0 || container.offsetHeight <= 0) return false; + if (container.style && container.style.display === 'none') return false; + if (typeof window.getComputedStyle === 'function') { + const style = window.getComputedStyle(container); + if (style.display === 'none' || style.visibility === 'hidden') return false; + } + return true; + } /** * Load location into input fields @@ -189,9 +189,9 @@ const SSTV = (function() { /** * Initialize Leaflet map for ISS tracking */ - async function initMap() { - const mapContainer = document.getElementById('sstvIssMap'); - if (!mapContainer || issMap) return; + async function initMap() { + const mapContainer = document.getElementById('sstvIssMap'); + if (!mapContainer || issMap) return; // Create map issMap = L.map('sstvIssMap', { @@ -231,21 +231,21 @@ const SSTV = (function() { issMarker = L.marker([0, 0], { icon: issIcon }).addTo(issMap); // Create ground track line - issTrackLine = L.polyline([], { - color: '#00d4ff', - weight: 2, - opacity: 0.6, - dashArray: '5, 5' - }).addTo(issMap); - - issMap.on('resize moveend zoomend', () => { - if (pendingMapInvalidate) invalidateMap(); - }); - - // Initial layout passes for first-time mode load. - setTimeout(() => invalidateMap(), 40); - setTimeout(() => invalidateMap(), 180); - } + issTrackLine = L.polyline([], { + color: '#00d4ff', + weight: 2, + opacity: 0.6, + dashArray: '5, 5' + }).addTo(issMap); + + issMap.on('resize moveend zoomend', () => { + if (pendingMapInvalidate) invalidateMap(); + }); + + // Initial layout passes for first-time mode load. + setTimeout(() => invalidateMap(), 40); + setTimeout(() => invalidateMap(), 180); + } /** * Start ISS position tracking @@ -454,9 +454,9 @@ const SSTV = (function() { /** * Update map with ISS position */ - function updateMap() { - if (!issMap || !issPosition) return; - if (pendingMapInvalidate) invalidateMap(); + function updateMap() { + if (!issMap || !issPosition) return; + if (pendingMapInvalidate) invalidateMap(); const lat = issPosition.lat; const lon = issPosition.lon; @@ -516,13 +516,13 @@ const SSTV = (function() { issTrackLine.setLatLngs(segments.length > 0 ? segments : []); } - // Pan map to follow ISS only when the map pane is currently renderable. - if (isMapContainerVisible()) { - issMap.panTo([lat, lon], { animate: true, duration: 0.5 }); - } else { - pendingMapInvalidate = true; - } - } + // Pan map to follow ISS only when the map pane is currently renderable. + if (isMapContainerVisible()) { + issMap.panTo([lat, lon], { animate: true, duration: 0.5 }); + } else { + pendingMapInvalidate = true; + } + } /** * Check current decoder status @@ -1335,27 +1335,27 @@ const SSTV = (function() { /** * Show status message */ - function showStatusMessage(message, type) { - if (typeof showNotification === 'function') { - showNotification('SSTV', message); - } else { - console.log(`[SSTV ${type}] ${message}`); - } - } - - /** - * Invalidate ISS map size after pane/layout changes. - */ - function invalidateMap() { - if (!issMap) return false; - if (!isMapContainerVisible()) { - pendingMapInvalidate = true; - return false; - } - issMap.invalidateSize({ pan: false, animate: false }); - pendingMapInvalidate = false; - return true; - } + function showStatusMessage(message, type) { + if (typeof showNotification === 'function') { + showNotification('SSTV', message); + } else { + console.log(`[SSTV ${type}] ${message}`); + } + } + + /** + * Invalidate ISS map size after pane/layout changes. + */ + function invalidateMap() { + if (!issMap) return false; + if (!isMapContainerVisible()) { + pendingMapInvalidate = true; + return false; + } + issMap.invalidateSize({ pan: false, animate: false }); + pendingMapInvalidate = false; + return true; + } // Public API return { @@ -1370,12 +1370,25 @@ const SSTV = (function() { deleteAllImages, downloadImage, useGPS, - updateTLE, - stopIssTracking, - stopCountdown, - invalidateMap - }; -})(); + updateTLE, + stopIssTracking, + stopCountdown, + invalidateMap, + destroy + }; + + /** + * Destroy — close SSE stream and clear ISS tracking/countdown timers for clean mode switching. + */ + function destroy() { + if (eventSource) { + eventSource.close(); + eventSource = null; + } + stopIssTracking(); + stopCountdown(); + } +})(); // Initialize when DOM is ready (will be called by selectMode) document.addEventListener('DOMContentLoaded', function() { diff --git a/static/js/modes/system.js b/static/js/modes/system.js index 1aab9aa..006485f 100644 --- a/static/js/modes/system.js +++ b/static/js/modes/system.js @@ -1,8 +1,9 @@ /** - * System Health – IIFE module + * System Health – Enhanced Dashboard IIFE module * - * Always-on monitoring that auto-connects when the mode is entered. - * Streams real-time system metrics via SSE and provides SDR device enumeration. + * Streams real-time system metrics via SSE with rich visualizations: + * SVG arc gauge, per-core bars, temperature sparkline, network bandwidth, + * disk I/O, 3D globe, weather, and process grid. */ const SystemHealth = (function () { 'use strict'; @@ -11,19 +12,46 @@ const SystemHealth = (function () { let connected = false; let lastMetrics = null; + // Temperature sparkline ring buffer (last 20 readings) + const SPARKLINE_SIZE = 20; + let tempHistory = []; + + // Network I/O delta tracking + let prevNetIo = null; + let prevNetTimestamp = null; + + // Disk I/O delta tracking + let prevDiskIo = null; + let prevDiskTimestamp = null; + + // Location & weather state + let locationData = null; + let weatherData = null; + let weatherTimer = null; + let globeInstance = null; + let globeDestroyed = false; + + const GLOBE_SCRIPT_URL = 'https://cdn.jsdelivr.net/npm/globe.gl@2.33.1/dist/globe.gl.min.js'; + const GLOBE_TEXTURE_URL = '/static/images/globe/earth-dark.jpg'; + // ----------------------------------------------------------------------- // Helpers // ----------------------------------------------------------------------- function formatBytes(bytes) { if (bytes == null) return '--'; - const units = ['B', 'KB', 'MB', 'GB', 'TB']; - let i = 0; - let val = bytes; + var units = ['B', 'KB', 'MB', 'GB', 'TB']; + var i = 0; + var val = bytes; while (val >= 1024 && i < units.length - 1) { val /= 1024; i++; } return val.toFixed(1) + ' ' + units[i]; } + function formatRate(bytesPerSec) { + if (bytesPerSec == null) return '--'; + return formatBytes(bytesPerSec) + '/s'; + } + function barClass(pct) { if (pct >= 85) return 'crit'; if (pct >= 60) return 'warn'; @@ -32,8 +60,8 @@ const SystemHealth = (function () { function barHtml(pct, label) { if (pct == null) return 'N/A'; - const cls = barClass(pct); - const rounded = Math.round(pct); + var cls = barClass(pct); + var rounded = Math.round(pct); return '
' + (label ? '' + label + '' : '') + '
' + @@ -41,71 +69,531 @@ const SystemHealth = (function () { '
'; } + function escHtml(s) { + var d = document.createElement('div'); + d.textContent = s; + return d.innerHTML; + } + // ----------------------------------------------------------------------- - // Rendering + // SVG Arc Gauge + // ----------------------------------------------------------------------- + + function arcGaugeSvg(pct) { + var radius = 36; + var cx = 45, cy = 45; + var startAngle = -225; + var endAngle = 45; + var totalAngle = endAngle - startAngle; // 270 degrees + var fillAngle = startAngle + (totalAngle * Math.min(pct, 100) / 100); + + function polarToCart(angle) { + var r = angle * Math.PI / 180; + return { x: cx + radius * Math.cos(r), y: cy + radius * Math.sin(r) }; + } + + var bgStart = polarToCart(startAngle); + var bgEnd = polarToCart(endAngle); + var fillEnd = polarToCart(fillAngle); + var largeArcBg = totalAngle > 180 ? 1 : 0; + var fillArc = (fillAngle - startAngle) > 180 ? 1 : 0; + var cls = barClass(pct); + + return '' + + '' + + '' + + ''; + } + + // ----------------------------------------------------------------------- + // Temperature Sparkline + // ----------------------------------------------------------------------- + + function sparklineSvg(values) { + if (!values || values.length < 2) return ''; + var w = 200, h = 40; + var min = Math.min.apply(null, values); + var max = Math.max.apply(null, values); + var range = max - min || 1; + var step = w / (values.length - 1); + + var points = values.map(function (v, i) { + var x = Math.round(i * step); + var y = Math.round(h - ((v - min) / range) * (h - 4) - 2); + return x + ',' + y; + }); + + var areaPoints = points.join(' ') + ' ' + w + ',' + h + ' 0,' + h; + + return '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + } + + // ----------------------------------------------------------------------- + // Rendering — CPU Card // ----------------------------------------------------------------------- function renderCpuCard(m) { - const el = document.getElementById('sysCardCpu'); + var el = document.getElementById('sysCardCpu'); if (!el) return; - const cpu = m.cpu; + var cpu = m.cpu; if (!cpu) { el.innerHTML = '
psutil not available
'; return; } + + var pct = Math.round(cpu.percent); + var coreHtml = ''; + if (cpu.per_core && cpu.per_core.length) { + coreHtml = '
'; + cpu.per_core.forEach(function (c) { + var cls = barClass(c); + var h = Math.max(3, Math.round(c / 100 * 48)); + coreHtml += '
'; + }); + coreHtml += '
'; + } + + var freqHtml = ''; + if (cpu.freq) { + var freqGhz = (cpu.freq.current / 1000).toFixed(2); + freqHtml = '
Freq: ' + freqGhz + ' GHz
'; + } + el.innerHTML = '
CPU
' + '
' + - barHtml(cpu.percent, '') + + '
' + + '
' + arcGaugeSvg(pct) + + '
' + pct + '%
' + + '
' + '
Load: ' + cpu.load_1 + ' / ' + cpu.load_5 + ' / ' + cpu.load_15 + '
' + '
Cores: ' + cpu.count + '
' + + freqHtml + + '
' + + coreHtml + '
'; } + // ----------------------------------------------------------------------- + // Memory Card + // ----------------------------------------------------------------------- + function renderMemoryCard(m) { - const el = document.getElementById('sysCardMemory'); + var el = document.getElementById('sysCardMemory'); if (!el) return; - const mem = m.memory; + var mem = m.memory; if (!mem) { el.innerHTML = '
N/A
'; return; } - const swap = m.swap || {}; + var swap = m.swap || {}; el.innerHTML = '
Memory
' + '
' + - barHtml(mem.percent, '') + + barHtml(mem.percent, 'RAM') + '
' + formatBytes(mem.used) + ' / ' + formatBytes(mem.total) + '
' + - '
Swap: ' + formatBytes(swap.used) + ' / ' + formatBytes(swap.total) + '
' + + (swap.total > 0 ? barHtml(swap.percent, 'Swap') + + '
' + formatBytes(swap.used) + ' / ' + formatBytes(swap.total) + '
' : '') + '
'; } - function renderDiskCard(m) { - const el = document.getElementById('sysCardDisk'); - if (!el) return; - const disk = m.disk; - if (!disk) { el.innerHTML = '
N/A
'; return; } - el.innerHTML = - '
Disk
' + - '
' + - barHtml(disk.percent, '') + - '
' + formatBytes(disk.used) + ' / ' + formatBytes(disk.total) + '
' + - '
Path: ' + (disk.path || '/') + '
' + - '
'; - } + // ----------------------------------------------------------------------- + // Temperature & Power Card + // ----------------------------------------------------------------------- function _extractPrimaryTemp(temps) { if (!temps) return null; - // Prefer common chip names - const preferred = ['cpu_thermal', 'coretemp', 'k10temp', 'acpitz', 'soc_thermal']; - for (const name of preferred) { - if (temps[name] && temps[name].length) return temps[name][0]; + var preferred = ['cpu_thermal', 'coretemp', 'k10temp', 'acpitz', 'soc_thermal']; + for (var i = 0; i < preferred.length; i++) { + if (temps[preferred[i]] && temps[preferred[i]].length) return temps[preferred[i]][0]; } - // Fall back to first available - for (const key of Object.keys(temps)) { + for (var key in temps) { if (temps[key] && temps[key].length) return temps[key][0]; } return null; } - function renderSdrCard(devices) { - const el = document.getElementById('sysCardSdr'); + function renderTempCard(m) { + var el = document.getElementById('sysCardTemp'); if (!el) return; - let html = '
SDR Devices
'; + + var temp = _extractPrimaryTemp(m.temperatures); + var html = '
Temperature & Power
'; + + if (temp) { + // Update sparkline history + tempHistory.push(temp.current); + if (tempHistory.length > SPARKLINE_SIZE) tempHistory.shift(); + + html += '
' + Math.round(temp.current) + '°C
'; + html += '
' + sparklineSvg(tempHistory) + '
'; + + // Additional sensors + if (m.temperatures) { + for (var chip in m.temperatures) { + m.temperatures[chip].forEach(function (s) { + html += '
' + escHtml(s.label) + ': ' + Math.round(s.current) + '°C
'; + }); + } + } + } else { + html += 'No temperature sensors'; + } + + // Fans + if (m.fans) { + for (var fChip in m.fans) { + m.fans[fChip].forEach(function (f) { + html += '
Fan ' + escHtml(f.label) + ': ' + f.current + ' RPM
'; + }); + } + } + + // Battery + if (m.battery) { + html += '
' + + 'Battery: ' + Math.round(m.battery.percent) + '%' + + (m.battery.plugged ? ' (plugged)' : '') + '
'; + } + + // Throttle flags (Pi) + if (m.power && m.power.throttled) { + html += '
Throttle: 0x' + m.power.throttled + '
'; + } + + // Power draw + if (m.power && m.power.draw_watts != null) { + html += '
Power: ' + m.power.draw_watts + ' W
'; + } + + html += '
'; + el.innerHTML = html; + } + + // ----------------------------------------------------------------------- + // Disk Card + // ----------------------------------------------------------------------- + + function renderDiskCard(m) { + var el = document.getElementById('sysCardDisk'); + if (!el) return; + var disk = m.disk; + if (!disk) { el.innerHTML = '
Disk & Storage
N/A
'; return; } + + var html = '
Disk & Storage
'; + html += barHtml(disk.percent, ''); + html += '
' + formatBytes(disk.used) + ' / ' + formatBytes(disk.total) + '
'; + + // Disk I/O rates + if (m.disk_io && prevDiskIo && prevDiskTimestamp) { + var dt = (m.timestamp - prevDiskTimestamp); + if (dt > 0) { + var readRate = (m.disk_io.read_bytes - prevDiskIo.read_bytes) / dt; + var writeRate = (m.disk_io.write_bytes - prevDiskIo.write_bytes) / dt; + var readIops = Math.round((m.disk_io.read_count - prevDiskIo.read_count) / dt); + var writeIops = Math.round((m.disk_io.write_count - prevDiskIo.write_count) / dt); + html += '
' + + 'R: ' + formatRate(Math.max(0, readRate)) + '' + + 'W: ' + formatRate(Math.max(0, writeRate)) + '' + + '
'; + html += '
IOPS: ' + Math.max(0, readIops) + 'r / ' + Math.max(0, writeIops) + 'w
'; + } + } + + if (m.disk_io) { + prevDiskIo = m.disk_io; + prevDiskTimestamp = m.timestamp; + } + + html += '
'; + el.innerHTML = html; + } + + // ----------------------------------------------------------------------- + // Network Card + // ----------------------------------------------------------------------- + + function renderNetworkCard(m) { + var el = document.getElementById('sysCardNetwork'); + if (!el) return; + var net = m.network; + if (!net) { el.innerHTML = '
Network
N/A
'; return; } + + var html = '
Network
'; + + // Interfaces + var ifaces = net.interfaces || []; + if (ifaces.length === 0) { + html += 'No interfaces'; + } else { + ifaces.forEach(function (iface) { + html += '
'; + html += '
' + escHtml(iface.name) + + (iface.is_up ? '' : ' (down)') + '
'; + if (iface.ipv4) html += '
' + escHtml(iface.ipv4) + '
'; + var details = []; + if (iface.mac) details.push('MAC: ' + iface.mac); + if (iface.speed) details.push(iface.speed + ' Mbps'); + if (details.length) html += '
' + escHtml(details.join(' | ')) + '
'; + + // Bandwidth for this interface + if (net.io && net.io[iface.name] && prevNetIo && prevNetIo[iface.name] && prevNetTimestamp) { + var dt = (m.timestamp - prevNetTimestamp); + if (dt > 0) { + var prev = prevNetIo[iface.name]; + var cur = net.io[iface.name]; + var upRate = (cur.bytes_sent - prev.bytes_sent) / dt; + var downRate = (cur.bytes_recv - prev.bytes_recv) / dt; + html += '
' + + '↑ ' + formatRate(Math.max(0, upRate)) + '' + + '↓ ' + formatRate(Math.max(0, downRate)) + '' + + '
'; + } + } + + html += '
'; + }); + } + + // Connection count + if (net.connections != null) { + html += '
Connections: ' + net.connections + '
'; + } + + // Save for next delta + if (net.io) { + prevNetIo = net.io; + prevNetTimestamp = m.timestamp; + } + + html += '
'; + el.innerHTML = html; + } + + // ----------------------------------------------------------------------- + // Location & Weather Card + // ----------------------------------------------------------------------- + + function renderLocationCard() { + var el = document.getElementById('sysCardLocation'); + if (!el) return; + + // Preserve the globe DOM node if it already has a canvas + var existingGlobe = document.getElementById('sysGlobeContainer'); + var savedGlobe = null; + if (existingGlobe && existingGlobe.querySelector('canvas')) { + savedGlobe = existingGlobe; + existingGlobe.parentNode.removeChild(existingGlobe); + } + + var html = '
Location & Weather
'; + html += '
'; + + // Globe placeholder (will be replaced with saved node or initialized fresh) + if (!savedGlobe) { + html += '
'; + } else { + html += '
'; + } + + // Details below globe + html += '
'; + + if (locationData && locationData.lat != null) { + html += '
' + + locationData.lat.toFixed(4) + '°' + (locationData.lat >= 0 ? 'N' : 'S') + ', ' + + locationData.lon.toFixed(4) + '°' + (locationData.lon >= 0 ? 'E' : 'W') + '
'; + + // GPS status indicator + if (locationData.source === 'gps' && locationData.gps) { + var gps = locationData.gps; + var fixLabel = gps.fix_quality === 3 ? '3D Fix' : '2D Fix'; + var dotCls = gps.fix_quality === 3 ? 'fix-3d' : 'fix-2d'; + html += '
' + + ' ' + fixLabel; + if (gps.satellites != null) html += ' · ' + gps.satellites + ' sats'; + if (gps.accuracy != null) html += ' · ±' + gps.accuracy + 'm'; + html += '
'; + } else { + html += '
Source: ' + escHtml(locationData.source || 'unknown') + '
'; + } + } else { + html += '
No location
'; + } + + // Weather + if (weatherData && !weatherData.error) { + html += '
'; + html += '
' + (weatherData.temp_c || '--') + '°C
'; + html += '
' + escHtml(weatherData.condition || '') + '
'; + var details = []; + if (weatherData.humidity) details.push('Humidity: ' + weatherData.humidity + '%'); + if (weatherData.wind_mph) details.push('Wind: ' + weatherData.wind_mph + ' mph ' + (weatherData.wind_dir || '')); + if (weatherData.feels_like_c) details.push('Feels like: ' + weatherData.feels_like_c + '°C'); + details.forEach(function (d) { + html += '
' + escHtml(d) + '
'; + }); + html += '
'; + } else if (weatherData && weatherData.error) { + html += '
Weather unavailable
'; + } + + html += '
'; // .sys-location-details + html += '
'; // .sys-location-inner + html += '
'; + el.innerHTML = html; + + // Re-insert saved globe or initialize fresh + if (savedGlobe) { + var placeholder = document.getElementById('sysGlobePlaceholder'); + if (placeholder) placeholder.parentNode.replaceChild(savedGlobe, placeholder); + } else { + requestAnimationFrame(function () { initGlobe(); }); + } + } + + // ----------------------------------------------------------------------- + // Globe (reuses globe.gl from GPS mode) + // ----------------------------------------------------------------------- + + function ensureGlobeLibrary() { + return new Promise(function (resolve, reject) { + if (typeof window.Globe === 'function') { resolve(true); return; } + + // Check if script already exists + var existing = document.querySelector( + 'script[data-intercept-globe-src="' + GLOBE_SCRIPT_URL + '"], ' + + 'script[src="' + GLOBE_SCRIPT_URL + '"]' + ); + if (existing) { + if (existing.dataset.loaded === 'true') { resolve(true); return; } + if (existing.dataset.failed === 'true') { resolve(false); return; } + existing.addEventListener('load', function () { resolve(true); }, { once: true }); + existing.addEventListener('error', function () { resolve(false); }, { once: true }); + return; + } + + var script = document.createElement('script'); + script.src = GLOBE_SCRIPT_URL; + script.async = true; + script.crossOrigin = 'anonymous'; + script.dataset.interceptGlobeSrc = GLOBE_SCRIPT_URL; + script.onload = function () { script.dataset.loaded = 'true'; resolve(true); }; + script.onerror = function () { script.dataset.failed = 'true'; resolve(false); }; + document.head.appendChild(script); + }); + } + + function initGlobe() { + var container = document.getElementById('sysGlobeContainer'); + if (!container || globeDestroyed) return; + + // Don't reinitialize if globe canvas is still alive in this container + if (globeInstance && container.querySelector('canvas')) return; + + // Clear stale reference if canvas was destroyed by innerHTML replacement + if (globeInstance && !container.querySelector('canvas')) { + globeInstance = null; + } + + ensureGlobeLibrary().then(function (ready) { + if (!ready || typeof window.Globe !== 'function' || globeDestroyed) return; + + // Wait for layout — container may have 0 dimensions right after + // display:none is removed by switchMode(). Use RAF retry like GPS mode. + var attempts = 0; + function tryInit() { + if (globeDestroyed) return; + container = document.getElementById('sysGlobeContainer'); + if (!container) return; + + if ((!container.clientWidth || !container.clientHeight) && attempts < 8) { + attempts++; + requestAnimationFrame(tryInit); + return; + } + if (!container.clientWidth || !container.clientHeight) return; + + container.innerHTML = ''; + container.style.background = 'radial-gradient(circle, rgba(10,20,40,0.9), rgba(2,4,8,0.98) 70%)'; + + try { + globeInstance = window.Globe()(container) + .backgroundColor('rgba(0,0,0,0)') + .globeImageUrl(GLOBE_TEXTURE_URL) + .showAtmosphere(true) + .atmosphereColor('#3bb9ff') + .atmosphereAltitude(0.12) + .pointsData([]) + .pointRadius(0.8) + .pointAltitude(0.01) + .pointColor(function () { return '#00d4ff'; }); + + var controls = globeInstance.controls(); + if (controls) { + controls.autoRotate = true; + controls.autoRotateSpeed = 0.5; + controls.enablePan = false; + controls.minDistance = 120; + controls.maxDistance = 300; + } + + // Size the globe + globeInstance.width(container.clientWidth); + globeInstance.height(container.clientHeight); + + updateGlobePosition(); + } catch (e) { + // Globe.gl / WebGL init failed — show static fallback + container.innerHTML = '
Globe unavailable
'; + } + } + requestAnimationFrame(tryInit); + }); + } + + function updateGlobePosition() { + if (!globeInstance || !locationData || locationData.lat == null) return; + + // Observer point + globeInstance.pointsData([{ + lat: locationData.lat, + lng: locationData.lon, + size: 0.8, + color: '#00d4ff', + }]); + + // Snap view + globeInstance.pointOfView({ lat: locationData.lat, lng: locationData.lon, altitude: 2.0 }, 1000); + + // Stop auto-rotate when we have a fix + var controls = globeInstance.controls(); + if (controls) controls.autoRotate = false; + } + + function destroyGlobe() { + globeDestroyed = true; + if (globeInstance) { + var container = document.getElementById('sysGlobeContainer'); + if (container) container.innerHTML = ''; + globeInstance = null; + } + } + + // ----------------------------------------------------------------------- + // SDR Card + // ----------------------------------------------------------------------- + + function renderSdrCard(devices) { + var el = document.getElementById('sysCardSdr'); + if (!el) return; + var html = '
SDR Devices
'; html += '
'; if (!devices || !devices.length) { html += 'No devices found'; @@ -113,9 +601,9 @@ const SystemHealth = (function () { devices.forEach(function (d) { html += '
' + ' ' + - '' + d.type + ' #' + d.index + '' + - '
' + (d.name || 'Unknown') + '
' + - (d.serial ? '
S/N: ' + d.serial + '
' : '') + + '' + escHtml(d.type) + ' #' + d.index + '' + + '
' + escHtml(d.name || 'Unknown') + '
' + + (d.serial ? '
S/N: ' + escHtml(d.serial) + '
' : '') + '
'; }); } @@ -123,93 +611,197 @@ const SystemHealth = (function () { el.innerHTML = html; } + // ----------------------------------------------------------------------- + // Process Card + // ----------------------------------------------------------------------- + function renderProcessCard(m) { - const el = document.getElementById('sysCardProcesses'); + var el = document.getElementById('sysCardProcesses'); if (!el) return; - const procs = m.processes || {}; - const keys = Object.keys(procs).sort(); - let html = '
Processes
'; + var procs = m.processes || {}; + var keys = Object.keys(procs).sort(); + var html = '
Active Processes
'; if (!keys.length) { html += 'No data'; } else { + var running = 0, stopped = 0; + html += '
'; keys.forEach(function (k) { - const running = procs[k]; - const dotCls = running ? 'running' : 'stopped'; - const label = k.charAt(0).toUpperCase() + k.slice(1); + var isRunning = procs[k]; + if (isRunning) running++; else stopped++; + var dotCls = isRunning ? 'running' : 'stopped'; + var label = k.charAt(0).toUpperCase() + k.slice(1); html += '
' + ' ' + - '' + label + '' + + '' + escHtml(label) + '' + '
'; }); + html += '
'; + html += '
' + running + ' running / ' + stopped + ' idle
'; } html += '
'; el.innerHTML = html; } + // ----------------------------------------------------------------------- + // System Info Card + // ----------------------------------------------------------------------- + function renderSystemInfoCard(m) { - const el = document.getElementById('sysCardInfo'); + var el = document.getElementById('sysCardInfo'); if (!el) return; - const sys = m.system || {}; - const temp = _extractPrimaryTemp(m.temperatures); - let html = '
System Info
'; - html += '
Host: ' + (sys.hostname || '--') + '
'; - html += '
OS: ' + (sys.platform || '--') + '
'; - html += '
Python: ' + (sys.python || '--') + '
'; - html += '
App: v' + (sys.version || '--') + '
'; - html += '
Uptime: ' + (sys.uptime_human || '--') + '
'; - if (temp) { - html += '
Temp: ' + Math.round(temp.current) + '°C'; - if (temp.high) html += ' / ' + Math.round(temp.high) + '°C max'; - html += '
'; + var sys = m.system || {}; + var html = '
System Info
'; + + html += '
Host' + escHtml(sys.hostname || '--') + '
'; + html += '
OS' + escHtml((sys.platform || '--').replace(/-with-glibc[\d.]+/, '')) + '
'; + html += '
Python' + escHtml(sys.python || '--') + '
'; + html += '
Appv' + escHtml(sys.version || '--') + '
'; + html += '
Uptime' + escHtml(sys.uptime_human || '--') + '
'; + + if (m.boot_time) { + var bootDate = new Date(m.boot_time * 1000); + html += '
Boot' + escHtml(bootDate.toLocaleString()) + '
'; } - html += '
'; + + if (m.network && m.network.connections != null) { + html += '
Connections' + m.network.connections + '
'; + } + + html += '
'; el.innerHTML = html; } + // ----------------------------------------------------------------------- + // Sidebar Updates + // ----------------------------------------------------------------------- + function updateSidebarQuickStats(m) { - const cpuEl = document.getElementById('sysQuickCpu'); - const tempEl = document.getElementById('sysQuickTemp'); - const ramEl = document.getElementById('sysQuickRam'); - const diskEl = document.getElementById('sysQuickDisk'); + var cpuEl = document.getElementById('sysQuickCpu'); + var tempEl = document.getElementById('sysQuickTemp'); + var ramEl = document.getElementById('sysQuickRam'); + var diskEl = document.getElementById('sysQuickDisk'); if (cpuEl) cpuEl.textContent = m.cpu ? Math.round(m.cpu.percent) + '%' : '--'; if (ramEl) ramEl.textContent = m.memory ? Math.round(m.memory.percent) + '%' : '--'; if (diskEl) diskEl.textContent = m.disk ? Math.round(m.disk.percent) + '%' : '--'; - const temp = _extractPrimaryTemp(m.temperatures); + var temp = _extractPrimaryTemp(m.temperatures); if (tempEl) tempEl.innerHTML = temp ? Math.round(temp.current) + '°C' : '--'; // Color-code values [cpuEl, ramEl, diskEl].forEach(function (el) { if (!el) return; - const val = parseInt(el.textContent); + var val = parseInt(el.textContent); el.classList.remove('sys-val-ok', 'sys-val-warn', 'sys-val-crit'); if (!isNaN(val)) el.classList.add('sys-val-' + barClass(val)); }); } function updateSidebarProcesses(m) { - const el = document.getElementById('sysProcessList'); + var el = document.getElementById('sysProcessList'); if (!el) return; - const procs = m.processes || {}; - const keys = Object.keys(procs).sort(); + var procs = m.processes || {}; + var keys = Object.keys(procs).sort(); if (!keys.length) { el.textContent = 'No data'; return; } - const running = keys.filter(function (k) { return procs[k]; }); - const stopped = keys.filter(function (k) { return !procs[k]; }); + var running = keys.filter(function (k) { return procs[k]; }); + var stopped = keys.filter(function (k) { return !procs[k]; }); el.innerHTML = (running.length ? '' + running.length + ' running' : '') + (running.length && stopped.length ? ' · ' : '') + (stopped.length ? '' + stopped.length + ' stopped' : ''); } + function updateSidebarNetwork(m) { + var el = document.getElementById('sysQuickNet'); + if (!el || !m.network) return; + var ifaces = m.network.interfaces || []; + var ips = []; + ifaces.forEach(function (iface) { + if (iface.ipv4 && iface.is_up) { + ips.push(iface.name + ': ' + iface.ipv4); + } + }); + el.textContent = ips.length ? ips.join(', ') : '--'; + } + + function updateSidebarBattery(m) { + var section = document.getElementById('sysQuickBatterySection'); + var el = document.getElementById('sysQuickBattery'); + if (!section || !el) return; + if (m.battery) { + section.style.display = ''; + el.textContent = Math.round(m.battery.percent) + '%' + (m.battery.plugged ? ' (plugged)' : ''); + } else { + section.style.display = 'none'; + } + } + + function updateSidebarLocation() { + var el = document.getElementById('sysQuickLocation'); + if (!el) return; + if (locationData && locationData.lat != null) { + el.textContent = locationData.lat.toFixed(4) + ', ' + locationData.lon.toFixed(4) + ' (' + locationData.source + ')'; + } else { + el.textContent = 'No location'; + } + } + + // ----------------------------------------------------------------------- + // Render all + // ----------------------------------------------------------------------- + function renderAll(m) { renderCpuCard(m); renderMemoryCard(m); + renderTempCard(m); renderDiskCard(m); + renderNetworkCard(m); renderProcessCard(m); renderSystemInfoCard(m); updateSidebarQuickStats(m); updateSidebarProcesses(m); + updateSidebarNetwork(m); + updateSidebarBattery(m); + } + + // ----------------------------------------------------------------------- + // Location & Weather Fetching + // ----------------------------------------------------------------------- + + function fetchLocation() { + fetch('/system/location') + .then(function (r) { return r.json(); }) + .then(function (data) { + // If server only has default/none, check client-side saved location + if ((data.source === 'default' || data.source === 'none') && + window.ObserverLocation && ObserverLocation.getShared) { + var shared = ObserverLocation.getShared(); + if (shared && shared.lat && shared.lon) { + data.lat = shared.lat; + data.lon = shared.lon; + data.source = 'manual'; + } + } + locationData = data; + updateSidebarLocation(); + renderLocationCard(); + if (data.lat != null) fetchWeather(); + }) + .catch(function () { + renderLocationCard(); + }); + } + + function fetchWeather() { + if (!locationData || locationData.lat == null) return; + fetch('/system/weather?lat=' + locationData.lat + '&lon=' + locationData.lon) + .then(function (r) { return r.json(); }) + .then(function (data) { + weatherData = data; + renderLocationCard(); + }) + .catch(function () {}); } // ----------------------------------------------------------------------- @@ -267,7 +859,7 @@ const SystemHealth = (function () { var html = ''; devices.forEach(function (d) { html += '
' + - d.type + ' #' + d.index + ' — ' + (d.name || 'Unknown') + '
'; + escHtml(d.type) + ' #' + d.index + ' — ' + escHtml(d.name || 'Unknown') + '
'; }); sidebarEl.innerHTML = html; } @@ -284,12 +876,24 @@ const SystemHealth = (function () { // ----------------------------------------------------------------------- function init() { + globeDestroyed = false; connect(); refreshSdr(); + fetchLocation(); + + // Refresh weather every 10 minutes + weatherTimer = setInterval(function () { + fetchWeather(); + }, 600000); } function destroy() { disconnect(); + destroyGlobe(); + if (weatherTimer) { + clearInterval(weatherTimer); + weatherTimer = null; + } } return { diff --git a/static/js/modes/websdr.js b/static/js/modes/websdr.js index b2a60fe..f99a6ea 100644 --- a/static/js/modes/websdr.js +++ b/static/js/modes/websdr.js @@ -1005,6 +1005,15 @@ function escapeHtmlWebsdr(str) { // ============== EXPORTS ============== +/** + * Destroy — disconnect audio and clear S-meter timer for clean mode switching. + */ +function destroyWebSDR() { + disconnectFromReceiver(); +} + +const WebSDR = { destroy: destroyWebSDR }; + window.initWebSDR = initWebSDR; window.searchReceivers = searchReceivers; window.selectReceiver = selectReceiver; @@ -1015,3 +1024,4 @@ window.disconnectFromReceiver = disconnectFromReceiver; window.tuneKiwi = tuneKiwi; window.tuneFromBar = tuneFromBar; window.setKiwiVolume = setKiwiVolume; +window.WebSDR = WebSDR; diff --git a/static/js/modes/wifi.js b/static/js/modes/wifi.js index 35c93c0..bc44c02 100644 --- a/static/js/modes/wifi.js +++ b/static/js/modes/wifi.js @@ -28,9 +28,9 @@ const WiFiMode = (function() { maxProbes: 1000, }; - // ========================================================================== - // Agent Support - // ========================================================================== + // ========================================================================== + // Agent Support + // ========================================================================== /** * Get the API base URL, routing through agent proxy if agent is selected. @@ -59,49 +59,49 @@ const WiFiMode = (function() { /** * Check for agent mode conflicts before starting WiFi scan. */ - function checkAgentConflicts() { - if (typeof currentAgent === 'undefined' || currentAgent === 'local') { - return true; - } - if (typeof checkAgentModeConflict === 'function') { - return checkAgentModeConflict('wifi'); - } - return true; - } - - function getChannelPresetList(preset) { - switch (preset) { - case '2.4-common': - return '1,6,11'; - case '2.4-all': - return '1,2,3,4,5,6,7,8,9,10,11,12,13'; - case '5-low': - return '36,40,44,48'; - case '5-mid': - return '52,56,60,64'; - case '5-high': - return '149,153,157,161,165'; - default: - return ''; - } - } - - function buildChannelConfig() { - const preset = document.getElementById('wifiChannelPreset')?.value || ''; - const listInput = document.getElementById('wifiChannelList')?.value || ''; - const singleInput = document.getElementById('wifiChannel')?.value || ''; - - const listValue = listInput.trim(); - const presetValue = getChannelPresetList(preset); - - const channels = listValue || presetValue || ''; - const channel = channels ? null : (singleInput.trim() ? parseInt(singleInput.trim()) : null); - - return { - channels: channels || null, - channel: Number.isFinite(channel) ? channel : null, - }; - } + function checkAgentConflicts() { + if (typeof currentAgent === 'undefined' || currentAgent === 'local') { + return true; + } + if (typeof checkAgentModeConflict === 'function') { + return checkAgentModeConflict('wifi'); + } + return true; + } + + function getChannelPresetList(preset) { + switch (preset) { + case '2.4-common': + return '1,6,11'; + case '2.4-all': + return '1,2,3,4,5,6,7,8,9,10,11,12,13'; + case '5-low': + return '36,40,44,48'; + case '5-mid': + return '52,56,60,64'; + case '5-high': + return '149,153,157,161,165'; + default: + return ''; + } + } + + function buildChannelConfig() { + const preset = document.getElementById('wifiChannelPreset')?.value || ''; + const listInput = document.getElementById('wifiChannelList')?.value || ''; + const singleInput = document.getElementById('wifiChannel')?.value || ''; + + const listValue = listInput.trim(); + const presetValue = getChannelPresetList(preset); + + const channels = listValue || presetValue || ''; + const channel = channels ? null : (singleInput.trim() ? parseInt(singleInput.trim()) : null); + + return { + channels: channels || null, + channel: Number.isFinite(channel) ? channel : null, + }; + } // ========================================================================== // State @@ -120,23 +120,23 @@ const WiFiMode = (function() { let channelStats = []; let recommendations = []; - // UI state - let selectedNetwork = null; - let currentFilter = 'all'; - let currentSort = { field: 'rssi', order: 'desc' }; - let renderFramePending = false; - const pendingRender = { - table: false, - stats: false, - radar: false, - chart: false, - detail: false, - }; - const listenersBound = { - scanTabs: false, - filters: false, - sort: false, - }; + // UI state + let selectedNetwork = null; + let currentFilter = 'all'; + let currentSort = { field: 'rssi', order: 'desc' }; + let renderFramePending = false; + const pendingRender = { + table: false, + stats: false, + radar: false, + chart: false, + detail: false, + }; + const listenersBound = { + scanTabs: false, + filters: false, + sort: false, + }; // Agent state let showAllAgentsMode = false; // Show combined results from all agents @@ -165,11 +165,11 @@ const WiFiMode = (function() { // Initialize components initScanModeTabs(); - initNetworkFilters(); - initSortControls(); - initProximityRadar(); - initChannelChart(); - scheduleRender({ table: true, stats: true, radar: true, chart: true }); + initNetworkFilters(); + initSortControls(); + initProximityRadar(); + initChannelChart(); + scheduleRender({ table: true, stats: true, radar: true, chart: true }); // Check if already scanning checkScanStatus(); @@ -378,16 +378,16 @@ const WiFiMode = (function() { // Scan Mode Tabs // ========================================================================== - function initScanModeTabs() { - if (listenersBound.scanTabs) return; - if (elements.scanModeQuick) { - elements.scanModeQuick.addEventListener('click', () => setScanMode('quick')); - } - if (elements.scanModeDeep) { - elements.scanModeDeep.addEventListener('click', () => setScanMode('deep')); - } - listenersBound.scanTabs = true; - } + function initScanModeTabs() { + if (listenersBound.scanTabs) return; + if (elements.scanModeQuick) { + elements.scanModeQuick.addEventListener('click', () => setScanMode('quick')); + } + if (elements.scanModeDeep) { + elements.scanModeDeep.addEventListener('click', () => setScanMode('deep')); + } + listenersBound.scanTabs = true; + } function setScanMode(mode) { scanMode = mode; @@ -511,10 +511,10 @@ const WiFiMode = (function() { setScanning(true, 'deep'); try { - const iface = elements.interfaceSelect?.value || null; - const band = document.getElementById('wifiBand')?.value || 'all'; - const channelConfig = buildChannelConfig(); - const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + const iface = elements.interfaceSelect?.value || null; + const band = document.getElementById('wifiBand')?.value || 'all'; + const channelConfig = buildChannelConfig(); + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; let response; if (isAgentMode) { @@ -523,25 +523,25 @@ const WiFiMode = (function() { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - interface: iface, - scan_type: 'deep', - band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5', - channel: channelConfig.channel, - channels: channelConfig.channels, - }), - }); - } else { - response = await fetch(`${CONFIG.apiBase}/scan/start`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - interface: iface, - band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5', - channel: channelConfig.channel, - channels: channelConfig.channels, - }), - }); - } + interface: iface, + scan_type: 'deep', + band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5', + channel: channelConfig.channel, + channels: channelConfig.channels, + }), + }); + } else { + response = await fetch(`${CONFIG.apiBase}/scan/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + interface: iface, + band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5', + channel: channelConfig.channel, + channels: channelConfig.channels, + }), + }); + } if (!response.ok) { const error = await response.json(); @@ -572,8 +572,8 @@ const WiFiMode = (function() { } } - async function stopScan() { - console.log('[WiFiMode] Stopping scan...'); + async function stopScan() { + console.log('[WiFiMode] Stopping scan...'); // Stop polling if (pollTimer) { @@ -585,41 +585,41 @@ const WiFiMode = (function() { stopAgentDeepScanPolling(); // Close event stream - if (eventSource) { - eventSource.close(); - eventSource = null; - } - - // Update UI immediately so mode transitions are responsive even if the - // backend needs extra time to terminate subprocesses. - setScanning(false); - - // Stop scan on server (local or agent) - const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; - const timeoutMs = isAgentMode ? 8000 : 2200; - const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null; - const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null; - - try { - if (isAgentMode) { - await fetch(`/controller/agents/${currentAgent}/wifi/stop`, { - method: 'POST', - ...(controller ? { signal: controller.signal } : {}), - }); - } else if (scanMode === 'deep') { - await fetch(`${CONFIG.apiBase}/scan/stop`, { - method: 'POST', - ...(controller ? { signal: controller.signal } : {}), - }); - } - } catch (error) { - console.warn('[WiFiMode] Error stopping scan:', error); - } finally { - if (timeoutId) { - clearTimeout(timeoutId); - } - } - } + if (eventSource) { + eventSource.close(); + eventSource = null; + } + + // Update UI immediately so mode transitions are responsive even if the + // backend needs extra time to terminate subprocesses. + setScanning(false); + + // Stop scan on server (local or agent) + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + const timeoutMs = isAgentMode ? 8000 : 2200; + const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null; + const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null; + + try { + if (isAgentMode) { + await fetch(`/controller/agents/${currentAgent}/wifi/stop`, { + method: 'POST', + ...(controller ? { signal: controller.signal } : {}), + }); + } else if (scanMode === 'deep') { + await fetch(`${CONFIG.apiBase}/scan/stop`, { + method: 'POST', + ...(controller ? { signal: controller.signal } : {}), + }); + } + } catch (error) { + console.warn('[WiFiMode] Error stopping scan:', error); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } + } function setScanning(scanning, mode = null) { isScanning = scanning; @@ -713,10 +713,10 @@ const WiFiMode = (function() { }, CONFIG.pollInterval); } - function processQuickScanResult(result) { - // Update networks - result.access_points.forEach(ap => { - networks.set(ap.bssid, ap); + function processQuickScanResult(result) { + // Update networks + result.access_points.forEach(ap => { + networks.set(ap.bssid, ap); }); // Update channel stats (calculate from networks if not provided by API) @@ -724,12 +724,12 @@ const WiFiMode = (function() { recommendations = result.recommendations || []; // If no channel stats from API, calculate from networks - if (channelStats.length === 0 && networks.size > 0) { - channelStats = calculateChannelStats(); - } - - // Update UI - scheduleRender({ table: true, stats: true, radar: true, chart: true }); + if (channelStats.length === 0 && networks.size > 0) { + channelStats = calculateChannelStats(); + } + + // Update UI + scheduleRender({ table: true, stats: true, radar: true, chart: true }); // Callbacks result.access_points.forEach(ap => { @@ -938,25 +938,25 @@ const WiFiMode = (function() { } } - function handleNetworkUpdate(network) { - networks.set(network.bssid, network); - scheduleRender({ - table: true, - stats: true, - radar: true, - chart: true, - detail: selectedNetwork === network.bssid, - }); - - if (onNetworkUpdate) onNetworkUpdate(network); - } - - function handleClientUpdate(client) { - clients.set(client.mac, client); - scheduleRender({ stats: true }); - - // Update client display if this client belongs to the selected network - updateClientInList(client); + function handleNetworkUpdate(network) { + networks.set(network.bssid, network); + scheduleRender({ + table: true, + stats: true, + radar: true, + chart: true, + detail: selectedNetwork === network.bssid, + }); + + if (onNetworkUpdate) onNetworkUpdate(network); + } + + function handleClientUpdate(client) { + clients.set(client.mac, client); + scheduleRender({ stats: true }); + + // Update client display if this client belongs to the selected network + updateClientInList(client); if (onClientUpdate) onClientUpdate(client); } @@ -970,37 +970,37 @@ const WiFiMode = (function() { if (onProbeRequest) onProbeRequest(probe); } - function handleHiddenRevealed(bssid, revealedSsid) { - const network = networks.get(bssid); - if (network) { - network.revealed_essid = revealedSsid; - network.display_name = `${revealedSsid} (revealed)`; - scheduleRender({ - table: true, - detail: selectedNetwork === bssid, - }); - - // Show notification - showInfo(`Hidden SSID revealed: ${revealedSsid}`); - } - } + function handleHiddenRevealed(bssid, revealedSsid) { + const network = networks.get(bssid); + if (network) { + network.revealed_essid = revealedSsid; + network.display_name = `${revealedSsid} (revealed)`; + scheduleRender({ + table: true, + detail: selectedNetwork === bssid, + }); + + // Show notification + showInfo(`Hidden SSID revealed: ${revealedSsid}`); + } + } // ========================================================================== // Network Table // ========================================================================== - function initNetworkFilters() { - if (listenersBound.filters) return; - if (!elements.networkFilters) return; - - elements.networkFilters.addEventListener('click', (e) => { - if (e.target.matches('.wifi-filter-btn')) { - const filter = e.target.dataset.filter; - setNetworkFilter(filter); - } - }); - listenersBound.filters = true; - } + function initNetworkFilters() { + if (listenersBound.filters) return; + if (!elements.networkFilters) return; + + elements.networkFilters.addEventListener('click', (e) => { + if (e.target.matches('.wifi-filter-btn')) { + const filter = e.target.dataset.filter; + setNetworkFilter(filter); + } + }); + listenersBound.filters = true; + } function setNetworkFilter(filter) { currentFilter = filter; @@ -1015,11 +1015,11 @@ const WiFiMode = (function() { updateNetworkTable(); } - function initSortControls() { - if (listenersBound.sort) return; - if (!elements.networkTable) return; - - elements.networkTable.addEventListener('click', (e) => { + function initSortControls() { + if (listenersBound.sort) return; + if (!elements.networkTable) return; + + elements.networkTable.addEventListener('click', (e) => { const th = e.target.closest('th[data-sort]'); if (th) { const field = th.dataset.sort; @@ -1029,54 +1029,54 @@ const WiFiMode = (function() { currentSort.field = field; currentSort.order = 'desc'; } - updateNetworkTable(); - } - }); - - if (elements.networkTableBody) { - elements.networkTableBody.addEventListener('click', (e) => { - const row = e.target.closest('tr[data-bssid]'); - if (!row) return; - selectNetwork(row.dataset.bssid); - }); - } - listenersBound.sort = true; - } - - function scheduleRender(flags = {}) { - pendingRender.table = pendingRender.table || Boolean(flags.table); - pendingRender.stats = pendingRender.stats || Boolean(flags.stats); - pendingRender.radar = pendingRender.radar || Boolean(flags.radar); - pendingRender.chart = pendingRender.chart || Boolean(flags.chart); - pendingRender.detail = pendingRender.detail || Boolean(flags.detail); - - if (renderFramePending) return; - renderFramePending = true; - - requestAnimationFrame(() => { - renderFramePending = false; - - if (pendingRender.table) updateNetworkTable(); - if (pendingRender.stats) updateStats(); - if (pendingRender.radar) updateProximityRadar(); - if (pendingRender.chart) updateChannelChart(); - if (pendingRender.detail && selectedNetwork) { - updateDetailPanel(selectedNetwork, { refreshClients: false }); - } - - pendingRender.table = false; - pendingRender.stats = false; - pendingRender.radar = false; - pendingRender.chart = false; - pendingRender.detail = false; - }); - } - - function updateNetworkTable() { - if (!elements.networkTableBody) return; - - // Filter networks - let filtered = Array.from(networks.values()); + updateNetworkTable(); + } + }); + + if (elements.networkTableBody) { + elements.networkTableBody.addEventListener('click', (e) => { + const row = e.target.closest('tr[data-bssid]'); + if (!row) return; + selectNetwork(row.dataset.bssid); + }); + } + listenersBound.sort = true; + } + + function scheduleRender(flags = {}) { + pendingRender.table = pendingRender.table || Boolean(flags.table); + pendingRender.stats = pendingRender.stats || Boolean(flags.stats); + pendingRender.radar = pendingRender.radar || Boolean(flags.radar); + pendingRender.chart = pendingRender.chart || Boolean(flags.chart); + pendingRender.detail = pendingRender.detail || Boolean(flags.detail); + + if (renderFramePending) return; + renderFramePending = true; + + requestAnimationFrame(() => { + renderFramePending = false; + + if (pendingRender.table) updateNetworkTable(); + if (pendingRender.stats) updateStats(); + if (pendingRender.radar) updateProximityRadar(); + if (pendingRender.chart) updateChannelChart(); + if (pendingRender.detail && selectedNetwork) { + updateDetailPanel(selectedNetwork, { refreshClients: false }); + } + + pendingRender.table = false; + pendingRender.stats = false; + pendingRender.radar = false; + pendingRender.chart = false; + pendingRender.detail = false; + }); + } + + function updateNetworkTable() { + if (!elements.networkTableBody) return; + + // Filter networks + let filtered = Array.from(networks.values()); switch (currentFilter) { case 'hidden': @@ -1126,44 +1126,44 @@ const WiFiMode = (function() { return bVal > aVal ? 1 : bVal < aVal ? -1 : 0; } else { return aVal > bVal ? 1 : aVal < bVal ? -1 : 0; - } - }); - - if (filtered.length === 0) { - let message = 'Start scanning to discover networks'; - let type = 'empty'; - if (isScanning) { - message = 'Scanning for networks...'; - type = 'loading'; - } else if (networks.size > 0) { - message = 'No networks match current filters'; - } - if (typeof renderCollectionState === 'function') { - renderCollectionState(elements.networkTableBody, { - type, - message, - columns: 7, - }); - } else { - elements.networkTableBody.innerHTML = `
${escapeHtml(message)}
`; - } - return; - } - - // Render table - elements.networkTableBody.innerHTML = filtered.map(n => createNetworkRow(n)).join(''); - } + } + }); - function createNetworkRow(network) { - const rssi = network.rssi_current; - const security = network.security || 'Unknown'; - const signalClass = rssi >= -50 ? 'signal-strong' : - rssi >= -70 ? 'signal-medium' : - rssi >= -85 ? 'signal-weak' : 'signal-very-weak'; - - const securityClass = security === 'Open' ? 'security-open' : - security === 'WEP' ? 'security-wep' : - security.includes('WPA3') ? 'security-wpa3' : 'security-wpa'; + if (filtered.length === 0) { + let message = 'Start scanning to discover networks'; + let type = 'empty'; + if (isScanning) { + message = 'Scanning for networks...'; + type = 'loading'; + } else if (networks.size > 0) { + message = 'No networks match current filters'; + } + if (typeof renderCollectionState === 'function') { + renderCollectionState(elements.networkTableBody, { + type, + message, + columns: 7, + }); + } else { + elements.networkTableBody.innerHTML = `
${escapeHtml(message)}
`; + } + return; + } + + // Render table + elements.networkTableBody.innerHTML = filtered.map(n => createNetworkRow(n)).join(''); + } + + function createNetworkRow(network) { + const rssi = network.rssi_current; + const security = network.security || 'Unknown'; + const signalClass = rssi >= -50 ? 'signal-strong' : + rssi >= -70 ? 'signal-medium' : + rssi >= -85 ? 'signal-weak' : 'signal-very-weak'; + + const securityClass = security === 'Open' ? 'security-open' : + security === 'WEP' ? 'security-wep' : + security.includes('WPA3') ? 'security-wpa3' : 'security-wpa'; const hiddenBadge = network.is_hidden ? 'Hidden' : ''; const newBadge = network.is_new ? 'New' : ''; @@ -1172,25 +1172,25 @@ const WiFiMode = (function() { const agentName = network._agent || 'Local'; const agentClass = agentName === 'Local' ? 'agent-local' : 'agent-remote'; - return ` - - - ${escapeHtml(network.display_name || network.essid || '[Hidden]')} - ${hiddenBadge}${newBadge} - + return ` + + + ${escapeHtml(network.display_name || network.essid || '[Hidden]')} + ${hiddenBadge}${newBadge} + ${escapeHtml(network.bssid)} ${network.channel || '-'} - - ${rssi != null ? rssi : '-'} - - - ${escapeHtml(security)} - + + ${rssi != null ? rssi : '-'} + + + ${escapeHtml(security)} + ${network.client_count || 0} ${escapeHtml(agentName)} @@ -1199,12 +1199,12 @@ const WiFiMode = (function() { `; } - function updateNetworkRow(network) { - scheduleRender({ - table: true, - detail: selectedNetwork === network.bssid, - }); - } + function updateNetworkRow(network) { + scheduleRender({ + table: true, + detail: selectedNetwork === network.bssid, + }); + } function selectNetwork(bssid) { selectedNetwork = bssid; @@ -1227,9 +1227,9 @@ const WiFiMode = (function() { // Detail Panel // ========================================================================== - function updateDetailPanel(bssid, options = {}) { - const { refreshClients = true } = options; - if (!elements.detailDrawer) return; + function updateDetailPanel(bssid, options = {}) { + const { refreshClients = true } = options; + if (!elements.detailDrawer) return; const network = networks.get(bssid); if (!network) { @@ -1274,11 +1274,11 @@ const WiFiMode = (function() { // Show the drawer elements.detailDrawer.classList.add('open'); - // Fetch and display clients for this network - if (refreshClients) { - fetchClientsForNetwork(network.bssid); - } - } + // Fetch and display clients for this network + if (refreshClients) { + fetchClientsForNetwork(network.bssid); + } + } function closeDetail() { selectedNetwork = null; @@ -1294,18 +1294,18 @@ const WiFiMode = (function() { // Client Display // ========================================================================== - async function fetchClientsForNetwork(bssid) { - if (!elements.detailClientList) return; - const listContainer = elements.detailClientList.querySelector('.wifi-client-list'); - - if (listContainer && typeof renderCollectionState === 'function') { - renderCollectionState(listContainer, { type: 'loading', message: 'Loading clients...' }); - elements.detailClientList.style.display = 'block'; - } - - try { - const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; - let response; + async function fetchClientsForNetwork(bssid) { + if (!elements.detailClientList) return; + const listContainer = elements.detailClientList.querySelector('.wifi-client-list'); + + if (listContainer && typeof renderCollectionState === 'function') { + renderCollectionState(listContainer, { type: 'loading', message: 'Loading clients...' }); + elements.detailClientList.style.display = 'block'; + } + + try { + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + let response; if (isAgentMode) { // Route through agent proxy @@ -1314,44 +1314,44 @@ const WiFiMode = (function() { response = await fetch(`${CONFIG.apiBase}/clients?bssid=${encodeURIComponent(bssid)}&associated=true`); } - if (!response.ok) { - if (listContainer && typeof renderCollectionState === 'function') { - renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' }); - elements.detailClientList.style.display = 'block'; - } else { - elements.detailClientList.style.display = 'none'; - } - return; - } + if (!response.ok) { + if (listContainer && typeof renderCollectionState === 'function') { + renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' }); + elements.detailClientList.style.display = 'block'; + } else { + elements.detailClientList.style.display = 'none'; + } + return; + } const data = await response.json(); // Handle agent response format (may be nested in 'result') const result = isAgentMode && data.result ? data.result : data; const clientList = result.clients || []; - if (clientList.length > 0) { - renderClientList(clientList, bssid); - elements.detailClientList.style.display = 'block'; - } else { - const countBadge = document.getElementById('wifiClientCountBadge'); - if (countBadge) countBadge.textContent = '0'; - if (listContainer && typeof renderCollectionState === 'function') { - renderCollectionState(listContainer, { type: 'empty', message: 'No associated clients' }); - elements.detailClientList.style.display = 'block'; - } else { - elements.detailClientList.style.display = 'none'; - } - } - } catch (error) { - console.debug('[WiFiMode] Error fetching clients:', error); - if (listContainer && typeof renderCollectionState === 'function') { - renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' }); - elements.detailClientList.style.display = 'block'; - } else { - elements.detailClientList.style.display = 'none'; - } - } - } + if (clientList.length > 0) { + renderClientList(clientList, bssid); + elements.detailClientList.style.display = 'block'; + } else { + const countBadge = document.getElementById('wifiClientCountBadge'); + if (countBadge) countBadge.textContent = '0'; + if (listContainer && typeof renderCollectionState === 'function') { + renderCollectionState(listContainer, { type: 'empty', message: 'No associated clients' }); + elements.detailClientList.style.display = 'block'; + } else { + elements.detailClientList.style.display = 'none'; + } + } + } catch (error) { + console.debug('[WiFiMode] Error fetching clients:', error); + if (listContainer && typeof renderCollectionState === 'function') { + renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' }); + elements.detailClientList.style.display = 'block'; + } else { + elements.detailClientList.style.display = 'none'; + } + } + } function renderClientList(clientList, bssid) { const container = elements.detailClientList?.querySelector('.wifi-client-list'); @@ -1708,16 +1708,16 @@ const WiFiMode = (function() { /** * Clear all collected data. */ - function clearData() { - networks.clear(); - clients.clear(); - probeRequests = []; - channelStats = []; - recommendations = []; - if (selectedNetwork) { - closeDetail(); - } - scheduleRender({ table: true, stats: true, radar: true, chart: true }); + function clearData() { + networks.clear(); + clients.clear(); + probeRequests = []; + channelStats = []; + recommendations = []; + if (selectedNetwork) { + closeDetail(); + } + scheduleRender({ table: true, stats: true, radar: true, chart: true }); } /** @@ -1763,12 +1763,12 @@ const WiFiMode = (function() { clientsToRemove.push(mac); } }); - clientsToRemove.forEach(mac => clients.delete(mac)); - if (selectedNetwork && !networks.has(selectedNetwork)) { - closeDetail(); - } - scheduleRender({ table: true, stats: true, radar: true, chart: true }); - } + clientsToRemove.forEach(mac => clients.delete(mac)); + if (selectedNetwork && !networks.has(selectedNetwork)) { + closeDetail(); + } + scheduleRender({ table: true, stats: true, radar: true, chart: true }); + } /** * Refresh WiFi interfaces from current agent. @@ -1811,7 +1811,28 @@ const WiFiMode = (function() { onNetworkUpdate: (cb) => { onNetworkUpdate = cb; }, onClientUpdate: (cb) => { onClientUpdate = cb; }, onProbeRequest: (cb) => { onProbeRequest = cb; }, + + // Lifecycle + destroy, }; + + /** + * Destroy — close SSE stream and clear polling timers for clean mode switching. + */ + function destroy() { + if (eventSource) { + eventSource.close(); + eventSource = null; + } + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + if (agentPollTimer) { + clearInterval(agentPollTimer); + agentPollTimer = null; + } + } })(); // Auto-initialize when DOM is ready diff --git a/templates/adsb_dashboard.html b/templates/adsb_dashboard.html index 782f3e4..e5b98ba 100644 --- a/templates/adsb_dashboard.html +++ b/templates/adsb_dashboard.html @@ -1762,31 +1762,37 @@ ACARS: ${r.statistics.acarsMessages} messages`; airbandSelect.innerHTML = ''; if (devices.length === 0) { - adsbSelect.innerHTML = ''; - airbandSelect.innerHTML = ''; + adsbSelect.innerHTML = ''; + airbandSelect.innerHTML = ''; airbandSelect.disabled = true; } else { devices.forEach((dev, i) => { const idx = dev.index !== undefined ? dev.index : i; + const sdrType = dev.sdr_type || 'rtlsdr'; + const compositeVal = `${sdrType}:${idx}`; const displayName = `SDR ${idx}: ${dev.name}`; // Add to ADS-B selector const adsbOpt = document.createElement('option'); - adsbOpt.value = idx; + adsbOpt.value = compositeVal; + adsbOpt.dataset.sdrType = sdrType; + adsbOpt.dataset.index = idx; adsbOpt.textContent = displayName; adsbSelect.appendChild(adsbOpt); // Add to Airband selector const airbandOpt = document.createElement('option'); - airbandOpt.value = idx; + airbandOpt.value = compositeVal; + airbandOpt.dataset.sdrType = sdrType; + airbandOpt.dataset.index = idx; airbandOpt.textContent = displayName; airbandSelect.appendChild(airbandOpt); }); // Default: ADS-B uses first device, Airband uses second (if available) - adsbSelect.value = devices[0].index !== undefined ? devices[0].index : 0; + adsbSelect.value = adsbSelect.options[0]?.value || 'rtlsdr:0'; if (devices.length > 1) { - airbandSelect.value = devices[1].index !== undefined ? devices[1].index : 1; + airbandSelect.value = airbandSelect.options[1]?.value || airbandSelect.options[0]?.value || 'rtlsdr:0'; } // Show warning if only one device @@ -1797,8 +1803,8 @@ ACARS: ${r.statistics.acarsMessages} messages`; } }) .catch(() => { - document.getElementById('adsbDeviceSelect').innerHTML = ''; - document.getElementById('airbandDeviceSelect').innerHTML = ''; + document.getElementById('adsbDeviceSelect').innerHTML = ''; + document.getElementById('airbandDeviceSelect').innerHTML = ''; }); } @@ -2161,11 +2167,14 @@ sudo make install } } - // Get selected ADS-B device - const adsbDevice = parseInt(document.getElementById('adsbDeviceSelect').value) || 0; + // Get selected ADS-B device (composite value "sdr_type:index") + const adsbSelectVal = document.getElementById('adsbDeviceSelect').value || 'rtlsdr:0'; + const [adsbSdrType, adsbDeviceIdx] = adsbSelectVal.includes(':') ? adsbSelectVal.split(':') : ['rtlsdr', adsbSelectVal]; + const adsbDevice = parseInt(adsbDeviceIdx) || 0; const requestBody = { device: adsbDevice, + sdr_type: adsbSdrType, bias_t: getBiasTEnabled() }; if (remoteConfig) { @@ -2316,11 +2325,13 @@ sudo make install } const sessionDevice = session.device_index; + const sessionSdrType = session.sdr_type || 'rtlsdr'; if (sessionDevice !== null && sessionDevice !== undefined) { adsbActiveDevice = sessionDevice; const adsbSelect = document.getElementById('adsbDeviceSelect'); if (adsbSelect) { - adsbSelect.value = sessionDevice; + // Use composite value to select the correct device+type + adsbSelect.value = `${sessionSdrType}:${sessionDevice}`; } } @@ -3834,8 +3845,9 @@ sudo make install function startAcars() { const acarsSelect = document.getElementById('acarsDeviceSelect'); - const device = acarsSelect.value; - const sdr_type = acarsSelect.selectedOptions[0]?.dataset.sdrType || 'rtlsdr'; + const compositeVal = acarsSelect.value || 'rtlsdr:0'; + const [sdr_type, deviceIdx] = compositeVal.includes(':') ? compositeVal.split(':') : ['rtlsdr', compositeVal]; + const device = deviceIdx; const frequencies = getAcarsRegionFreqs(); // Check if using agent mode @@ -4179,13 +4191,16 @@ sudo make install const select = document.getElementById('acarsDeviceSelect'); select.innerHTML = ''; if (devices.length === 0) { - select.innerHTML = ''; + select.innerHTML = ''; } else { devices.forEach((d, i) => { const opt = document.createElement('option'); - opt.value = d.index || i; - opt.dataset.sdrType = d.sdr_type || 'rtlsdr'; - opt.textContent = `SDR ${d.index || i}: ${d.name || d.type || 'SDR'}`; + const sdrType = d.sdr_type || 'rtlsdr'; + const idx = d.index !== undefined ? d.index : i; + opt.value = `${sdrType}:${idx}`; + opt.dataset.sdrType = sdrType; + opt.dataset.index = idx; + opt.textContent = `SDR ${idx}: ${d.name || d.type || 'SDR'}`; select.appendChild(opt); }); } @@ -4277,8 +4292,9 @@ sudo make install function startVdl2() { const vdl2Select = document.getElementById('vdl2DeviceSelect'); - const device = vdl2Select.value; - const sdr_type = vdl2Select.selectedOptions[0]?.dataset.sdrType || 'rtlsdr'; + const compositeVal = vdl2Select.value || 'rtlsdr:0'; + const [sdr_type, deviceIdx] = compositeVal.includes(':') ? compositeVal.split(':') : ['rtlsdr', compositeVal]; + const device = deviceIdx; const frequencies = getVdl2RegionFreqs(); // Check if using agent mode @@ -4723,13 +4739,16 @@ sudo make install const select = document.getElementById('vdl2DeviceSelect'); select.innerHTML = ''; if (devices.length === 0) { - select.innerHTML = ''; + select.innerHTML = ''; } else { devices.forEach((d, i) => { const opt = document.createElement('option'); - opt.value = d.index || i; - opt.dataset.sdrType = d.sdr_type || 'rtlsdr'; - opt.textContent = `SDR ${d.index || i}: ${d.name || d.type || 'SDR'}`; + const sdrType = d.sdr_type || 'rtlsdr'; + const idx = d.index !== undefined ? d.index : i; + opt.value = `${sdrType}:${idx}`; + opt.dataset.sdrType = sdrType; + opt.dataset.index = idx; + opt.textContent = `SDR ${idx}: ${d.name || d.type || 'SDR'}`; select.appendChild(opt); }); } @@ -5715,13 +5734,16 @@ sudo make install select.innerHTML = ''; if (devices.length === 0) { - select.innerHTML = ''; + select.innerHTML = ''; } else { devices.forEach(device => { const opt = document.createElement('option'); - opt.value = device.index; - opt.dataset.sdrType = device.sdr_type || 'rtlsdr'; - opt.textContent = `SDR ${device.index}: ${device.name || device.type || 'SDR'}`; + const sdrType = device.sdr_type || 'rtlsdr'; + const idx = device.index !== undefined ? device.index : 0; + opt.value = `${sdrType}:${idx}`; + opt.dataset.sdrType = sdrType; + opt.dataset.index = idx; + opt.textContent = `SDR ${idx}: ${device.name || device.type || 'SDR'}`; select.appendChild(opt); }); } diff --git a/templates/index.html b/templates/index.html index ddc1b96..92a459a 100644 --- a/templates/index.html +++ b/templates/index.html @@ -83,6 +83,7 @@ spaceweather: "{{ url_for('static', filename='css/modes/space-weather.css') }}", wefax: "{{ url_for('static', filename='css/modes/wefax.css') }}", morse: "{{ url_for('static', filename='css/modes/morse.css') }}", + radiosonde: "{{ url_for('static', filename='css/modes/radiosonde.css') }}", system: "{{ url_for('static', filename='css/modes/system.css') }}" }; window.INTERCEPT_MODE_STYLE_LOADED = {}; @@ -307,6 +308,10 @@ GPS +
@@ -696,6 +701,8 @@ {% include 'partials/modes/ais.html' %} + {% include 'partials/modes/radiosonde.html' %} + {% include 'partials/modes/spy-stations.html' %} {% include 'partials/modes/meshtastic.html' %} @@ -3127,9 +3134,17 @@ + + + + +
+

Network

+
--
+
+ + + + + +
+

Location

+
--
+
+

SDR Devices

diff --git a/templates/partials/nav.html b/templates/partials/nav.html index 68a6d6c..dfee292 100644 --- a/templates/partials/nav.html +++ b/templates/partials/nav.html @@ -84,6 +84,7 @@ {{ mode_item('ais', 'Vessels', '', '/ais/dashboard') }} {{ mode_item('aprs', 'APRS', '') }} {{ mode_item('gps', 'GPS', '') }} + {{ mode_item('radiosonde', 'Radiosonde', '') }}
diff --git a/tests/test_morse.py b/tests/test_morse.py index b17c7de..22fafc0 100644 --- a/tests/test_morse.py +++ b/tests/test_morse.py @@ -257,8 +257,8 @@ class TestMorseLifecycleRoutes: released_devices = [] - monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode: None) - monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx: released_devices.append(idx)) + monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode, sdr_type='rtlsdr': None) + monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx, sdr_type='rtlsdr': released_devices.append(idx)) class DummyDevice: sdr_type = morse_routes.SDRType.RTL_SDR @@ -337,8 +337,8 @@ class TestMorseLifecycleRoutes: released_devices = [] - monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode: None) - monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx: released_devices.append(idx)) + monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode, sdr_type='rtlsdr': None) + monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx, sdr_type='rtlsdr': released_devices.append(idx)) class DummyDevice: sdr_type = morse_routes.SDRType.RTL_SDR @@ -421,8 +421,8 @@ class TestMorseLifecycleRoutes: released_devices = [] - monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode: None) - monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx: released_devices.append(idx)) + monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode, sdr_type='rtlsdr': None) + monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx, sdr_type='rtlsdr': released_devices.append(idx)) class DummyDevice: def __init__(self, index: int): diff --git a/tests/test_system.py b/tests/test_system.py index 6d2ea59..bd5fdac 100644 --- a/tests/test_system.py +++ b/tests/test_system.py @@ -30,6 +30,32 @@ def test_metrics_returns_expected_keys(client): assert 'uptime_human' in data['system'] +def test_metrics_enhanced_keys(client): + """GET /system/metrics returns enhanced metric keys.""" + _login(client) + resp = client.get('/system/metrics') + assert resp.status_code == 200 + data = resp.get_json() + # New enhanced keys + assert 'network' in data + assert 'disk_io' in data + assert 'boot_time' in data + assert 'battery' in data + assert 'fans' in data + assert 'power' in data + + # CPU should have per_core and freq + if data['cpu'] is not None: + assert 'per_core' in data['cpu'] + assert 'freq' in data['cpu'] + + # Network should have interfaces and connections + if data['network'] is not None: + assert 'interfaces' in data['network'] + assert 'connections' in data['network'] + assert 'io' in data['network'] + + def test_metrics_without_psutil(client): """Metrics degrade gracefully when psutil is unavailable.""" _login(client) @@ -45,6 +71,11 @@ def test_metrics_without_psutil(client): assert data['cpu'] is None assert data['memory'] is None assert data['disk'] is None + assert data['network'] is None + assert data['disk_io'] is None + assert data['battery'] is None + assert data['boot_time'] is None + assert data['power'] is None finally: mod._HAS_PSUTIL = orig @@ -87,3 +118,113 @@ def test_stream_returns_sse_content_type(client): resp = client.get('/system/stream') assert resp.status_code == 200 assert 'text/event-stream' in resp.content_type + + +def test_location_returns_shape(client): + """GET /system/location returns lat/lon/source shape.""" + _login(client) + resp = client.get('/system/location') + assert resp.status_code == 200 + data = resp.get_json() + assert 'lat' in data + assert 'lon' in data + assert 'source' in data + + +def test_location_from_gps(client): + """Location endpoint returns GPS data when fix available.""" + _login(client) + mock_pos = MagicMock() + mock_pos.fix_quality = 3 + mock_pos.latitude = 51.5074 + mock_pos.longitude = -0.1278 + mock_pos.satellites = 12 + mock_pos.epx = 2.5 + mock_pos.epy = 3.1 + mock_pos.altitude = 45.0 + + with patch('routes.system.get_current_position', return_value=mock_pos, create=True): + # Patch the import inside the function + import routes.system as mod + original = mod._get_observer_location + + def _patched(): + with patch('utils.gps.get_current_position', return_value=mock_pos): + return original() + + mod._get_observer_location = _patched + try: + resp = client.get('/system/location') + finally: + mod._get_observer_location = original + + assert resp.status_code == 200 + data = resp.get_json() + assert data['source'] == 'gps' + assert data['lat'] == 51.5074 + assert data['lon'] == -0.1278 + assert data['gps']['fix_quality'] == 3 + assert data['gps']['satellites'] == 12 + assert data['gps']['accuracy'] == 3.1 + assert data['gps']['altitude'] == 45.0 + + +def test_location_falls_back_to_defaults(client): + """Location endpoint returns constants defaults when GPS and config unavailable.""" + _login(client) + resp = client.get('/system/location') + assert resp.status_code == 200 + data = resp.get_json() + assert 'source' in data + # Should get location from config or default constants + assert data['lat'] is not None + assert data['lon'] is not None + assert data['source'] in ('config', 'default') + + +def test_weather_requires_location(client): + """Weather endpoint returns error when no location available.""" + _login(client) + # Without lat/lon params and no GPS state or config + resp = client.get('/system/weather') + assert resp.status_code == 200 + data = resp.get_json() + # Either returns weather or error (depending on config) + assert 'error' in data or 'temp_c' in data + + +def test_weather_with_mocked_response(client): + """Weather endpoint returns parsed weather data with mocked HTTP.""" + _login(client) + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + 'current_condition': [{ + 'temp_C': '22', + 'temp_F': '72', + 'weatherDesc': [{'value': 'Clear'}], + 'humidity': '45', + 'windspeedMiles': '8', + 'winddir16Point': 'NW', + 'FeelsLikeC': '20', + 'visibility': '10', + 'pressure': '1013', + }] + } + mock_resp.raise_for_status = MagicMock() + + import routes.system as mod + # Clear cache + mod._weather_cache.clear() + mod._weather_cache_time = 0.0 + + with patch('routes.system._requests') as mock_requests: + mock_requests.get.return_value = mock_resp + resp = client.get('/system/weather?lat=40.7&lon=-74.0') + + assert resp.status_code == 200 + data = resp.get_json() + assert data['temp_c'] == '22' + assert data['condition'] == 'Clear' + assert data['humidity'] == '45' + assert data['wind_mph'] == '8' diff --git a/tests/test_wefax.py b/tests/test_wefax.py index 9a62192..54c2d14 100644 --- a/tests/test_wefax.py +++ b/tests/test_wefax.py @@ -54,72 +54,72 @@ class TestWeFaxStations: from utils.wefax_stations import get_station assert get_station('noj') is not None - def test_get_station_not_found(self): - """get_station() should return None for unknown callsign.""" - from utils.wefax_stations import get_station - assert get_station('XXXXX') is None - - def test_resolve_tuning_frequency_auto_uses_carrier_for_known_station(self): - """Known station frequencies default to carrier-list behavior in auto mode.""" - from utils.wefax_stations import resolve_tuning_frequency_khz - - tuned, reference, offset_applied = resolve_tuning_frequency_khz( - listed_frequency_khz=4298.0, - station_callsign='NOJ', - frequency_reference='auto', - ) - - assert math.isclose(tuned, 4296.1, abs_tol=1e-6) - assert reference == 'carrier' - assert offset_applied is True - - def test_resolve_tuning_frequency_auto_preserves_unknown_station_input(self): - """Ad-hoc frequencies (no station metadata) should be treated as dial.""" - from utils.wefax_stations import resolve_tuning_frequency_khz - - tuned, reference, offset_applied = resolve_tuning_frequency_khz( - listed_frequency_khz=4298.0, - station_callsign='', - frequency_reference='auto', - ) - - assert math.isclose(tuned, 4298.0, abs_tol=1e-6) - assert reference == 'dial' - assert offset_applied is False - - def test_resolve_tuning_frequency_dial_override(self): - """Explicit dial reference must bypass USB alignment.""" - from utils.wefax_stations import resolve_tuning_frequency_khz - - tuned, reference, offset_applied = resolve_tuning_frequency_khz( - listed_frequency_khz=4298.0, - station_callsign='NOJ', - frequency_reference='dial', - ) - - assert math.isclose(tuned, 4298.0, abs_tol=1e-6) - assert reference == 'dial' - assert offset_applied is False - - def test_resolve_tuning_frequency_rejects_invalid_reference(self): - """Invalid frequency reference values should raise a validation error.""" - from utils.wefax_stations import resolve_tuning_frequency_khz - - try: - resolve_tuning_frequency_khz( - listed_frequency_khz=4298.0, - station_callsign='NOJ', - frequency_reference='invalid', - ) - assert False, "Expected ValueError for invalid frequency_reference" - except ValueError as exc: - assert 'frequency_reference' in str(exc) - - def test_station_frequencies_have_khz(self): - """Each frequency entry must have 'khz' and 'description'.""" - from utils.wefax_stations import load_stations - for station in load_stations(): - for freq in station['frequencies']: + def test_get_station_not_found(self): + """get_station() should return None for unknown callsign.""" + from utils.wefax_stations import get_station + assert get_station('XXXXX') is None + + def test_resolve_tuning_frequency_auto_uses_carrier_for_known_station(self): + """Known station frequencies default to carrier-list behavior in auto mode.""" + from utils.wefax_stations import resolve_tuning_frequency_khz + + tuned, reference, offset_applied = resolve_tuning_frequency_khz( + listed_frequency_khz=4298.0, + station_callsign='NOJ', + frequency_reference='auto', + ) + + assert math.isclose(tuned, 4296.1, abs_tol=1e-6) + assert reference == 'carrier' + assert offset_applied is True + + def test_resolve_tuning_frequency_auto_preserves_unknown_station_input(self): + """Ad-hoc frequencies (no station metadata) should be treated as dial.""" + from utils.wefax_stations import resolve_tuning_frequency_khz + + tuned, reference, offset_applied = resolve_tuning_frequency_khz( + listed_frequency_khz=4298.0, + station_callsign='', + frequency_reference='auto', + ) + + assert math.isclose(tuned, 4298.0, abs_tol=1e-6) + assert reference == 'dial' + assert offset_applied is False + + def test_resolve_tuning_frequency_dial_override(self): + """Explicit dial reference must bypass USB alignment.""" + from utils.wefax_stations import resolve_tuning_frequency_khz + + tuned, reference, offset_applied = resolve_tuning_frequency_khz( + listed_frequency_khz=4298.0, + station_callsign='NOJ', + frequency_reference='dial', + ) + + assert math.isclose(tuned, 4298.0, abs_tol=1e-6) + assert reference == 'dial' + assert offset_applied is False + + def test_resolve_tuning_frequency_rejects_invalid_reference(self): + """Invalid frequency reference values should raise a validation error.""" + from utils.wefax_stations import resolve_tuning_frequency_khz + + try: + resolve_tuning_frequency_khz( + listed_frequency_khz=4298.0, + station_callsign='NOJ', + frequency_reference='invalid', + ) + assert False, "Expected ValueError for invalid frequency_reference" + except ValueError as exc: + assert 'frequency_reference' in str(exc) + + def test_station_frequencies_have_khz(self): + """Each frequency entry must have 'khz' and 'description'.""" + from utils.wefax_stations import load_stations + for station in load_stations(): + for freq in station['frequencies']: assert 'khz' in freq, f"{station['callsign']} missing khz" assert 'description' in freq, f"{station['callsign']} missing description" assert isinstance(freq['khz'], (int, float)) @@ -281,7 +281,7 @@ class TestWeFaxDecoder: # Route tests # --------------------------------------------------------------------------- -class TestWeFaxRoutes: +class TestWeFaxRoutes: """WeFax route endpoint tests.""" def test_status(self, client): @@ -390,11 +390,11 @@ class TestWeFaxRoutes: data = response.get_json() assert 'LPM' in data['message'] - def test_start_success(self, client): - """POST /wefax/start with valid params should succeed.""" - _login_session(client) - mock_decoder = MagicMock() - mock_decoder.is_running = False + def test_start_success(self, client): + """POST /wefax/start with valid params should succeed.""" + _login_session(client) + mock_decoder = MagicMock() + mock_decoder.is_running = False mock_decoder.start.return_value = True with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder), \ @@ -411,46 +411,46 @@ class TestWeFaxRoutes: content_type='application/json', ) - assert response.status_code == 200 - data = response.get_json() - assert data['status'] == 'started' - assert data['frequency_khz'] == 4298 - assert data['usb_offset_applied'] is True - assert math.isclose(data['tuned_frequency_khz'], 4296.1, abs_tol=1e-6) - assert data['frequency_reference'] == 'carrier' - assert data['station'] == 'NOJ' - mock_decoder.start.assert_called_once() - start_kwargs = mock_decoder.start.call_args.kwargs - assert math.isclose(start_kwargs['frequency_khz'], 4296.1, abs_tol=1e-6) - - def test_start_respects_dial_reference_override(self, client): - """POST /wefax/start with dial reference should not apply USB offset.""" - _login_session(client) - mock_decoder = MagicMock() - mock_decoder.is_running = False - mock_decoder.start.return_value = True - - with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder), \ - patch('routes.wefax.app_module.claim_sdr_device', return_value=None): - response = client.post( - '/wefax/start', - data=json.dumps({ - 'frequency_khz': 4298, - 'station': 'NOJ', - 'device': 0, - 'frequency_reference': 'dial', - }), - content_type='application/json', - ) - - assert response.status_code == 200 - data = response.get_json() - assert data['status'] == 'started' - assert data['usb_offset_applied'] is False - assert math.isclose(data['tuned_frequency_khz'], 4298.0, abs_tol=1e-6) - assert data['frequency_reference'] == 'dial' - start_kwargs = mock_decoder.start.call_args.kwargs - assert math.isclose(start_kwargs['frequency_khz'], 4298.0, abs_tol=1e-6) + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'started' + assert data['frequency_khz'] == 4298 + assert data['usb_offset_applied'] is True + assert math.isclose(data['tuned_frequency_khz'], 4296.1, abs_tol=1e-6) + assert data['frequency_reference'] == 'carrier' + assert data['station'] == 'NOJ' + mock_decoder.start.assert_called_once() + start_kwargs = mock_decoder.start.call_args.kwargs + assert math.isclose(start_kwargs['frequency_khz'], 4296.1, abs_tol=1e-6) + + def test_start_respects_dial_reference_override(self, client): + """POST /wefax/start with dial reference should not apply USB offset.""" + _login_session(client) + mock_decoder = MagicMock() + mock_decoder.is_running = False + mock_decoder.start.return_value = True + + with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder), \ + patch('routes.wefax.app_module.claim_sdr_device', return_value=None): + response = client.post( + '/wefax/start', + data=json.dumps({ + 'frequency_khz': 4298, + 'station': 'NOJ', + 'device': 0, + 'frequency_reference': 'dial', + }), + content_type='application/json', + ) + + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'started' + assert data['usb_offset_applied'] is False + assert math.isclose(data['tuned_frequency_khz'], 4298.0, abs_tol=1e-6) + assert data['frequency_reference'] == 'dial' + start_kwargs = mock_decoder.start.call_args.kwargs + assert math.isclose(start_kwargs['frequency_khz'], 4298.0, abs_tol=1e-6) def test_start_device_busy(self, client): """POST /wefax/start should return 409 when device is busy.""" @@ -509,83 +509,83 @@ class TestWeFaxRoutes: assert response.status_code == 400 - def test_delete_image_wrong_extension(self, client): - """DELETE /wefax/images/ should reject non-PNG.""" - _login_session(client) - mock_decoder = MagicMock() - - with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder): - response = client.delete('/wefax/images/test.jpg') - - assert response.status_code == 400 - - def test_schedule_enable_applies_usb_alignment(self, client): - """Scheduler should receive tuned USB dial frequency in auto mode.""" - _login_session(client) - mock_scheduler = MagicMock() - mock_scheduler.enable.return_value = { - 'enabled': True, - 'scheduled_count': 2, - 'total_broadcasts': 2, - } - - with patch('utils.wefax_scheduler.get_wefax_scheduler', return_value=mock_scheduler): - response = client.post( - '/wefax/schedule/enable', - data=json.dumps({ - 'station': 'NOJ', - 'frequency_khz': 4298, - 'device': 0, - }), - content_type='application/json', - ) - - assert response.status_code == 200 - data = response.get_json() - assert data['status'] == 'ok' - assert data['usb_offset_applied'] is True - assert math.isclose(data['tuned_frequency_khz'], 4296.1, abs_tol=1e-6) - enable_kwargs = mock_scheduler.enable.call_args.kwargs - assert math.isclose(enable_kwargs['frequency_khz'], 4296.1, abs_tol=1e-6) - - -class TestWeFaxProgressCallback: - """Regression tests for WeFax route-level progress callback behavior.""" - - def test_terminal_progress_releases_active_device(self): - """Terminal decoder events must release any manually claimed SDR.""" - import routes.wefax as wefax_routes - - original_device = wefax_routes.wefax_active_device - try: - wefax_routes.wefax_active_device = 3 - with patch('routes.wefax.app_module.release_sdr_device') as mock_release: - wefax_routes._progress_callback({ - 'type': 'wefax_progress', - 'status': 'error', - 'message': 'decode failed', - }) - - mock_release.assert_called_once_with(3) - assert wefax_routes.wefax_active_device is None - finally: - wefax_routes.wefax_active_device = original_device - - def test_non_terminal_progress_does_not_release_active_device(self): - """Non-terminal progress updates must not release SDR ownership.""" - import routes.wefax as wefax_routes - - original_device = wefax_routes.wefax_active_device - try: - wefax_routes.wefax_active_device = 4 - with patch('routes.wefax.app_module.release_sdr_device') as mock_release: - wefax_routes._progress_callback({ - 'type': 'wefax_progress', - 'status': 'receiving', - 'line_count': 120, - }) - - mock_release.assert_not_called() - assert wefax_routes.wefax_active_device == 4 - finally: - wefax_routes.wefax_active_device = original_device + def test_delete_image_wrong_extension(self, client): + """DELETE /wefax/images/ should reject non-PNG.""" + _login_session(client) + mock_decoder = MagicMock() + + with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder): + response = client.delete('/wefax/images/test.jpg') + + assert response.status_code == 400 + + def test_schedule_enable_applies_usb_alignment(self, client): + """Scheduler should receive tuned USB dial frequency in auto mode.""" + _login_session(client) + mock_scheduler = MagicMock() + mock_scheduler.enable.return_value = { + 'enabled': True, + 'scheduled_count': 2, + 'total_broadcasts': 2, + } + + with patch('utils.wefax_scheduler.get_wefax_scheduler', return_value=mock_scheduler): + response = client.post( + '/wefax/schedule/enable', + data=json.dumps({ + 'station': 'NOJ', + 'frequency_khz': 4298, + 'device': 0, + }), + content_type='application/json', + ) + + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'ok' + assert data['usb_offset_applied'] is True + assert math.isclose(data['tuned_frequency_khz'], 4296.1, abs_tol=1e-6) + enable_kwargs = mock_scheduler.enable.call_args.kwargs + assert math.isclose(enable_kwargs['frequency_khz'], 4296.1, abs_tol=1e-6) + + +class TestWeFaxProgressCallback: + """Regression tests for WeFax route-level progress callback behavior.""" + + def test_terminal_progress_releases_active_device(self): + """Terminal decoder events must release any manually claimed SDR.""" + import routes.wefax as wefax_routes + + original_device = wefax_routes.wefax_active_device + try: + wefax_routes.wefax_active_device = 3 + with patch('routes.wefax.app_module.release_sdr_device') as mock_release: + wefax_routes._progress_callback({ + 'type': 'wefax_progress', + 'status': 'error', + 'message': 'decode failed', + }) + + mock_release.assert_called_once_with(3, 'rtlsdr') + assert wefax_routes.wefax_active_device is None + finally: + wefax_routes.wefax_active_device = original_device + + def test_non_terminal_progress_does_not_release_active_device(self): + """Non-terminal progress updates must not release SDR ownership.""" + import routes.wefax as wefax_routes + + original_device = wefax_routes.wefax_active_device + try: + wefax_routes.wefax_active_device = 4 + with patch('routes.wefax.app_module.release_sdr_device') as mock_release: + wefax_routes._progress_callback({ + 'type': 'wefax_progress', + 'status': 'receiving', + 'line_count': 120, + }) + + mock_release.assert_not_called() + assert wefax_routes.wefax_active_device == 4 + finally: + wefax_routes.wefax_active_device = original_device diff --git a/utils/constants.py b/utils/constants.py index a0be48b..85552bd 100644 --- a/utils/constants.py +++ b/utils/constants.py @@ -300,6 +300,20 @@ SUBGHZ_PRESETS = { } +# ============================================================================= +# RADIOSONDE (Weather Balloon Tracking) +# ============================================================================= + +# UDP port for radiosonde_auto_rx telemetry broadcast +RADIOSONDE_UDP_PORT = 55673 + +# Radiosonde process termination timeout +RADIOSONDE_TERMINATE_TIMEOUT = 5 + +# Maximum age for balloon data before cleanup (30 min — balloons move slowly) +MAX_RADIOSONDE_AGE_SECONDS = 1800 + + # ============================================================================= # DEAUTH ATTACK DETECTION # ============================================================================= diff --git a/utils/dependencies.py b/utils/dependencies.py index 933be4b..e482bb2 100644 --- a/utils/dependencies.py +++ b/utils/dependencies.py @@ -1,49 +1,57 @@ from __future__ import annotations -import logging -import os -import platform -import shutil -import subprocess -from typing import Any +import logging +import os +import platform +import shutil +import subprocess +from typing import Any logger = logging.getLogger('intercept.dependencies') # Additional paths to search for tools (e.g., /usr/sbin on Debian) EXTRA_TOOL_PATHS = ['/usr/sbin', '/sbin'] +# Tools installed to non-standard locations (not on PATH) +KNOWN_TOOL_PATHS: dict[str, list[str]] = { + 'auto_rx.py': [ + '/opt/radiosonde_auto_rx/auto_rx/auto_rx.py', + '/opt/auto_rx/auto_rx.py', + ], +} + def check_tool(name: str) -> bool: """Check if a tool is installed.""" return get_tool_path(name) is not None -def get_tool_path(name: str) -> str | None: - """Get the full path to a tool, checking standard PATH and extra locations.""" - # Optional explicit override, e.g. INTERCEPT_RTL_FM_PATH=/opt/homebrew/bin/rtl_fm - env_key = f"INTERCEPT_{name.upper().replace('-', '_')}_PATH" - env_path = os.environ.get(env_key) - if env_path and os.path.isfile(env_path) and os.access(env_path, os.X_OK): - return env_path - - # Prefer native Homebrew binaries on Apple Silicon to avoid mixing Rosetta - # /usr/local tools with arm64 Python/runtime. - if platform.system() == 'Darwin': - machine = platform.machine().lower() - preferred_paths: list[str] = [] - if machine in {'arm64', 'aarch64'}: - preferred_paths.append('/opt/homebrew/bin') - preferred_paths.append('/usr/local/bin') - - for base in preferred_paths: - full_path = os.path.join(base, name) - if os.path.isfile(full_path) and os.access(full_path, os.X_OK): - return full_path - - # First check standard PATH - path = shutil.which(name) - if path: - return path +def get_tool_path(name: str) -> str | None: + """Get the full path to a tool, checking standard PATH and extra locations.""" + # Optional explicit override, e.g. INTERCEPT_RTL_FM_PATH=/opt/homebrew/bin/rtl_fm + env_key = f"INTERCEPT_{name.upper().replace('-', '_')}_PATH" + env_path = os.environ.get(env_key) + if env_path and os.path.isfile(env_path) and os.access(env_path, os.X_OK): + return env_path + + # Prefer native Homebrew binaries on Apple Silicon to avoid mixing Rosetta + # /usr/local tools with arm64 Python/runtime. + if platform.system() == 'Darwin': + machine = platform.machine().lower() + preferred_paths: list[str] = [] + if machine in {'arm64', 'aarch64'}: + preferred_paths.append('/opt/homebrew/bin') + preferred_paths.append('/usr/local/bin') + + for base in preferred_paths: + full_path = os.path.join(base, name) + if os.path.isfile(full_path) and os.access(full_path, os.X_OK): + return full_path + + # First check standard PATH + path = shutil.which(name) + if path: + return path # Check additional paths (e.g., /usr/sbin for aircrack-ng on Debian) for extra_path in EXTRA_TOOL_PATHS: @@ -51,6 +59,11 @@ def get_tool_path(name: str) -> str | None: if os.path.isfile(full_path) and os.access(full_path, os.X_OK): return full_path + # Check known non-standard install locations + for known_path in KNOWN_TOOL_PATHS.get(name, []): + if os.path.isfile(known_path): + return known_path + return None @@ -447,6 +460,20 @@ TOOL_DEPENDENCIES = { } } }, + 'radiosonde': { + 'name': 'Radiosonde Tracking', + 'tools': { + 'auto_rx.py': { + 'required': True, + 'description': 'Radiosonde weather balloon decoder', + 'install': { + 'apt': 'Run ./setup.sh (clones from GitHub)', + 'brew': 'Run ./setup.sh (clones from GitHub)', + 'manual': 'https://github.com/projecthorus/radiosonde_auto_rx' + } + } + } + }, 'tscm': { 'name': 'TSCM Counter-Surveillance', 'tools': { diff --git a/utils/sdr/detection.py b/utils/sdr/detection.py index cec3fc1..2f90d3f 100644 --- a/utils/sdr/detection.py +++ b/utils/sdr/detection.py @@ -6,31 +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 -import time -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__) - -# 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 +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: @@ -112,21 +112,21 @@ def detect_rtlsdr_devices() -> list[SDRDevice]: lib_paths = ['/usr/local/lib', '/opt/homebrew/lib'] current_ld = env.get('DYLD_LIBRARY_PATH', '') env['DYLD_LIBRARY_PATH'] = ':'.join(lib_paths + [current_ld] if current_ld else lib_paths) - result = subprocess.run( - ['rtl_test', '-t'], - capture_output=True, - text=True, - encoding='utf-8', - errors='replace', - timeout=5, - env=env - ) - output = result.stderr + result.stdout - - # Parse device info from rtl_test output - # Format: "0: Realtek, RTL2838UHIDIR, SN: 00000001" - # Require a non-empty serial to avoid matching malformed lines like "SN:". - device_pattern = r'(\d+):\s+(.+?),\s*SN:\s*(\S+)\s*$' + result = subprocess.run( + ['rtl_test', '-t'], + capture_output=True, + text=True, + encoding='utf-8', + errors='replace', + timeout=5, + env=env + ) + output = result.stderr + result.stdout + + # Parse device info from rtl_test output + # Format: "0: Realtek, RTL2838UHIDIR, SN: 00000001" + # Require a non-empty serial to avoid matching malformed lines like "SN:". + device_pattern = r'(\d+):\s+(.+?),\s*SN:\s*(\S+)\s*$' from .rtlsdr import RTLSDRCommandBuilder @@ -134,14 +134,14 @@ def detect_rtlsdr_devices() -> list[SDRDevice]: line = line.strip() match = re.match(device_pattern, line) if match: - devices.append(SDRDevice( - sdr_type=SDRType.RTL_SDR, - index=int(match.group(1)), - name=match.group(2).strip().rstrip(','), - serial=match.group(3), - driver='rtlsdr', - capabilities=RTLSDRCommandBuilder.CAPABILITIES - )) + devices.append(SDRDevice( + sdr_type=SDRType.RTL_SDR, + index=int(match.group(1)), + name=match.group(2).strip().rstrip(','), + serial=match.group(3), + driver='rtlsdr', + capabilities=RTLSDRCommandBuilder.CAPABILITIES + )) # Fallback: if we found devices but couldn't parse details if not devices: @@ -314,29 +314,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. - """ - 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 +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( @@ -374,12 +374,12 @@ def detect_hackrf_devices() -> list[SDRDevice]: capabilities=HackRFCommandBuilder.CAPABILITIES )) - except Exception as e: - logger.debug(f"HackRF detection error: {e}") - - _hackrf_cache = list(devices) - _hackrf_cache_ts = now - 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: @@ -413,31 +413,73 @@ def probe_rtlsdr_device(device_index: int) -> str | None: lib_paths + [current_ld] if current_ld else lib_paths ) - result = subprocess.run( + # Use Popen with early termination instead of run() with full timeout. + # rtl_test prints device info to stderr quickly, then keeps running + # its test loop. We kill it as soon as we see success or failure. + proc = subprocess.Popen( ['rtl_test', '-d', str(device_index), '-t'], - capture_output=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, - timeout=3, env=env, ) - output = result.stderr + result.stdout - if 'usb_claim_interface' in output or 'Failed to open' in output: + import select + error_found = False + device_found = False + deadline = time.monotonic() + 3.0 + + try: + while time.monotonic() < deadline: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + # Wait for stderr output with timeout + ready, _, _ = select.select( + [proc.stderr], [], [], min(remaining, 0.1) + ) + if ready: + line = proc.stderr.readline() + if not line: + break # EOF — process closed stderr + # Check for no-device messages first (before success check, + # since "No supported devices found" also contains "Found" + "device") + if 'no supported devices' in line.lower() or 'no matching devices' in line.lower(): + error_found = True + break + if 'usb_claim_interface' in line or 'Failed to open' in line: + error_found = True + break + if 'Found' in line and 'device' in line.lower(): + # Device opened successfully — no need to wait longer + device_found = True + break + if proc.poll() is not None: + break # Process exited + if not device_found and not error_found and proc.poll() is not None and proc.returncode != 0: + # rtl_test exited with error and we never saw a success message + error_found = True + finally: + try: + proc.kill() + except OSError: + pass + proc.wait() + if device_found: + # Allow the kernel to fully release the USB interface + # before the caller opens the device with dump1090/rtl_fm/etc. + time.sleep(0.5) + + if error_found: logger.warning( f"RTL-SDR device {device_index} USB probe failed: " f"device busy or unavailable" ) return ( - f'SDR device {device_index} is busy at the USB level — ' - f'another process outside INTERCEPT may be using it. ' - f'Check for stale rtl_fm/rtl_433/dump1090 processes, ' - f'or try a different device.' + f'SDR device {device_index} is not available — ' + f'check that the RTL-SDR is connected and not in use by another process.' ) - except subprocess.TimeoutExpired: - # rtl_test opened the device successfully and is running the - # test — that means the device *is* available. - pass except Exception as e: logger.debug(f"RTL-SDR probe error for device {device_index}: {e}")