feat: add radiosonde weather balloon tracking mode

Integrate radiosonde_auto_rx for automatic weather balloon detection and
decoding on 400-406 MHz. Includes UDP telemetry parsing, Leaflet map with
altitude-colored markers and trajectory tracks, SDR device registry
integration, setup script installation, and Docker support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-02-27 10:46:33 +00:00
parent 5aa68a49c6
commit 5b06c57565
12 changed files with 1254 additions and 39 deletions

View File

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

19
app.py
View File

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

View File

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

View File

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

547
routes/radiosonde.py Normal file
View File

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

View File

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

View File

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

View File

@@ -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 @@
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg></span>
<span class="mode-name">GPS</span>
</button>
<button class="mode-card mode-card-sm" onclick="selectMode('radiosonde')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v6"/><circle cx="12" cy="12" r="4"/><path d="M12 16v6"/><path d="M4.93 4.93l4.24 4.24"/><path d="M14.83 14.83l4.24 4.24"/></svg></span>
<span class="mode-name">Radiosonde</span>
</button>
</div>
</div>
@@ -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 @@
</div>
</div>
<!-- Radiosonde Visuals -->
<div id="radiosondeVisuals" class="radiosonde-visuals-container" style="display: none;">
<div id="radiosondeMapContainer" style="flex: 1; min-height: 300px; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-primary);"></div>
<div id="radiosondeCardContainer" class="radiosonde-card-container"></div>
</div>
<!-- System Health Visuals -->
<div id="systemVisuals" class="sys-visuals-container" style="display: none;">
<div class="sys-dashboard">
@@ -3387,6 +3400,7 @@
subghz: { label: 'SubGHz', indicator: 'SUBGHZ', outputTitle: 'SubGHz Transceiver', group: 'signals' },
aprs: { label: 'APRS', indicator: 'APRS', outputTitle: 'APRS Tracker', group: 'tracking' },
gps: { label: 'GPS', indicator: 'GPS', outputTitle: 'GPS Receiver', group: 'tracking' },
radiosonde: { label: 'Radiosonde', indicator: 'SONDE', outputTitle: 'Radiosonde Decoder', group: 'tracking' },
satellite: { label: 'Satellite', indicator: 'SATELLITE', outputTitle: 'Satellite Monitor', group: 'space' },
sstv: { label: 'ISS SSTV', indicator: 'ISS SSTV', outputTitle: 'ISS SSTV Decoder', group: 'space' },
weathersat: { label: 'Weather Sat', indicator: 'WEATHER SAT', outputTitle: 'Weather Satellite Decoder', group: 'space' },
@@ -4175,6 +4189,7 @@
document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs');
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
document.getElementById('aisMode')?.classList.toggle('active', mode === 'ais');
document.getElementById('radiosondeMode')?.classList.toggle('active', mode === 'radiosonde');
document.getElementById('spystationsMode')?.classList.toggle('active', mode === 'spystations');
document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic');
document.getElementById('websdrMode')?.classList.toggle('active', mode === 'websdr');
@@ -4224,6 +4239,7 @@
const wefaxVisuals = document.getElementById('wefaxVisuals');
const spaceWeatherVisuals = document.getElementById('spaceWeatherVisuals');
const waterfallVisuals = document.getElementById('waterfallVisuals');
const radiosondeVisuals = document.getElementById('radiosondeVisuals');
const systemVisuals = document.getElementById('systemVisuals');
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none';
@@ -4242,6 +4258,7 @@
if (wefaxVisuals) wefaxVisuals.style.display = mode === 'wefax' ? 'flex' : 'none';
if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? 'flex' : 'none';
if (waterfallVisuals) waterfallVisuals.style.display = mode === 'waterfall' ? 'flex' : 'none';
if (radiosondeVisuals) radiosondeVisuals.style.display = mode === 'radiosonde' ? 'flex' : 'none';
if (systemVisuals) systemVisuals.style.display = mode === 'system' ? 'flex' : 'none';
// Prevent Leaflet heatmap redraws on hidden BT Locate map containers.
@@ -4329,7 +4346,7 @@
// Show RTL-SDR device section for modes that use it
const rtlDeviceSection = document.getElementById('rtlDeviceSection');
if (rtlDeviceSection) {
rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'aprs' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'morse') ? 'block' : 'none';
rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'aprs' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'morse' || mode === 'radiosonde') ? 'block' : 'none';
// Save original sidebar position of SDR device section (once)
if (!rtlDeviceSection._origParent) {
rtlDeviceSection._origParent = rtlDeviceSection.parentNode;
@@ -4434,6 +4451,11 @@
if (typeof Waterfall !== 'undefined') Waterfall.init();
} else if (mode === 'morse') {
MorseMode.init();
} else if (mode === 'radiosonde') {
initRadiosondeMap();
setTimeout(() => {
if (radiosondeMap) radiosondeMap.invalidateSize();
}, 100);
} else if (mode === 'system') {
SystemHealth.init();
}

