diff --git a/Dockerfile b/Dockerfile index b5c8904..aab54a8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -200,6 +200,16 @@ 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 \ + && 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 +256,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 b74410b..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) @@ -766,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(), }, @@ -784,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 = [] @@ -799,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: @@ -829,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/radiosonde.py b/routes/radiosonde.py new file mode 100644 index 0000000..2fb1ae0 --- /dev/null +++ b/routes/radiosonde.py @@ -0,0 +1,547 @@ +"""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 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.join('data', 'radiosonde') + os.makedirs(cfg_dir, exist_ok=True) + cfg_path = os.path.join(cfg_dir, 'station.cfg') + + # Minimal station.cfg that auto_rx needs + cfg = f"""# Auto-generated by INTERCEPT for radiosonde_auto_rx +[search_params] +min_freq = {freq_min} +max_freq = {freq_max} +rx_timeout = 180 +whitelist = [] +blacklist = [] +greylist = [] + +[sdr] +sdr_type = rtlsdr +rtlsdr_device_idx = {device_index} +rtlsdr_gain = {gain} +rtlsdr_ppm = {ppm} +rtlsdr_bias = {str(bias_t).lower()} + +[habitat] +upload_enabled = False + +[aprs] +upload_enabled = False + +[sondehub] +upload_enabled = False + +[positioning] +station_lat = 0.0 +station_lon = 0.0 +station_alt = 0.0 + +[logging] +per_sonde_log = True +log_directory = ./data/radiosonde/logs + +[advanced] +web_host = 127.0.0.1 +web_port = 0 +udp_broadcast_port = {udp_port} +""" + + 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 + cfg_dir = os.path.dirname(os.path.abspath(cfg_path)) + if auto_rx_path.endswith('.py'): + cmd = ['python', auto_rx_path, '-c', cfg_dir] + else: + cmd = [auto_rx_path, '-c', cfg_dir] + + 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, + ) + + # 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/setup.sh b/setup.sh index 148fd6f..e60fd77 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,37 @@ WRAPPER ) } +install_radiosonde_auto_rx() { + info "Installing radiosonde_auto_rx (weather balloon decoder)..." + local install_dir="/opt/radiosonde_auto_rx" + + ( + 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" + pip3 install --quiet -r requirements.txt || { + warn "Failed to install radiosonde_auto_rx Python dependencies" + exit 1 + } + + 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 +857,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 +944,19 @@ 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 ]; 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 +1348,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 +1530,19 @@ 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 ]; 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/templates/index.html b/templates/index.html index 58d1497..2844a23 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,6 +3134,12 @@ + +
+ 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..72c2bf2 100644 --- a/utils/dependencies.py +++ b/utils/dependencies.py @@ -1,11 +1,11 @@ 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') @@ -18,32 +18,32 @@ def check_tool(name: str) -> bool: 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: @@ -447,6 +447,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': {