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 \
|
&& make install \
|
||||||
&& ldconfig \
|
&& ldconfig \
|
||||||
&& rm -rf /tmp/hackrf \
|
&& 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)
|
# Build rtlamr (utility meter decoder - requires Go)
|
||||||
&& cd /tmp \
|
&& cd /tmp \
|
||||||
&& curl -fsSL "https://go.dev/dl/go1.22.5.linux-$(dpkg --print-architecture).tar.gz" | tar -C /usr/local -xz \
|
&& 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 . .
|
COPY . .
|
||||||
|
|
||||||
# Create data directory for persistence
|
# 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 web interface port
|
||||||
EXPOSE 5050
|
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_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
subghz_lock = threading.Lock()
|
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
|
# CW/Morse code decoder
|
||||||
morse_process = None
|
morse_process = None
|
||||||
morse_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
morse_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
@@ -766,6 +771,7 @@ def health_check() -> Response:
|
|||||||
'wifi': wifi_active,
|
'wifi': wifi_active,
|
||||||
'bluetooth': bt_active,
|
'bluetooth': bt_active,
|
||||||
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
|
'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),
|
'morse': morse_process is not None and (morse_process.poll() is None if morse_process else False),
|
||||||
'subghz': _get_subghz_active(),
|
'subghz': _get_subghz_active(),
|
||||||
},
|
},
|
||||||
@@ -784,12 +790,13 @@ def health_check() -> Response:
|
|||||||
def kill_all() -> Response:
|
def kill_all() -> Response:
|
||||||
"""Kill all decoder, WiFi, and Bluetooth processes."""
|
"""Kill all decoder, WiFi, and Bluetooth processes."""
|
||||||
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
|
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
|
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 adsb as adsb_module
|
||||||
from routes import ais as ais_module
|
from routes import ais as ais_module
|
||||||
|
from routes import radiosonde as radiosonde_module
|
||||||
from utils.bluetooth import reset_bluetooth_scanner
|
from utils.bluetooth import reset_bluetooth_scanner
|
||||||
|
|
||||||
killed = []
|
killed = []
|
||||||
@@ -799,7 +806,8 @@ def kill_all() -> Response:
|
|||||||
'dump1090', 'acarsdec', 'dumpvdl2', 'direwolf', 'AIS-catcher',
|
'dump1090', 'acarsdec', 'dumpvdl2', 'direwolf', 'AIS-catcher',
|
||||||
'hcitool', 'bluetoothctl', 'satdump',
|
'hcitool', 'bluetoothctl', 'satdump',
|
||||||
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
|
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
|
||||||
'hackrf_transfer', 'hackrf_sweep'
|
'hackrf_transfer', 'hackrf_sweep',
|
||||||
|
'auto_rx'
|
||||||
]
|
]
|
||||||
|
|
||||||
for proc in processes_to_kill:
|
for proc in processes_to_kill:
|
||||||
@@ -829,6 +837,11 @@ def kill_all() -> Response:
|
|||||||
ais_process = None
|
ais_process = None
|
||||||
ais_module.ais_running = False
|
ais_module.ais_running = False
|
||||||
|
|
||||||
|
# Reset Radiosonde state
|
||||||
|
with radiosonde_lock:
|
||||||
|
radiosonde_process = None
|
||||||
|
radiosonde_module.radiosonde_running = False
|
||||||
|
|
||||||
# Reset ACARS state
|
# Reset ACARS state
|
||||||
with acars_lock:
|
with acars_lock:
|
||||||
acars_process = None
|
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_START_MHZ = _get_env_float('SUBGHZ_SWEEP_START', 300.0)
|
||||||
SUBGHZ_SWEEP_END_MHZ = _get_env_float('SUBGHZ_SWEEP_END', 928.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
|
# Update checking
|
||||||
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
|
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
|
||||||
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
|
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ def register_blueprints(app):
|
|||||||
from .morse import morse_bp
|
from .morse import morse_bp
|
||||||
from .offline import offline_bp
|
from .offline import offline_bp
|
||||||
from .pager import pager_bp
|
from .pager import pager_bp
|
||||||
|
from .radiosonde import radiosonde_bp
|
||||||
from .recordings import recordings_bp
|
from .recordings import recordings_bp
|
||||||
from .rtlamr import rtlamr_bp
|
from .rtlamr import rtlamr_bp
|
||||||
from .satellite import satellite_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(signalid_bp) # External signal ID enrichment
|
||||||
app.register_blueprint(wefax_bp) # WeFax HF weather fax decoder
|
app.register_blueprint(wefax_bp) # WeFax HF weather fax decoder
|
||||||
app.register_blueprint(morse_bp) # CW/Morse code 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
|
app.register_blueprint(system_bp) # System health monitoring
|
||||||
|
|
||||||
# Initialize TSCM state with queue and lock from app
|
# 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_optional "dumpvdl2" "VDL2 decoder" dumpvdl2
|
||||||
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
|
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
|
||||||
check_optional "satdump" "Weather satellite decoder (NOAA/Meteor)" satdump
|
check_optional "satdump" "Weather satellite decoder (NOAA/Meteor)" satdump
|
||||||
|
check_optional "auto_rx.py" "Radiosonde weather balloon decoder" auto_rx.py
|
||||||
echo
|
echo
|
||||||
info "GPS:"
|
info "GPS:"
|
||||||
check_required "gpsd" "GPS daemon" gpsd
|
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() {
|
install_macos_packages() {
|
||||||
need_sudo
|
need_sudo
|
||||||
|
|
||||||
@@ -825,7 +857,7 @@ install_macos_packages() {
|
|||||||
sudo -v || { fail "sudo authentication failed"; exit 1; }
|
sudo -v || { fail "sudo authentication failed"; exit 1; }
|
||||||
fi
|
fi
|
||||||
|
|
||||||
TOTAL_STEPS=21
|
TOTAL_STEPS=22
|
||||||
CURRENT_STEP=0
|
CURRENT_STEP=0
|
||||||
|
|
||||||
progress "Checking Homebrew"
|
progress "Checking Homebrew"
|
||||||
@@ -912,6 +944,19 @@ install_macos_packages() {
|
|||||||
ok "SatDump already installed"
|
ok "SatDump already installed"
|
||||||
fi
|
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"
|
progress "Installing aircrack-ng"
|
||||||
brew_install aircrack-ng
|
brew_install aircrack-ng
|
||||||
|
|
||||||
@@ -1303,7 +1348,7 @@ install_debian_packages() {
|
|||||||
export NEEDRESTART_MODE=a
|
export NEEDRESTART_MODE=a
|
||||||
fi
|
fi
|
||||||
|
|
||||||
TOTAL_STEPS=27
|
TOTAL_STEPS=28
|
||||||
CURRENT_STEP=0
|
CURRENT_STEP=0
|
||||||
|
|
||||||
progress "Updating APT package lists"
|
progress "Updating APT package lists"
|
||||||
@@ -1485,6 +1530,19 @@ install_debian_packages() {
|
|||||||
ok "SatDump already installed"
|
ok "SatDump already installed"
|
||||||
fi
|
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"
|
progress "Configuring udev rules"
|
||||||
setup_udev_rules_debian
|
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') }}",
|
spaceweather: "{{ url_for('static', filename='css/modes/space-weather.css') }}",
|
||||||
wefax: "{{ url_for('static', filename='css/modes/wefax.css') }}",
|
wefax: "{{ url_for('static', filename='css/modes/wefax.css') }}",
|
||||||
morse: "{{ url_for('static', filename='css/modes/morse.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') }}"
|
system: "{{ url_for('static', filename='css/modes/system.css') }}"
|
||||||
};
|
};
|
||||||
window.INTERCEPT_MODE_STYLE_LOADED = {};
|
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-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>
|
<span class="mode-name">GPS</span>
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -696,6 +701,8 @@
|
|||||||
|
|
||||||
{% include 'partials/modes/ais.html' %}
|
{% include 'partials/modes/ais.html' %}
|
||||||
|
|
||||||
|
{% include 'partials/modes/radiosonde.html' %}
|
||||||
|
|
||||||
{% include 'partials/modes/spy-stations.html' %}
|
{% include 'partials/modes/spy-stations.html' %}
|
||||||
|
|
||||||
{% include 'partials/modes/meshtastic.html' %}
|
{% include 'partials/modes/meshtastic.html' %}
|
||||||
@@ -3127,6 +3134,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- System Health Visuals -->
|
||||||
<div id="systemVisuals" class="sys-visuals-container" style="display: none;">
|
<div id="systemVisuals" class="sys-visuals-container" style="display: none;">
|
||||||
<div class="sys-dashboard">
|
<div class="sys-dashboard">
|
||||||
@@ -3387,6 +3400,7 @@
|
|||||||
subghz: { label: 'SubGHz', indicator: 'SUBGHZ', outputTitle: 'SubGHz Transceiver', group: 'signals' },
|
subghz: { label: 'SubGHz', indicator: 'SUBGHZ', outputTitle: 'SubGHz Transceiver', group: 'signals' },
|
||||||
aprs: { label: 'APRS', indicator: 'APRS', outputTitle: 'APRS Tracker', group: 'tracking' },
|
aprs: { label: 'APRS', indicator: 'APRS', outputTitle: 'APRS Tracker', group: 'tracking' },
|
||||||
gps: { label: 'GPS', indicator: 'GPS', outputTitle: 'GPS Receiver', 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' },
|
satellite: { label: 'Satellite', indicator: 'SATELLITE', outputTitle: 'Satellite Monitor', group: 'space' },
|
||||||
sstv: { label: 'ISS SSTV', indicator: 'ISS SSTV', outputTitle: 'ISS SSTV Decoder', 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' },
|
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('aprsMode')?.classList.toggle('active', mode === 'aprs');
|
||||||
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
|
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
|
||||||
document.getElementById('aisMode')?.classList.toggle('active', mode === 'ais');
|
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('spystationsMode')?.classList.toggle('active', mode === 'spystations');
|
||||||
document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic');
|
document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic');
|
||||||
document.getElementById('websdrMode')?.classList.toggle('active', mode === 'websdr');
|
document.getElementById('websdrMode')?.classList.toggle('active', mode === 'websdr');
|
||||||
@@ -4224,6 +4239,7 @@
|
|||||||
const wefaxVisuals = document.getElementById('wefaxVisuals');
|
const wefaxVisuals = document.getElementById('wefaxVisuals');
|
||||||
const spaceWeatherVisuals = document.getElementById('spaceWeatherVisuals');
|
const spaceWeatherVisuals = document.getElementById('spaceWeatherVisuals');
|
||||||
const waterfallVisuals = document.getElementById('waterfallVisuals');
|
const waterfallVisuals = document.getElementById('waterfallVisuals');
|
||||||
|
const radiosondeVisuals = document.getElementById('radiosondeVisuals');
|
||||||
const systemVisuals = document.getElementById('systemVisuals');
|
const systemVisuals = document.getElementById('systemVisuals');
|
||||||
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
|
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
|
||||||
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? '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 (wefaxVisuals) wefaxVisuals.style.display = mode === 'wefax' ? 'flex' : 'none';
|
||||||
if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? 'flex' : 'none';
|
if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? 'flex' : 'none';
|
||||||
if (waterfallVisuals) waterfallVisuals.style.display = mode === 'waterfall' ? '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';
|
if (systemVisuals) systemVisuals.style.display = mode === 'system' ? 'flex' : 'none';
|
||||||
|
|
||||||
// Prevent Leaflet heatmap redraws on hidden BT Locate map containers.
|
// Prevent Leaflet heatmap redraws on hidden BT Locate map containers.
|
||||||
@@ -4329,7 +4346,7 @@
|
|||||||
// Show RTL-SDR device section for modes that use it
|
// Show RTL-SDR device section for modes that use it
|
||||||
const rtlDeviceSection = document.getElementById('rtlDeviceSection');
|
const rtlDeviceSection = document.getElementById('rtlDeviceSection');
|
||||||
if (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)
|
// Save original sidebar position of SDR device section (once)
|
||||||
if (!rtlDeviceSection._origParent) {
|
if (!rtlDeviceSection._origParent) {
|
||||||
rtlDeviceSection._origParent = rtlDeviceSection.parentNode;
|
rtlDeviceSection._origParent = rtlDeviceSection.parentNode;
|
||||||
@@ -4434,6 +4451,11 @@
|
|||||||
if (typeof Waterfall !== 'undefined') Waterfall.init();
|
if (typeof Waterfall !== 'undefined') Waterfall.init();
|
||||||
} else if (mode === 'morse') {
|
} else if (mode === 'morse') {
|
||||||
MorseMode.init();
|
MorseMode.init();
|
||||||
|
} else if (mode === 'radiosonde') {
|
||||||
|
initRadiosondeMap();
|
||||||
|
setTimeout(() => {
|
||||||
|
if (radiosondeMap) radiosondeMap.invalidateSize();
|
||||||
|
}, 100);
|
||||||
} else if (mode === 'system') {
|
} else if (mode === 'system') {
|
||||||
SystemHealth.init();
|
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('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('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('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>
|
||||||
</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
|
# DEAUTH ATTACK DETECTION
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
logger = logging.getLogger('intercept.dependencies')
|
logger = logging.getLogger('intercept.dependencies')
|
||||||
|
|
||||||
@@ -18,32 +18,32 @@ def check_tool(name: str) -> bool:
|
|||||||
return get_tool_path(name) is not None
|
return get_tool_path(name) is not None
|
||||||
|
|
||||||
|
|
||||||
def get_tool_path(name: str) -> str | None:
|
def get_tool_path(name: str) -> str | None:
|
||||||
"""Get the full path to a tool, checking standard PATH and extra locations."""
|
"""Get the full path to a tool, checking standard PATH and extra locations."""
|
||||||
# Optional explicit override, e.g. INTERCEPT_RTL_FM_PATH=/opt/homebrew/bin/rtl_fm
|
# Optional explicit override, e.g. INTERCEPT_RTL_FM_PATH=/opt/homebrew/bin/rtl_fm
|
||||||
env_key = f"INTERCEPT_{name.upper().replace('-', '_')}_PATH"
|
env_key = f"INTERCEPT_{name.upper().replace('-', '_')}_PATH"
|
||||||
env_path = os.environ.get(env_key)
|
env_path = os.environ.get(env_key)
|
||||||
if env_path and os.path.isfile(env_path) and os.access(env_path, os.X_OK):
|
if env_path and os.path.isfile(env_path) and os.access(env_path, os.X_OK):
|
||||||
return env_path
|
return env_path
|
||||||
|
|
||||||
# Prefer native Homebrew binaries on Apple Silicon to avoid mixing Rosetta
|
# Prefer native Homebrew binaries on Apple Silicon to avoid mixing Rosetta
|
||||||
# /usr/local tools with arm64 Python/runtime.
|
# /usr/local tools with arm64 Python/runtime.
|
||||||
if platform.system() == 'Darwin':
|
if platform.system() == 'Darwin':
|
||||||
machine = platform.machine().lower()
|
machine = platform.machine().lower()
|
||||||
preferred_paths: list[str] = []
|
preferred_paths: list[str] = []
|
||||||
if machine in {'arm64', 'aarch64'}:
|
if machine in {'arm64', 'aarch64'}:
|
||||||
preferred_paths.append('/opt/homebrew/bin')
|
preferred_paths.append('/opt/homebrew/bin')
|
||||||
preferred_paths.append('/usr/local/bin')
|
preferred_paths.append('/usr/local/bin')
|
||||||
|
|
||||||
for base in preferred_paths:
|
for base in preferred_paths:
|
||||||
full_path = os.path.join(base, name)
|
full_path = os.path.join(base, name)
|
||||||
if os.path.isfile(full_path) and os.access(full_path, os.X_OK):
|
if os.path.isfile(full_path) and os.access(full_path, os.X_OK):
|
||||||
return full_path
|
return full_path
|
||||||
|
|
||||||
# First check standard PATH
|
# First check standard PATH
|
||||||
path = shutil.which(name)
|
path = shutil.which(name)
|
||||||
if path:
|
if path:
|
||||||
return path
|
return path
|
||||||
|
|
||||||
# Check additional paths (e.g., /usr/sbin for aircrack-ng on Debian)
|
# Check additional paths (e.g., /usr/sbin for aircrack-ng on Debian)
|
||||||
for extra_path in EXTRA_TOOL_PATHS:
|
for extra_path in EXTRA_TOOL_PATHS:
|
||||||
@@ -447,6 +447,20 @@ TOOL_DEPENDENCIES = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
'radiosonde': {
|
||||||
|
'name': 'Radiosonde Tracking',
|
||||||
|
'tools': {
|
||||||
|
'auto_rx.py': {
|
||||||
|
'required': True,
|
||||||
|
'description': 'Radiosonde weather balloon decoder',
|
||||||
|
'install': {
|
||||||
|
'apt': 'Run ./setup.sh (clones from GitHub)',
|
||||||
|
'brew': 'Run ./setup.sh (clones from GitHub)',
|
||||||
|
'manual': 'https://github.com/projecthorus/radiosonde_auto_rx'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
'tscm': {
|
'tscm': {
|
||||||
'name': 'TSCM Counter-Surveillance',
|
'name': 'TSCM Counter-Surveillance',
|
||||||
'tools': {
|
'tools': {
|
||||||
|
|||||||
Reference in New Issue
Block a user