View File

@@ -0,0 +1,376 @@
<!-- RADIOSONDE WEATHER BALLOON TRACKING MODE -->
<div id="radiosondeMode" class="mode-content">
<div class="section">
<h3>Radiosonde Decoder</h3>
<div class="info-text" style="margin-bottom: 15px;">
Track weather balloons via radiosonde telemetry on 400&ndash;406 MHz. Decodes position, altitude, temperature, humidity, and pressure.
</div>
</div>
<div class="section">
<h3>Settings</h3>
<div class="form-group">
<label>Region / Frequency Band</label>
<select id="radiosondeRegionSelect" onchange="updateRadiosondeFreqRange()">
<option value="global" selected>Global (400&ndash;406 MHz)</option>
<option value="eu">Europe (400&ndash;403 MHz)</option>
<option value="us">US (400&ndash;406 MHz)</option>
<option value="au">Australia (400&ndash;403 MHz)</option>
<option value="custom">Custom&hellip;</option>
</select>
</div>
<div class="form-group" id="radiosondeCustomFreqGroup" style="display: none;">
<label>Frequency Range (MHz)</label>
<div style="display: flex; gap: 8px; align-items: center;">
<input type="number" id="radiosondeFreqMin" value="400.0" min="380" max="410" step="0.1" style="width: 50%;" placeholder="Min">
<span style="color: var(--text-dim);">&ndash;</span>
<input type="number" id="radiosondeFreqMax" value="406.0" min="380" max="410" step="0.1" style="width: 50%;" placeholder="Max">
</div>
</div>
<div class="form-group">
<label>Gain (dB, 0 = auto)</label>
<input type="number" id="radiosondeGainInput" value="40" min="0" max="50" placeholder="0-50">
</div>
</div>
<div class="section">
<h3>Status</h3>
<div id="radiosondeStatusDisplay" class="info-text">
<p>Status: <span id="radiosondeStatusText" style="color: var(--accent-yellow);">Standby</span></p>
<p>Balloons: <span id="radiosondeBalloonCount">0</span></p>
<p>Last update: <span id="radiosondeLastUpdate">&mdash;</span></p>
</div>
</div>
<!-- Antenna Guide -->
<div class="section">
<h3>Antenna Guide</h3>
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
400 MHz meteorological band &mdash; stock SDR antenna may work for nearby launches
</p>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">Simple Quarter-Wave</strong>
<ul style="margin: 6px 0 0 14px; padding: 0;">
<li><strong style="color: var(--text-primary);">Element length:</strong> ~18.7 cm (quarter-wave at 400 MHz)</li>
<li><strong style="color: var(--text-primary);">Material:</strong> Wire or copper rod</li>
<li><strong style="color: var(--text-primary);">Orientation:</strong> Vertical</li>
<li><strong style="color: var(--text-primary);">Placement:</strong> Outdoors, as high as possible with clear sky view</li>
</ul>
</div>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">Tips</strong>
<ul style="margin: 6px 0 0 14px; padding: 0;">
<li><strong style="color: var(--text-primary);">Range:</strong> 200+ km with LNA and good antenna placement</li>
<li><strong style="color: var(--text-primary);">LNA:</strong> Recommended &mdash; mount near antenna for best results</li>
<li><strong style="color: var(--text-primary);">Launches:</strong> Typically 2&times;/day at 00Z and 12Z from weather stations</li>
</ul>
</div>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong>
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Frequency band</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">400&ndash;406 MHz</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Quarter-wave length</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">18.7 cm</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Common types</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">RS41, RS92, DFM, M10</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Max altitude</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">~35 km (115,000 ft)</td>
</tr>
<tr>
<td style="padding: 3px 4px; color: var(--text-dim);">Flight duration</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">~90 min ascent</td>
</tr>
</table>
</div>
</div>
</div>
<button class="run-btn" id="startRadiosondeBtn" onclick="startRadiosondeTracking()">
Start Radiosonde Tracking
</button>
<button class="stop-btn" id="stopRadiosondeBtn" onclick="stopRadiosondeTracking()" style="display: none;">
Stop Radiosonde Tracking
</button>
</div>
<script>
let radiosondeEventSource = null;
let radiosondeBalloons = {};
function updateRadiosondeFreqRange() {
const region = document.getElementById('radiosondeRegionSelect').value;
const customGroup = document.getElementById('radiosondeCustomFreqGroup');
const minInput = document.getElementById('radiosondeFreqMin');
const maxInput = document.getElementById('radiosondeFreqMax');
const presets = {
global: [400.0, 406.0],
eu: [400.0, 403.0],
us: [400.0, 406.0],
au: [400.0, 403.0],
};
if (region === 'custom') {
customGroup.style.display = 'block';
} else {
customGroup.style.display = 'none';
if (presets[region]) {
minInput.value = presets[region][0];
maxInput.value = presets[region][1];
}
}
}
function startRadiosondeTracking() {
const gain = document.getElementById('radiosondeGainInput').value || '40';
const device = document.getElementById('deviceSelect')?.value || '0';
const freqMin = parseFloat(document.getElementById('radiosondeFreqMin').value) || 400.0;
const freqMax = parseFloat(document.getElementById('radiosondeFreqMax').value) || 406.0;
fetch('/radiosonde/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
device,
gain,
freq_min: freqMin,
freq_max: freqMax,
bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false,
})
})
.then(r => r.json())
.then(data => {
if (data.status === 'started' || data.status === 'already_running') {
document.getElementById('startRadiosondeBtn').style.display = 'none';
document.getElementById('stopRadiosondeBtn').style.display = 'block';
document.getElementById('radiosondeStatusText').textContent = 'Tracking';
document.getElementById('radiosondeStatusText').style.color = 'var(--accent-green)';
startRadiosondeSSE();
} else {
alert(data.message || 'Failed to start radiosonde tracking');
}
})
.catch(err => alert('Error: ' + err.message));
}
function stopRadiosondeTracking() {
fetch('/radiosonde/stop', { method: 'POST' })
.then(r => r.json())
.then(() => {
document.getElementById('startRadiosondeBtn').style.display = 'block';
document.getElementById('stopRadiosondeBtn').style.display = 'none';
document.getElementById('radiosondeStatusText').textContent = 'Standby';
document.getElementById('radiosondeStatusText').style.color = 'var(--accent-yellow)';
document.getElementById('radiosondeBalloonCount').textContent = '0';
document.getElementById('radiosondeLastUpdate').textContent = '\u2014';
if (radiosondeEventSource) {
radiosondeEventSource.close();
radiosondeEventSource = null;
}
radiosondeBalloons = {};
// Clear map markers
if (typeof radiosondeMap !== 'undefined' && radiosondeMap) {
radiosondeMarkers.forEach(m => radiosondeMap.removeLayer(m));
radiosondeMarkers.clear();
radiosondeTracks.forEach(t => radiosondeMap.removeLayer(t));
radiosondeTracks.clear();
}
});
}
function startRadiosondeSSE() {
if (radiosondeEventSource) radiosondeEventSource.close();
radiosondeEventSource = new EventSource('/radiosonde/stream');
radiosondeEventSource.onmessage = function(e) {
try {
const data = JSON.parse(e.data);
if (data.type === 'balloon') {
radiosondeBalloons[data.id] = data;
document.getElementById('radiosondeBalloonCount').textContent = Object.keys(radiosondeBalloons).length;
const now = new Date();
document.getElementById('radiosondeLastUpdate').textContent =
now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
updateRadiosondeMap(data);
updateRadiosondeCards();
}
} catch (err) {}
};
radiosondeEventSource.onerror = function() {
setTimeout(() => {
if (document.getElementById('stopRadiosondeBtn').style.display === 'block') {
startRadiosondeSSE();
}
}, 2000);
};
}
// Map management
let radiosondeMap = null;
let radiosondeMarkers = new Map();
let radiosondeTracks = new Map();
let radiosondeTrackPoints = new Map();
function initRadiosondeMap() {
if (radiosondeMap) return;
const container = document.getElementById('radiosondeMapContainer');
if (!container) return;
radiosondeMap = L.map('radiosondeMapContainer', {
center: [40, -95],
zoom: 4,
zoomControl: true,
});
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; OpenStreetMap &copy; CARTO',
maxZoom: 18,
}).addTo(radiosondeMap);
}
function updateRadiosondeMap(balloon) {
if (!radiosondeMap || !balloon.lat || !balloon.lon) return;
const id = balloon.id;
const latlng = [balloon.lat, balloon.lon];
// Altitude-based colour coding
const alt = balloon.alt || 0;
let colour;
if (alt < 5000) colour = '#00ff88';
else if (alt < 15000) colour = '#00ccff';
else if (alt < 25000) colour = '#ff9900';
else colour = '#ff3366';
// Update or create marker
if (radiosondeMarkers.has(id)) {
radiosondeMarkers.get(id).setLatLng(latlng);
} else {
const marker = L.circleMarker(latlng, {
radius: 7,
color: colour,
fillColor: colour,
fillOpacity: 0.8,
weight: 2,
}).addTo(radiosondeMap);
radiosondeMarkers.set(id, marker);
}
// Update marker colour based on altitude
radiosondeMarkers.get(id).setStyle({ color: colour, fillColor: colour });
// Build popup content
const altStr = alt ? `${Math.round(alt).toLocaleString()} m` : '--';
const tempStr = balloon.temp != null ? `${balloon.temp.toFixed(1)} °C` : '--';
const humStr = balloon.humidity != null ? `${balloon.humidity.toFixed(0)}%` : '--';
const velStr = balloon.vel_v != null ? `${balloon.vel_v.toFixed(1)} m/s` : '--';
radiosondeMarkers.get(id).bindPopup(
`<strong>${id}</strong><br>` +
`Type: ${balloon.sonde_type || '--'}<br>` +
`Alt: ${altStr}<br>` +
`Temp: ${tempStr} | Hum: ${humStr}<br>` +
`Vert: ${velStr}<br>` +
(balloon.freq ? `Freq: ${balloon.freq.toFixed(3)} MHz` : '')
);
// Track polyline
if (!radiosondeTrackPoints.has(id)) {
radiosondeTrackPoints.set(id, []);
}
radiosondeTrackPoints.get(id).push(latlng);
if (radiosondeTracks.has(id)) {
radiosondeTracks.get(id).setLatLngs(radiosondeTrackPoints.get(id));
} else {
const track = L.polyline(radiosondeTrackPoints.get(id), {
color: colour,
weight: 2,
opacity: 0.6,
dashArray: '4 4',
}).addTo(radiosondeMap);
radiosondeTracks.set(id, track);
}
// Auto-centre on first balloon
if (radiosondeMarkers.size === 1) {
radiosondeMap.setView(latlng, 8);
}
}
function updateRadiosondeCards() {
const container = document.getElementById('radiosondeCardContainer');
if (!container) return;
const sorted = Object.values(radiosondeBalloons).sort((a, b) => (b.alt || 0) - (a.alt || 0));
container.innerHTML = sorted.map(b => {
const alt = b.alt ? `${Math.round(b.alt).toLocaleString()} m` : '--';
const temp = b.temp != null ? `${b.temp.toFixed(1)}°C` : '--';
const hum = b.humidity != null ? `${b.humidity.toFixed(0)}%` : '--';
const press = b.pressure != null ? `${b.pressure.toFixed(1)} hPa` : '--';
const vel = b.vel_v != null ? `${b.vel_v > 0 ? '+' : ''}${b.vel_v.toFixed(1)} m/s` : '--';
const freq = b.freq ? `${b.freq.toFixed(3)} MHz` : '--';
return `
<div class="radiosonde-card" onclick="radiosondeMap && radiosondeMap.setView([${b.lat || 0}, ${b.lon || 0}], 10)">
<div class="radiosonde-card-header">
<span class="radiosonde-serial">${b.id}</span>
<span class="radiosonde-type">${b.sonde_type || '??'}</span>
</div>
<div class="radiosonde-stats">
<div class="radiosonde-stat">
<span class="radiosonde-stat-value">${alt}</span>
<span class="radiosonde-stat-label">ALT</span>
</div>
<div class="radiosonde-stat">
<span class="radiosonde-stat-value">${temp}</span>
<span class="radiosonde-stat-label">TEMP</span>
</div>
<div class="radiosonde-stat">
<span class="radiosonde-stat-value">${hum}</span>
<span class="radiosonde-stat-label">HUM</span>
</div>
<div class="radiosonde-stat">
<span class="radiosonde-stat-value">${press}</span>
<span class="radiosonde-stat-label">PRESS</span>
</div>
<div class="radiosonde-stat">
<span class="radiosonde-stat-value">${vel}</span>
<span class="radiosonde-stat-label">VERT</span>
</div>
<div class="radiosonde-stat">
<span class="radiosonde-stat-value">${freq}</span>
<span class="radiosonde-stat-label">FREQ</span>
</div>
</div>
</div>
`;
}).join('');
}
// Check initial status on load
fetch('/radiosonde/status')
.then(r => r.json())
.then(data => {
if (data.tracking_active) {
document.getElementById('startRadiosondeBtn').style.display = 'none';
document.getElementById('stopRadiosondeBtn').style.display = 'block';
document.getElementById('radiosondeStatusText').textContent = 'Tracking';
document.getElementById('radiosondeStatusText').style.color = 'var(--accent-green)';
document.getElementById('radiosondeBalloonCount').textContent = data.balloon_count || 0;
startRadiosondeSSE();
}
})
.catch(() => {});
</script>

View File

@@ -84,6 +84,7 @@
{{ mode_item('ais', 'Vessels', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg>', '/ais/dashboard') }}
{{ mode_item('aprs', 'APRS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>') }}
{{ mode_item('gps', 'GPS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }}
{{ mode_item('radiosonde', 'Radiosonde', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v6"/><circle cx="12" cy="12" r="4"/><path d="M12 16v6"/><path d="M4.93 4.93l4.24 4.24"/><path d="M14.83 14.83l4.24 4.24"/></svg>') }}
</div>
</div>

View File

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

View File

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