mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
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:
12
Dockerfile
12
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
|
||||
|
||||
19
app.py
19
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
547
routes/radiosonde.py
Normal 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),
|
||||
})
|
||||
62
setup.sh
62
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
|
||||
|
||||
|
||||
152
static/css/modes/radiosonde.css
Normal file
152
static/css/modes/radiosonde.css
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
376
templates/partials/modes/radiosonde.html
Normal file
376
templates/partials/modes/radiosonde.html
Normal 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–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–406 MHz)</option>
|
||||
<option value="eu">Europe (400–403 MHz)</option>
|
||||
<option value="us">US (400–406 MHz)</option>
|
||||
<option value="au">Australia (400–403 MHz)</option>
|
||||
<option value="custom">Custom…</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);">–</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">—</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 — 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 — mount near antenna for best results</li>
|
||||
<li><strong style="color: var(--text-primary);">Launches:</strong> Typically 2×/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–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: '© OpenStreetMap © 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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': {
|
||||
|
||||
Reference in New Issue
Block a user