Merge upstream/main and resolve acars, vdl2, dashboard conflicts

Resolved conflicts:
- routes/acars.py: keep /messages and /clear endpoints for history reload
- routes/vdl2.py: keep /messages and /clear endpoints for history reload
- templates/adsb_dashboard.html: keep removal of hardcoded device-1
  defaults for ACARS/VDL2 selectors (users pick their own device)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
mitchross
2026-02-27 14:47:57 -05:00
41 changed files with 5065 additions and 1951 deletions

View File

@@ -200,6 +200,17 @@ 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 \
&& bash build.sh \
&& mkdir -p /opt/radiosonde_auto_rx/auto_rx \
&& cp -r . /opt/radiosonde_auto_rx/auto_rx/ \
&& chmod +x /opt/radiosonde_auto_rx/auto_rx/auto_rx.py \
&& cd /tmp \
&& rm -rf /tmp/radiosonde_auto_rx \
# Build rtlamr (utility meter decoder - requires Go) # 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 +257,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

65
app.py
View File

@@ -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)
@@ -257,12 +262,12 @@ cleanup_manager.register(deauth_alerts)
# SDR DEVICE REGISTRY # SDR DEVICE REGISTRY
# ============================================ # ============================================
# Tracks which mode is using which SDR device to prevent conflicts # Tracks which mode is using which SDR device to prevent conflicts
# Key: device_index (int), Value: mode_name (str) # Key: "sdr_type:device_index" (str), Value: mode_name (str)
sdr_device_registry: dict[int, str] = {} sdr_device_registry: dict[str, str] = {}
sdr_device_registry_lock = threading.Lock() sdr_device_registry_lock = threading.Lock()
def claim_sdr_device(device_index: int, mode_name: str) -> str | None: def claim_sdr_device(device_index: int, mode_name: str, sdr_type: str = 'rtlsdr') -> str | None:
"""Claim an SDR device for a mode. """Claim an SDR device for a mode.
Checks the in-app registry first, then probes the USB device to Checks the in-app registry first, then probes the USB device to
@@ -272,43 +277,48 @@ def claim_sdr_device(device_index: int, mode_name: str) -> str | None:
Args: Args:
device_index: The SDR device index to claim device_index: The SDR device index to claim
mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr') mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr')
sdr_type: SDR type string (e.g., 'rtlsdr', 'hackrf', 'limesdr')
Returns: Returns:
Error message if device is in use, None if successfully claimed Error message if device is in use, None if successfully claimed
""" """
key = f"{sdr_type}:{device_index}"
with sdr_device_registry_lock: with sdr_device_registry_lock:
if device_index in sdr_device_registry: if key in sdr_device_registry:
in_use_by = sdr_device_registry[device_index] in_use_by = sdr_device_registry[key]
return f'SDR device {device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.' return f'SDR device {sdr_type}:{device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.'
# Probe the USB device to catch external processes holding the handle # Probe the USB device to catch external processes holding the handle
try: if sdr_type == 'rtlsdr':
from utils.sdr.detection import probe_rtlsdr_device try:
usb_error = probe_rtlsdr_device(device_index) from utils.sdr.detection import probe_rtlsdr_device
if usb_error: usb_error = probe_rtlsdr_device(device_index)
return usb_error if usb_error:
except Exception: return usb_error
pass # If probe fails, let the caller proceed normally except Exception:
pass # If probe fails, let the caller proceed normally
sdr_device_registry[device_index] = mode_name sdr_device_registry[key] = mode_name
return None return None
def release_sdr_device(device_index: int) -> None: def release_sdr_device(device_index: int, sdr_type: str = 'rtlsdr') -> None:
"""Release an SDR device from the registry. """Release an SDR device from the registry.
Args: Args:
device_index: The SDR device index to release device_index: The SDR device index to release
sdr_type: SDR type string (e.g., 'rtlsdr', 'hackrf', 'limesdr')
""" """
key = f"{sdr_type}:{device_index}"
with sdr_device_registry_lock: with sdr_device_registry_lock:
sdr_device_registry.pop(device_index, None) sdr_device_registry.pop(key, None)
def get_sdr_device_status() -> dict[int, str]: def get_sdr_device_status() -> dict[str, str]:
"""Get current SDR device allocations. """Get current SDR device allocations.
Returns: Returns:
Dictionary mapping device indices to mode names Dictionary mapping 'sdr_type:device_index' keys to mode names
""" """
with sdr_device_registry_lock: with sdr_device_registry_lock:
return dict(sdr_device_registry) return dict(sdr_device_registry)
@@ -429,8 +439,9 @@ def get_devices_status() -> Response:
result = [] result = []
for device in devices: for device in devices:
d = device.to_dict() d = device.to_dict()
d['in_use'] = device.index in registry key = f"{device.sdr_type.value}:{device.index}"
d['used_by'] = registry.get(device.index) d['in_use'] = key in registry
d['used_by'] = registry.get(key)
result.append(d) result.append(d)
return jsonify(result) return jsonify(result)
@@ -760,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(),
}, },
@@ -778,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 = []
@@ -793,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:
@@ -823,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

View File

@@ -355,6 +355,12 @@ SUBGHZ_MAX_TX_DURATION = _get_env_int('SUBGHZ_MAX_TX_DURATION', 10)
SUBGHZ_SWEEP_START_MHZ = _get_env_float('SUBGHZ_SWEEP_START', 300.0) SUBGHZ_SWEEP_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)

View File

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

View File

@@ -48,6 +48,7 @@ acars_last_message_time = None
# Track which device is being used # Track which device is being used
acars_active_device: int | None = None acars_active_device: int | None = None
acars_active_sdr_type: str | None = None
def find_acarsdec(): def find_acarsdec():
@@ -164,7 +165,7 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
logger.error(f"ACARS stream error: {e}") logger.error(f"ACARS stream error: {e}")
app_module.acars_queue.put({'type': 'error', 'message': str(e)}) app_module.acars_queue.put({'type': 'error', 'message': str(e)})
finally: finally:
global acars_active_device global acars_active_device, acars_active_sdr_type
# Ensure process is terminated # Ensure process is terminated
try: try:
process.terminate() process.terminate()
@@ -180,8 +181,9 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
app_module.acars_process = None app_module.acars_process = None
# Release SDR device # Release SDR device
if acars_active_device is not None: if acars_active_device is not None:
app_module.release_sdr_device(acars_active_device) app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
acars_active_device = None acars_active_device = None
acars_active_sdr_type = None
@acars_bp.route('/tools') @acars_bp.route('/tools')
@@ -213,7 +215,7 @@ def acars_status() -> Response:
@acars_bp.route('/start', methods=['POST']) @acars_bp.route('/start', methods=['POST'])
def start_acars() -> Response: def start_acars() -> Response:
"""Start ACARS decoder.""" """Start ACARS decoder."""
global acars_message_count, acars_last_message_time, acars_active_device global acars_message_count, acars_last_message_time, acars_active_device, acars_active_sdr_type
with app_module.acars_lock: with app_module.acars_lock:
if app_module.acars_process and app_module.acars_process.poll() is None: if app_module.acars_process and app_module.acars_process.poll() is None:
@@ -240,9 +242,12 @@ def start_acars() -> Response:
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return jsonify({'status': 'error', 'message': str(e)}), 400
# Resolve SDR type for device selection
sdr_type_str = data.get('sdr_type', 'rtlsdr')
# Check if device is available # Check if device is available
device_int = int(device) device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'acars') error = app_module.claim_sdr_device(device_int, 'acars', sdr_type_str)
if error: if error:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -251,6 +256,7 @@ def start_acars() -> Response:
}), 409 }), 409
acars_active_device = device_int acars_active_device = device_int
acars_active_sdr_type = sdr_type_str
# Get frequencies - use provided or defaults # Get frequencies - use provided or defaults
frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES) frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES)
@@ -268,8 +274,6 @@ def start_acars() -> Response:
acars_message_count = 0 acars_message_count = 0
acars_last_message_time = None acars_last_message_time = None
# Resolve SDR type for device selection
sdr_type_str = data.get('sdr_type', 'rtlsdr')
try: try:
sdr_type = SDRType(sdr_type_str) sdr_type = SDRType(sdr_type_str)
except ValueError: except ValueError:
@@ -356,8 +360,9 @@ def start_acars() -> Response:
if process.poll() is not None: if process.poll() is not None:
# Process died - release device # Process died - release device
if acars_active_device is not None: if acars_active_device is not None:
app_module.release_sdr_device(acars_active_device) app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
acars_active_device = None acars_active_device = None
acars_active_sdr_type = None
stderr = '' stderr = ''
if process.stderr: if process.stderr:
stderr = process.stderr.read().decode('utf-8', errors='replace') stderr = process.stderr.read().decode('utf-8', errors='replace')
@@ -388,8 +393,9 @@ def start_acars() -> Response:
except Exception as e: except Exception as e:
# Release device on failure # Release device on failure
if acars_active_device is not None: if acars_active_device is not None:
app_module.release_sdr_device(acars_active_device) app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
acars_active_device = None acars_active_device = None
acars_active_sdr_type = None
logger.error(f"Failed to start ACARS decoder: {e}") logger.error(f"Failed to start ACARS decoder: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500 return jsonify({'status': 'error', 'message': str(e)}), 500
@@ -397,7 +403,7 @@ def start_acars() -> Response:
@acars_bp.route('/stop', methods=['POST']) @acars_bp.route('/stop', methods=['POST'])
def stop_acars() -> Response: def stop_acars() -> Response:
"""Stop ACARS decoder.""" """Stop ACARS decoder."""
global acars_active_device global acars_active_device, acars_active_sdr_type
with app_module.acars_lock: with app_module.acars_lock:
if not app_module.acars_process: if not app_module.acars_process:
@@ -418,8 +424,9 @@ def stop_acars() -> Response:
# Release device from registry # Release device from registry
if acars_active_device is not None: if acars_active_device is not None:
app_module.release_sdr_device(acars_active_device) app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
acars_active_device = None acars_active_device = None
acars_active_sdr_type = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
@@ -445,6 +452,7 @@ def stream_acars() -> Response:
return response return response
@acars_bp.route('/messages') @acars_bp.route('/messages')
def get_acars_messages() -> Response: def get_acars_messages() -> Response:
"""Get recent ACARS messages from correlator (for history reload).""" """Get recent ACARS messages from correlator (for history reload)."""

View File

@@ -72,6 +72,7 @@ adsb_last_message_time = None
adsb_bytes_received = 0 adsb_bytes_received = 0
adsb_lines_received = 0 adsb_lines_received = 0
adsb_active_device = None # Track which device index is being used adsb_active_device = None # Track which device index is being used
adsb_active_sdr_type: str | None = None
_sbs_error_logged = False # Suppress repeated connection error logs _sbs_error_logged = False # Suppress repeated connection error logs
# Track ICAOs already looked up in aircraft database (avoid repeated lookups) # Track ICAOs already looked up in aircraft database (avoid repeated lookups)
@@ -674,7 +675,7 @@ def adsb_session():
@adsb_bp.route('/start', methods=['POST']) @adsb_bp.route('/start', methods=['POST'])
def start_adsb(): def start_adsb():
"""Start ADS-B tracking.""" """Start ADS-B tracking."""
global adsb_using_service, adsb_active_device global adsb_using_service, adsb_active_device, adsb_active_sdr_type
with app_module.adsb_lock: with app_module.adsb_lock:
if adsb_using_service: if adsb_using_service:
@@ -757,6 +758,7 @@ def start_adsb():
sdr_type = SDRType(sdr_type_str) sdr_type = SDRType(sdr_type_str)
except ValueError: except ValueError:
sdr_type = SDRType.RTL_SDR sdr_type = SDRType.RTL_SDR
sdr_type_str = sdr_type.value
# For RTL-SDR, use dump1090. For other hardware, need readsb with SoapySDR # For RTL-SDR, use dump1090. For other hardware, need readsb with SoapySDR
if sdr_type == SDRType.RTL_SDR: if sdr_type == SDRType.RTL_SDR:
@@ -787,7 +789,7 @@ def start_adsb():
# Check if device is available before starting local dump1090 # Check if device is available before starting local dump1090
device_int = int(device) device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'adsb') error = app_module.claim_sdr_device(device_int, 'adsb', sdr_type_str)
if error: if error:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -795,6 +797,10 @@ def start_adsb():
'message': error 'message': error
}), 409 }), 409
# Track claimed device immediately so stop_adsb() can always release it
adsb_active_device = device
adsb_active_sdr_type = sdr_type_str
# Create device object and build command via abstraction layer # Create device object and build command via abstraction layer
sdr_device = SDRFactory.create_default_device(sdr_type, index=device) sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
builder = SDRFactory.get_builder(sdr_type) builder = SDRFactory.get_builder(sdr_type)
@@ -821,11 +827,24 @@ def start_adsb():
) )
write_dump1090_pid(app_module.adsb_process.pid) write_dump1090_pid(app_module.adsb_process.pid)
time.sleep(DUMP1090_START_WAIT) # Poll for dump1090 readiness instead of blind sleep
dump1090_ready = False
poll_interval = 0.1
elapsed = 0.0
while elapsed < DUMP1090_START_WAIT:
if app_module.adsb_process.poll() is not None:
break # Process exited early — handle below
if check_dump1090_service():
dump1090_ready = True
break
time.sleep(poll_interval)
elapsed += poll_interval
if app_module.adsb_process.poll() is not None: if app_module.adsb_process.poll() is not None:
# Process exited - release device and get error message # Process exited - release device and get error message
app_module.release_sdr_device(device_int) app_module.release_sdr_device(device_int, sdr_type_str)
adsb_active_device = None
adsb_active_sdr_type = None
stderr_output = '' stderr_output = ''
if app_module.adsb_process.stderr: if app_module.adsb_process.stderr:
try: try:
@@ -871,7 +890,6 @@ def start_adsb():
}) })
adsb_using_service = True adsb_using_service = True
adsb_active_device = device # Track which device is being used
thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True) thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True)
thread.start() thread.start()
@@ -891,14 +909,16 @@ def start_adsb():
}) })
except Exception as e: except Exception as e:
# Release device on failure # Release device on failure
app_module.release_sdr_device(device_int) app_module.release_sdr_device(device_int, sdr_type_str)
adsb_active_device = None
adsb_active_sdr_type = None
return jsonify({'status': 'error', 'message': str(e)}) return jsonify({'status': 'error', 'message': str(e)})
@adsb_bp.route('/stop', methods=['POST']) @adsb_bp.route('/stop', methods=['POST'])
def stop_adsb(): def stop_adsb():
"""Stop ADS-B tracking.""" """Stop ADS-B tracking."""
global adsb_using_service, adsb_active_device global adsb_using_service, adsb_active_device, adsb_active_sdr_type
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
stop_source = data.get('source') stop_source = data.get('source')
stopped_by = request.remote_addr stopped_by = request.remote_addr
@@ -923,10 +943,11 @@ def stop_adsb():
# Release device from registry # Release device from registry
if adsb_active_device is not None: if adsb_active_device is not None:
app_module.release_sdr_device(adsb_active_device) app_module.release_sdr_device(adsb_active_device, adsb_active_sdr_type or 'rtlsdr')
adsb_using_service = False adsb_using_service = False
adsb_active_device = None adsb_active_device = None
adsb_active_sdr_type = None
app_module.adsb_aircraft.clear() app_module.adsb_aircraft.clear()
_looked_up_icaos.clear() _looked_up_icaos.clear()

View File

@@ -44,6 +44,7 @@ ais_connected = False
ais_messages_received = 0 ais_messages_received = 0
ais_last_message_time = None ais_last_message_time = None
ais_active_device = None ais_active_device = None
ais_active_sdr_type: str | None = None
_ais_error_logged = True _ais_error_logged = True
# Common installation paths for AIS-catcher # Common installation paths for AIS-catcher
@@ -350,7 +351,7 @@ def ais_status():
@ais_bp.route('/start', methods=['POST']) @ais_bp.route('/start', methods=['POST'])
def start_ais(): def start_ais():
"""Start AIS tracking.""" """Start AIS tracking."""
global ais_running, ais_active_device global ais_running, ais_active_device, ais_active_sdr_type
with app_module.ais_lock: with app_module.ais_lock:
if ais_running: if ais_running:
@@ -397,7 +398,7 @@ def start_ais():
# Check if device is available # Check if device is available
device_int = int(device) device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'ais') error = app_module.claim_sdr_device(device_int, 'ais', sdr_type_str)
if error: if error:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -436,7 +437,7 @@ def start_ais():
if app_module.ais_process.poll() is not None: if app_module.ais_process.poll() is not None:
# Release device on failure # Release device on failure
app_module.release_sdr_device(device_int) app_module.release_sdr_device(device_int, sdr_type_str)
stderr_output = '' stderr_output = ''
if app_module.ais_process.stderr: if app_module.ais_process.stderr:
try: try:
@@ -450,6 +451,7 @@ def start_ais():
ais_running = True ais_running = True
ais_active_device = device ais_active_device = device
ais_active_sdr_type = sdr_type_str
# Start TCP parser thread # Start TCP parser thread
thread = threading.Thread(target=parse_ais_stream, args=(tcp_port,), daemon=True) thread = threading.Thread(target=parse_ais_stream, args=(tcp_port,), daemon=True)
@@ -463,7 +465,7 @@ def start_ais():
}) })
except Exception as e: except Exception as e:
# Release device on failure # Release device on failure
app_module.release_sdr_device(device_int) app_module.release_sdr_device(device_int, sdr_type_str)
logger.error(f"Failed to start AIS-catcher: {e}") logger.error(f"Failed to start AIS-catcher: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500 return jsonify({'status': 'error', 'message': str(e)}), 500
@@ -471,7 +473,7 @@ def start_ais():
@ais_bp.route('/stop', methods=['POST']) @ais_bp.route('/stop', methods=['POST'])
def stop_ais(): def stop_ais():
"""Stop AIS tracking.""" """Stop AIS tracking."""
global ais_running, ais_active_device global ais_running, ais_active_device, ais_active_sdr_type
with app_module.ais_lock: with app_module.ais_lock:
if app_module.ais_process: if app_module.ais_process:
@@ -490,10 +492,11 @@ def stop_ais():
# Release device from registry # Release device from registry
if ais_active_device is not None: if ais_active_device is not None:
app_module.release_sdr_device(ais_active_device) app_module.release_sdr_device(ais_active_device, ais_active_sdr_type or 'rtlsdr')
ais_running = False ais_running = False
ais_active_device = None ais_active_device = None
ais_active_sdr_type = None
app_module.ais_vessels.clear() app_module.ais_vessels.clear()
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})

View File

@@ -5,8 +5,10 @@ from __future__ import annotations
import csv import csv
import json import json
import os import os
import pty
import queue import queue
import re import re
import select
import shutil import shutil
import subprocess import subprocess
import tempfile import tempfile
@@ -35,6 +37,7 @@ aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
# Track which SDR device is being used # Track which SDR device is being used
aprs_active_device: int | None = None aprs_active_device: int | None = None
aprs_active_sdr_type: str | None = None
# APRS frequencies by region (MHz) # APRS frequencies by region (MHz)
APRS_FREQUENCIES = { APRS_FREQUENCIES = {
@@ -103,6 +106,9 @@ ADEVICE stdin null
CHANNEL 0 CHANNEL 0
MYCALL N0CALL MYCALL N0CALL
MODEM 1200 MODEM 1200
FIX_BITS 1
AGWPORT 0
KISSPORT 0
""" """
with open(DIREWOLF_CONFIG_PATH, 'w') as f: with open(DIREWOLF_CONFIG_PATH, 'w') as f:
f.write(config) f.write(config)
@@ -1437,19 +1443,19 @@ def should_send_meter_update(level: int) -> bool:
return False return False
def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subprocess.Popen) -> None: def stream_aprs_output(master_fd: int, rtl_process: subprocess.Popen, decoder_process: subprocess.Popen) -> None:
"""Stream decoded APRS packets and audio level meter to queue. """Stream decoded APRS packets and audio level meter to queue.
This function reads from the decoder's stdout (text mode, line-buffered). Reads from a PTY master fd to get line-buffered output from the decoder,
The decoder's stderr is merged into stdout (STDOUT) to avoid deadlocks. avoiding the 15-minute pipe buffering delay. Uses select() + os.read()
rtl_fm's stderr is captured via PIPE with a monitor thread. to poll the PTY (same pattern as pager.py).
Outputs two types of messages to the queue: Outputs two types of messages to the queue:
- type='aprs': Decoded APRS packets - type='aprs': Decoded APRS packets
- type='meter': Audio level meter readings (rate-limited) - type='meter': Audio level meter readings (rate-limited)
""" """
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
global _last_meter_time, _last_meter_level, aprs_active_device global _last_meter_time, _last_meter_level, aprs_active_device, aprs_active_sdr_type
# Capture the device claimed by THIS session so the finally block only # Capture the device claimed by THIS session so the finally block only
# releases our own device, not one claimed by a subsequent start. # releases our own device, not one claimed by a subsequent start.
@@ -1462,93 +1468,114 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
try: try:
app_module.aprs_queue.put({'type': 'status', 'status': 'started'}) app_module.aprs_queue.put({'type': 'status', 'status': 'started'})
# Read line-by-line in binary mode. Empty bytes b'' signals EOF. # Read from PTY using select() for non-blocking reads.
# Decode with errors='replace' so corrupted radio bytes (e.g. 0xf7) # PTY forces the decoder to line-buffer, so output arrives immediately
# never crash the stream. # instead of waiting for a full 4-8KB pipe buffer to fill.
for raw in iter(decoder_process.stdout.readline, b''): buffer = ""
line = raw.decode('utf-8', errors='replace').strip() while True:
if not line: try:
continue ready, _, _ = select.select([master_fd], [], [], 1.0)
except Exception:
break
# Check for audio level line first (for signal meter) if ready:
audio_level = parse_audio_level(line) try:
if audio_level is not None: data = os.read(master_fd, 1024)
if should_send_meter_update(audio_level): if not data:
meter_msg = { break
'type': 'meter', buffer += data.decode('utf-8', errors='replace')
'level': audio_level, except OSError:
'ts': datetime.utcnow().isoformat() + 'Z' break
}
app_module.aprs_queue.put(meter_msg)
continue # Audio level lines are not packets
# Normalize decoder prefixes (multimon/direwolf) before parsing. while '\n' in buffer:
line = normalize_aprs_output_line(line) line, buffer = buffer.split('\n', 1)
line = line.strip()
if not line:
continue
# Skip non-packet lines (APRS format: CALL>PATH:DATA) # Check for audio level line first (for signal meter)
if '>' not in line or ':' not in line: audio_level = parse_audio_level(line)
continue if audio_level is not None:
if should_send_meter_update(audio_level):
meter_msg = {
'type': 'meter',
'level': audio_level,
'ts': datetime.utcnow().isoformat() + 'Z'
}
app_module.aprs_queue.put(meter_msg)
continue # Audio level lines are not packets
packet = parse_aprs_packet(line) # Normalize decoder prefixes (multimon/direwolf) before parsing.
if packet: line = normalize_aprs_output_line(line)
aprs_packet_count += 1
aprs_last_packet_time = time.time()
# Track unique stations # Skip non-packet lines (APRS format: CALL>PATH:DATA)
callsign = packet.get('callsign') if '>' not in line or ':' not in line:
if callsign and callsign not in aprs_stations: continue
aprs_station_count += 1
# Update station data, preserving last known coordinates when packet = parse_aprs_packet(line)
# packets do not contain position fields. if packet:
if callsign: aprs_packet_count += 1
existing = aprs_stations.get(callsign, {}) aprs_last_packet_time = time.time()
packet_lat = packet.get('lat')
packet_lon = packet.get('lon')
aprs_stations[callsign] = {
'callsign': callsign,
'lat': packet_lat if packet_lat is not None else existing.get('lat'),
'lon': packet_lon if packet_lon is not None else existing.get('lon'),
'symbol': packet.get('symbol') or existing.get('symbol'),
'last_seen': packet.get('timestamp'),
'packet_type': packet.get('packet_type'),
}
# Geofence check
_aprs_lat = packet_lat
_aprs_lon = packet_lon
if _aprs_lat is not None and _aprs_lon is not None:
try:
from utils.geofence import get_geofence_manager
for _gf_evt in get_geofence_manager().check_position(
callsign, 'aprs_station', _aprs_lat, _aprs_lon,
{'callsign': callsign}
):
process_event('aprs', _gf_evt, 'geofence')
except Exception:
pass
# Evict oldest stations when limit is exceeded
if len(aprs_stations) > APRS_MAX_STATIONS:
oldest = min(
aprs_stations,
key=lambda k: aprs_stations[k].get('last_seen', ''),
)
del aprs_stations[oldest]
app_module.aprs_queue.put(packet) # Track unique stations
callsign = packet.get('callsign')
if callsign and callsign not in aprs_stations:
aprs_station_count += 1
# Log if enabled # Update station data, preserving last known coordinates when
if app_module.logging_enabled: # packets do not contain position fields.
try: if callsign:
with open(app_module.log_file_path, 'a') as f: existing = aprs_stations.get(callsign, {})
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S') packet_lat = packet.get('lat')
f.write(f"{ts} | APRS | {json.dumps(packet)}\n") packet_lon = packet.get('lon')
except Exception: aprs_stations[callsign] = {
pass 'callsign': callsign,
'lat': packet_lat if packet_lat is not None else existing.get('lat'),
'lon': packet_lon if packet_lon is not None else existing.get('lon'),
'symbol': packet.get('symbol') or existing.get('symbol'),
'last_seen': packet.get('timestamp'),
'packet_type': packet.get('packet_type'),
}
# Geofence check
_aprs_lat = packet_lat
_aprs_lon = packet_lon
if _aprs_lat is not None and _aprs_lon is not None:
try:
from utils.geofence import get_geofence_manager
for _gf_evt in get_geofence_manager().check_position(
callsign, 'aprs_station', _aprs_lat, _aprs_lon,
{'callsign': callsign}
):
process_event('aprs', _gf_evt, 'geofence')
except Exception:
pass
# Evict oldest stations when limit is exceeded
if len(aprs_stations) > APRS_MAX_STATIONS:
oldest = min(
aprs_stations,
key=lambda k: aprs_stations[k].get('last_seen', ''),
)
del aprs_stations[oldest]
app_module.aprs_queue.put(packet)
# Log if enabled
if app_module.logging_enabled:
try:
with open(app_module.log_file_path, 'a') as f:
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
f.write(f"{ts} | APRS | {json.dumps(packet)}\n")
except Exception:
pass
except Exception as e: except Exception as e:
logger.error(f"APRS stream error: {e}") logger.error(f"APRS stream error: {e}")
app_module.aprs_queue.put({'type': 'error', 'message': str(e)}) app_module.aprs_queue.put({'type': 'error', 'message': str(e)})
finally: finally:
try:
os.close(master_fd)
except OSError:
pass
app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'}) app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'})
# Cleanup processes # Cleanup processes
for proc in [rtl_process, decoder_process]: for proc in [rtl_process, decoder_process]:
@@ -1562,8 +1589,9 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
pass pass
# Release SDR device — only if it's still ours (not reclaimed by a new start) # Release SDR device — only if it's still ours (not reclaimed by a new start)
if my_device is not None and aprs_active_device == my_device: if my_device is not None and aprs_active_device == my_device:
app_module.release_sdr_device(my_device) app_module.release_sdr_device(my_device, aprs_active_sdr_type or 'rtlsdr')
aprs_active_device = None aprs_active_device = None
aprs_active_sdr_type = None
@aprs_bp.route('/tools') @aprs_bp.route('/tools')
@@ -1632,7 +1660,7 @@ def aprs_data() -> Response:
def start_aprs() -> Response: def start_aprs() -> Response:
"""Start APRS decoder.""" """Start APRS decoder."""
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
global aprs_active_device global aprs_active_device, aprs_active_sdr_type
with app_module.aprs_lock: with app_module.aprs_lock:
if app_module.aprs_process and app_module.aprs_process.poll() is None: if app_module.aprs_process and app_module.aprs_process.poll() is None:
@@ -1681,7 +1709,7 @@ def start_aprs() -> Response:
}), 400 }), 400
# Reserve SDR device to prevent conflicts with other modes # Reserve SDR device to prevent conflicts with other modes
error = app_module.claim_sdr_device(device, 'aprs') error = app_module.claim_sdr_device(device, 'aprs', sdr_type_str)
if error: if error:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -1689,6 +1717,7 @@ def start_aprs() -> Response:
'message': error 'message': error
}), 409 }), 409
aprs_active_device = device aprs_active_device = device
aprs_active_sdr_type = sdr_type_str
# Get frequency for region # Get frequency for region
region = data.get('region', 'north_america') region = data.get('region', 'north_america')
@@ -1730,8 +1759,9 @@ def start_aprs() -> Response:
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-'] rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-']
except Exception as e: except Exception as e:
if aprs_active_device is not None: if aprs_active_device is not None:
app_module.release_sdr_device(aprs_active_device) app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
aprs_active_device = None aprs_active_device = None
aprs_active_sdr_type = None
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500 return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
# Build decoder command # Build decoder command
@@ -1785,19 +1815,25 @@ def start_aprs() -> Response:
rtl_stderr_thread = threading.Thread(target=monitor_rtl_stderr, daemon=True) rtl_stderr_thread = threading.Thread(target=monitor_rtl_stderr, daemon=True)
rtl_stderr_thread.start() rtl_stderr_thread.start()
# Create a pseudo-terminal for decoder output. PTY forces the
# decoder to line-buffer its stdout, avoiding the 15-minute delay
# caused by full pipe buffering (~4-8KB) on small APRS packets.
master_fd, slave_fd = pty.openpty()
# Start decoder with stdin wired to rtl_fm's stdout. # Start decoder with stdin wired to rtl_fm's stdout.
# Use binary mode to avoid UnicodeDecodeError on raw/corrupted bytes # stdout/stderr go to the PTY slave so output is line-buffered.
# from the radio decoder (e.g. 0xf7). Lines are decoded manually
# in stream_aprs_output with errors='replace'.
# Merge stderr into stdout to avoid blocking on unbuffered stderr.
decoder_process = subprocess.Popen( decoder_process = subprocess.Popen(
decoder_cmd, decoder_cmd,
stdin=rtl_process.stdout, stdin=rtl_process.stdout,
stdout=PIPE, stdout=slave_fd,
stderr=STDOUT, stderr=slave_fd,
close_fds=True,
start_new_session=True start_new_session=True
) )
# Close slave fd in parent — decoder owns it now.
os.close(slave_fd)
# Close rtl_fm's stdout in parent so decoder owns it exclusively. # Close rtl_fm's stdout in parent so decoder owns it exclusively.
# This ensures proper EOF propagation when rtl_fm terminates. # This ensures proper EOF propagation when rtl_fm terminates.
rtl_process.stdout.close() rtl_process.stdout.close()
@@ -1818,40 +1854,57 @@ def start_aprs() -> Response:
if stderr_output: if stderr_output:
error_msg += f': {stderr_output[:200]}' error_msg += f': {stderr_output[:200]}'
logger.error(error_msg) logger.error(error_msg)
try:
os.close(master_fd)
except OSError:
pass
try: try:
decoder_process.kill() decoder_process.kill()
except Exception: except Exception:
pass pass
if aprs_active_device is not None: if aprs_active_device is not None:
app_module.release_sdr_device(aprs_active_device) app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
aprs_active_device = None aprs_active_device = None
aprs_active_sdr_type = None
return jsonify({'status': 'error', 'message': error_msg}), 500 return jsonify({'status': 'error', 'message': error_msg}), 500
if decoder_process.poll() is not None: if decoder_process.poll() is not None:
# Decoder exited early - capture any output # Decoder exited early - capture any output from PTY
raw_output = decoder_process.stdout.read()[:500] if decoder_process.stdout else b'' error_output = ''
error_output = raw_output.decode('utf-8', errors='replace') if raw_output else '' try:
ready, _, _ = select.select([master_fd], [], [], 0.5)
if ready:
raw = os.read(master_fd, 500)
error_output = raw.decode('utf-8', errors='replace')
except Exception:
pass
error_msg = f'{decoder_name} failed to start' error_msg = f'{decoder_name} failed to start'
if error_output: if error_output:
error_msg += f': {error_output}' error_msg += f': {error_output}'
logger.error(error_msg) logger.error(error_msg)
try:
os.close(master_fd)
except OSError:
pass
try: try:
rtl_process.kill() rtl_process.kill()
except Exception: except Exception:
pass pass
if aprs_active_device is not None: if aprs_active_device is not None:
app_module.release_sdr_device(aprs_active_device) app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
aprs_active_device = None aprs_active_device = None
aprs_active_sdr_type = None
return jsonify({'status': 'error', 'message': error_msg}), 500 return jsonify({'status': 'error', 'message': error_msg}), 500
# Store references for status checks and cleanup # Store references for status checks and cleanup
app_module.aprs_process = decoder_process app_module.aprs_process = decoder_process
app_module.aprs_rtl_process = rtl_process app_module.aprs_rtl_process = rtl_process
app_module.aprs_master_fd = master_fd
# Start background thread to read decoder output and push to queue # Start background thread to read decoder output and push to queue
thread = threading.Thread( thread = threading.Thread(
target=stream_aprs_output, target=stream_aprs_output,
args=(rtl_process, decoder_process), args=(master_fd, rtl_process, decoder_process),
daemon=True daemon=True
) )
thread.start() thread.start()
@@ -1868,15 +1921,16 @@ def start_aprs() -> Response:
except Exception as e: except Exception as e:
logger.error(f"Failed to start APRS decoder: {e}") logger.error(f"Failed to start APRS decoder: {e}")
if aprs_active_device is not None: if aprs_active_device is not None:
app_module.release_sdr_device(aprs_active_device) app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
aprs_active_device = None aprs_active_device = None
aprs_active_sdr_type = None
return jsonify({'status': 'error', 'message': str(e)}), 500 return jsonify({'status': 'error', 'message': str(e)}), 500
@aprs_bp.route('/stop', methods=['POST']) @aprs_bp.route('/stop', methods=['POST'])
def stop_aprs() -> Response: def stop_aprs() -> Response:
"""Stop APRS decoder.""" """Stop APRS decoder."""
global aprs_active_device global aprs_active_device, aprs_active_sdr_type
with app_module.aprs_lock: with app_module.aprs_lock:
processes_to_stop = [] processes_to_stop = []
@@ -1902,14 +1956,23 @@ def stop_aprs() -> Response:
except Exception as e: except Exception as e:
logger.error(f"Error stopping APRS process: {e}") logger.error(f"Error stopping APRS process: {e}")
# Close PTY master fd
if hasattr(app_module, 'aprs_master_fd') and app_module.aprs_master_fd is not None:
try:
os.close(app_module.aprs_master_fd)
except OSError:
pass
app_module.aprs_master_fd = None
app_module.aprs_process = None app_module.aprs_process = None
if hasattr(app_module, 'aprs_rtl_process'): if hasattr(app_module, 'aprs_rtl_process'):
app_module.aprs_rtl_process = None app_module.aprs_rtl_process = None
# Release SDR device # Release SDR device
if aprs_active_device is not None: if aprs_active_device is not None:
app_module.release_sdr_device(aprs_active_device) app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
aprs_active_device = None aprs_active_device = None
aprs_active_sdr_type = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})

View File

@@ -51,6 +51,7 @@ dsc_running = False
# Track which device is being used # Track which device is being used
dsc_active_device: int | None = None dsc_active_device: int | None = None
dsc_active_sdr_type: str | None = None
def _get_dsc_decoder_path() -> str | None: def _get_dsc_decoder_path() -> str | None:
@@ -171,7 +172,7 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non
'error': str(e) 'error': str(e)
}) })
finally: finally:
global dsc_active_device global dsc_active_device, dsc_active_sdr_type
try: try:
os.close(master_fd) os.close(master_fd)
except OSError: except OSError:
@@ -197,8 +198,9 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non
app_module.dsc_rtl_process = None app_module.dsc_rtl_process = None
# Release SDR device # Release SDR device
if dsc_active_device is not None: if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device) app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
dsc_active_device = None dsc_active_device = None
dsc_active_sdr_type = None
def _store_critical_alert(msg: dict) -> None: def _store_critical_alert(msg: dict) -> None:
@@ -331,10 +333,13 @@ def start_decoding() -> Response:
'message': str(e) 'message': str(e)
}), 400 }), 400
# Get SDR type from request
sdr_type_str = data.get('sdr_type', 'rtlsdr')
# Check if device is available using centralized registry # Check if device is available using centralized registry
global dsc_active_device global dsc_active_device, dsc_active_sdr_type
device_int = int(device) device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'dsc') error = app_module.claim_sdr_device(device_int, 'dsc', sdr_type_str)
if error: if error:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -343,6 +348,7 @@ def start_decoding() -> Response:
}), 409 }), 409
dsc_active_device = device_int dsc_active_device = device_int
dsc_active_sdr_type = sdr_type_str
# Clear queue # Clear queue
while not app_module.dsc_queue.empty(): while not app_module.dsc_queue.empty():
@@ -440,8 +446,9 @@ def start_decoding() -> Response:
pass pass
# Release device on failure # Release device on failure
if dsc_active_device is not None: if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device) app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
dsc_active_device = None dsc_active_device = None
dsc_active_sdr_type = None
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': f'Tool not found: {e.filename}' 'message': f'Tool not found: {e.filename}'
@@ -458,8 +465,9 @@ def start_decoding() -> Response:
pass pass
# Release device on failure # Release device on failure
if dsc_active_device is not None: if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device) app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
dsc_active_device = None dsc_active_device = None
dsc_active_sdr_type = None
logger.error(f"Failed to start DSC decoder: {e}") logger.error(f"Failed to start DSC decoder: {e}")
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -470,7 +478,7 @@ def start_decoding() -> Response:
@dsc_bp.route('/stop', methods=['POST']) @dsc_bp.route('/stop', methods=['POST'])
def stop_decoding() -> Response: def stop_decoding() -> Response:
"""Stop DSC decoder.""" """Stop DSC decoder."""
global dsc_running, dsc_active_device global dsc_running, dsc_active_device, dsc_active_sdr_type
with app_module.dsc_lock: with app_module.dsc_lock:
if not app_module.dsc_process: if not app_module.dsc_process:
@@ -509,8 +517,9 @@ def stop_decoding() -> Response:
# Release device from registry # Release device from registry
if dsc_active_device is not None: if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device) app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
dsc_active_device = None dsc_active_device = None
dsc_active_sdr_type = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})

View File

@@ -55,7 +55,9 @@ scanner_lock = threading.Lock()
scanner_paused = False scanner_paused = False
scanner_current_freq = 0.0 scanner_current_freq = 0.0
scanner_active_device: Optional[int] = None scanner_active_device: Optional[int] = None
scanner_active_sdr_type: str = 'rtlsdr'
receiver_active_device: Optional[int] = None receiver_active_device: Optional[int] = None
receiver_active_sdr_type: str = 'rtlsdr'
scanner_power_process: Optional[subprocess.Popen] = None scanner_power_process: Optional[subprocess.Popen] = None
scanner_config = { scanner_config = {
'start_freq': 88.0, 'start_freq': 88.0,
@@ -996,7 +998,7 @@ def check_tools() -> Response:
@receiver_bp.route('/scanner/start', methods=['POST']) @receiver_bp.route('/scanner/start', methods=['POST'])
def start_scanner() -> Response: def start_scanner() -> Response:
"""Start the frequency scanner.""" """Start the frequency scanner."""
global scanner_thread, scanner_running, scanner_config, scanner_active_device, receiver_active_device global scanner_thread, scanner_running, scanner_config, scanner_active_device, scanner_active_sdr_type, receiver_active_device, receiver_active_sdr_type
with scanner_lock: with scanner_lock:
if scanner_running: if scanner_running:
@@ -1063,10 +1065,11 @@ def start_scanner() -> Response:
}), 503 }), 503
# Release listening device if active # Release listening device if active
if receiver_active_device is not None: if receiver_active_device is not None:
app_module.release_sdr_device(receiver_active_device) app_module.release_sdr_device(receiver_active_device, receiver_active_sdr_type)
receiver_active_device = None receiver_active_device = None
receiver_active_sdr_type = 'rtlsdr'
# Claim device for scanner # Claim device for scanner
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner') error = app_module.claim_sdr_device(scanner_config['device'], 'scanner', scanner_config['sdr_type'])
if error: if error:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -1074,6 +1077,7 @@ def start_scanner() -> Response:
'message': error 'message': error
}), 409 }), 409
scanner_active_device = scanner_config['device'] scanner_active_device = scanner_config['device']
scanner_active_sdr_type = scanner_config['sdr_type']
scanner_running = True scanner_running = True
scanner_thread = threading.Thread(target=scanner_loop_power, daemon=True) scanner_thread = threading.Thread(target=scanner_loop_power, daemon=True)
scanner_thread.start() scanner_thread.start()
@@ -1091,9 +1095,10 @@ def start_scanner() -> Response:
'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.' 'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.'
}), 503 }), 503
if receiver_active_device is not None: if receiver_active_device is not None:
app_module.release_sdr_device(receiver_active_device) app_module.release_sdr_device(receiver_active_device, receiver_active_sdr_type)
receiver_active_device = None receiver_active_device = None
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner') receiver_active_sdr_type = 'rtlsdr'
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner', scanner_config['sdr_type'])
if error: if error:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -1101,6 +1106,7 @@ def start_scanner() -> Response:
'message': error 'message': error
}), 409 }), 409
scanner_active_device = scanner_config['device'] scanner_active_device = scanner_config['device']
scanner_active_sdr_type = scanner_config['sdr_type']
scanner_running = True scanner_running = True
scanner_thread = threading.Thread(target=scanner_loop, daemon=True) scanner_thread = threading.Thread(target=scanner_loop, daemon=True)
@@ -1115,7 +1121,7 @@ def start_scanner() -> Response:
@receiver_bp.route('/scanner/stop', methods=['POST']) @receiver_bp.route('/scanner/stop', methods=['POST'])
def stop_scanner() -> Response: def stop_scanner() -> Response:
"""Stop the frequency scanner.""" """Stop the frequency scanner."""
global scanner_running, scanner_active_device, scanner_power_process global scanner_running, scanner_active_device, scanner_active_sdr_type, scanner_power_process
scanner_running = False scanner_running = False
_stop_audio_stream() _stop_audio_stream()
@@ -1130,8 +1136,9 @@ def stop_scanner() -> Response:
pass pass
scanner_power_process = None scanner_power_process = None
if scanner_active_device is not None: if scanner_active_device is not None:
app_module.release_sdr_device(scanner_active_device) app_module.release_sdr_device(scanner_active_device, scanner_active_sdr_type)
scanner_active_device = None scanner_active_device = None
scanner_active_sdr_type = 'rtlsdr'
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
@@ -1296,7 +1303,7 @@ def get_presets() -> Response:
@receiver_bp.route('/audio/start', methods=['POST']) @receiver_bp.route('/audio/start', methods=['POST'])
def start_audio() -> Response: def start_audio() -> Response:
"""Start audio at specific frequency (manual mode).""" """Start audio at specific frequency (manual mode)."""
global scanner_running, scanner_active_device, receiver_active_device, scanner_power_process, scanner_thread global scanner_running, scanner_active_device, scanner_active_sdr_type, receiver_active_device, receiver_active_sdr_type, scanner_power_process, scanner_thread
global audio_running, audio_frequency, audio_modulation, audio_source, audio_start_token global audio_running, audio_frequency, audio_modulation, audio_source, audio_start_token
data = request.json or {} data = request.json or {}
@@ -1356,8 +1363,9 @@ def start_audio() -> Response:
if scanner_running: if scanner_running:
scanner_running = False scanner_running = False
if scanner_active_device is not None: if scanner_active_device is not None:
app_module.release_sdr_device(scanner_active_device) app_module.release_sdr_device(scanner_active_device, scanner_active_sdr_type)
scanner_active_device = None scanner_active_device = None
scanner_active_sdr_type = 'rtlsdr'
scanner_thread_ref = scanner_thread scanner_thread_ref = scanner_thread
scanner_proc_ref = scanner_power_process scanner_proc_ref = scanner_power_process
scanner_power_process = None scanner_power_process = None
@@ -1419,8 +1427,9 @@ def start_audio() -> Response:
audio_source = 'waterfall' audio_source = 'waterfall'
# Shared monitor uses the waterfall's existing SDR claim. # Shared monitor uses the waterfall's existing SDR claim.
if receiver_active_device is not None: if receiver_active_device is not None:
app_module.release_sdr_device(receiver_active_device) app_module.release_sdr_device(receiver_active_device, receiver_active_sdr_type)
receiver_active_device = None receiver_active_device = None
receiver_active_sdr_type = 'rtlsdr'
return jsonify({ return jsonify({
'status': 'started', 'status': 'started',
'frequency': frequency, 'frequency': frequency,
@@ -1443,13 +1452,14 @@ def start_audio() -> Response:
# to give the USB device time to be fully released. # to give the USB device time to be fully released.
if receiver_active_device is None or receiver_active_device != device: if receiver_active_device is None or receiver_active_device != device:
if receiver_active_device is not None: if receiver_active_device is not None:
app_module.release_sdr_device(receiver_active_device) app_module.release_sdr_device(receiver_active_device, receiver_active_sdr_type)
receiver_active_device = None receiver_active_device = None
receiver_active_sdr_type = 'rtlsdr'
error = None error = None
max_claim_attempts = 6 max_claim_attempts = 6
for attempt in range(max_claim_attempts): for attempt in range(max_claim_attempts):
error = app_module.claim_sdr_device(device, 'receiver') error = app_module.claim_sdr_device(device, 'receiver', sdr_type)
if not error: if not error:
break break
if attempt < max_claim_attempts - 1: if attempt < max_claim_attempts - 1:
@@ -1466,6 +1476,7 @@ def start_audio() -> Response:
'message': error 'message': error
}), 409 }), 409
receiver_active_device = device receiver_active_device = device
receiver_active_sdr_type = sdr_type
_start_audio_stream( _start_audio_stream(
frequency, frequency,
@@ -1489,8 +1500,9 @@ def start_audio() -> Response:
# Avoid leaving a stale device claim after startup failure. # Avoid leaving a stale device claim after startup failure.
if receiver_active_device is not None: if receiver_active_device is not None:
app_module.release_sdr_device(receiver_active_device) app_module.release_sdr_device(receiver_active_device, receiver_active_sdr_type)
receiver_active_device = None receiver_active_device = None
receiver_active_sdr_type = 'rtlsdr'
start_error = '' start_error = ''
for log_path in ('/tmp/rtl_fm_stderr.log', '/tmp/ffmpeg_stderr.log'): for log_path in ('/tmp/rtl_fm_stderr.log', '/tmp/ffmpeg_stderr.log'):
@@ -1515,11 +1527,12 @@ def start_audio() -> Response:
@receiver_bp.route('/audio/stop', methods=['POST']) @receiver_bp.route('/audio/stop', methods=['POST'])
def stop_audio() -> Response: def stop_audio() -> Response:
"""Stop audio.""" """Stop audio."""
global receiver_active_device global receiver_active_device, receiver_active_sdr_type
_stop_audio_stream() _stop_audio_stream()
if receiver_active_device is not None: if receiver_active_device is not None:
app_module.release_sdr_device(receiver_active_device) app_module.release_sdr_device(receiver_active_device, receiver_active_sdr_type)
receiver_active_device = None receiver_active_device = None
receiver_active_sdr_type = 'rtlsdr'
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
@@ -1825,6 +1838,7 @@ waterfall_running = False
waterfall_lock = threading.Lock() waterfall_lock = threading.Lock()
waterfall_queue: queue.Queue = queue.Queue(maxsize=200) waterfall_queue: queue.Queue = queue.Queue(maxsize=200)
waterfall_active_device: Optional[int] = None waterfall_active_device: Optional[int] = None
waterfall_active_sdr_type: str = 'rtlsdr'
waterfall_config = { waterfall_config = {
'start_freq': 88.0, 'start_freq': 88.0,
'end_freq': 108.0, 'end_freq': 108.0,
@@ -2033,7 +2047,7 @@ def _waterfall_loop():
def _stop_waterfall_internal() -> None: def _stop_waterfall_internal() -> None:
"""Stop the waterfall display and release resources.""" """Stop the waterfall display and release resources."""
global waterfall_running, waterfall_process, waterfall_active_device global waterfall_running, waterfall_process, waterfall_active_device, waterfall_active_sdr_type
waterfall_running = False waterfall_running = False
if waterfall_process and waterfall_process.poll() is None: if waterfall_process and waterfall_process.poll() is None:
@@ -2048,14 +2062,15 @@ def _stop_waterfall_internal() -> None:
waterfall_process = None waterfall_process = None
if waterfall_active_device is not None: if waterfall_active_device is not None:
app_module.release_sdr_device(waterfall_active_device) app_module.release_sdr_device(waterfall_active_device, waterfall_active_sdr_type)
waterfall_active_device = None waterfall_active_device = None
waterfall_active_sdr_type = 'rtlsdr'
@receiver_bp.route('/waterfall/start', methods=['POST']) @receiver_bp.route('/waterfall/start', methods=['POST'])
def start_waterfall() -> Response: def start_waterfall() -> Response:
"""Start the waterfall/spectrogram display.""" """Start the waterfall/spectrogram display."""
global waterfall_thread, waterfall_running, waterfall_config, waterfall_active_device global waterfall_thread, waterfall_running, waterfall_config, waterfall_active_device, waterfall_active_sdr_type
with waterfall_lock: with waterfall_lock:
if waterfall_running: if waterfall_running:
@@ -2101,11 +2116,12 @@ def start_waterfall() -> Response:
pass pass
# Claim SDR device # Claim SDR device
error = app_module.claim_sdr_device(waterfall_config['device'], 'waterfall') error = app_module.claim_sdr_device(waterfall_config['device'], 'waterfall', 'rtlsdr')
if error: if error:
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409 return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
waterfall_active_device = waterfall_config['device'] waterfall_active_device = waterfall_config['device']
waterfall_active_sdr_type = 'rtlsdr'
waterfall_running = True waterfall_running = True
waterfall_thread = threading.Thread(target=_waterfall_loop, daemon=True) waterfall_thread = threading.Thread(target=_waterfall_loop, daemon=True)
waterfall_thread.start() waterfall_thread.start()

View File

@@ -51,6 +51,7 @@ class _FilteredQueue:
# Track which device is being used # Track which device is being used
morse_active_device: int | None = None morse_active_device: int | None = None
morse_active_sdr_type: str | None = None
# Runtime lifecycle state. # Runtime lifecycle state.
MORSE_IDLE = 'idle' MORSE_IDLE = 'idle'
@@ -231,7 +232,7 @@ def _snapshot_live_resources() -> list[str]:
@morse_bp.route('/morse/start', methods=['POST']) @morse_bp.route('/morse/start', methods=['POST'])
def start_morse() -> Response: def start_morse() -> Response:
global morse_active_device, morse_decoder_worker, morse_stderr_worker global morse_active_device, morse_active_sdr_type, morse_decoder_worker, morse_stderr_worker
global morse_stop_event, morse_control_queue, morse_runtime_config global morse_stop_event, morse_control_queue, morse_runtime_config
global morse_last_error, morse_session_id global morse_last_error, morse_session_id
@@ -261,6 +262,8 @@ def start_morse() -> Response:
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return jsonify({'status': 'error', 'message': str(e)}), 400
sdr_type_str = data.get('sdr_type', 'rtlsdr')
with app_module.morse_lock: with app_module.morse_lock:
if morse_state in {MORSE_STARTING, MORSE_RUNNING, MORSE_STOPPING}: if morse_state in {MORSE_STARTING, MORSE_RUNNING, MORSE_STOPPING}:
return jsonify({ return jsonify({
@@ -270,7 +273,7 @@ def start_morse() -> Response:
}), 409 }), 409
device_int = int(device) device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'morse') error = app_module.claim_sdr_device(device_int, 'morse', sdr_type_str)
if error: if error:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -279,6 +282,7 @@ def start_morse() -> Response:
}), 409 }), 409
morse_active_device = device_int morse_active_device = device_int
morse_active_sdr_type = sdr_type_str
morse_last_error = '' morse_last_error = ''
morse_session_id += 1 morse_session_id += 1
@@ -288,7 +292,6 @@ def start_morse() -> Response:
sample_rate = 22050 sample_rate = 22050
bias_t = _bool_value(data.get('bias_t', False), False) bias_t = _bool_value(data.get('bias_t', False), False)
sdr_type_str = data.get('sdr_type', 'rtlsdr')
try: try:
sdr_type = SDRType(sdr_type_str) sdr_type = SDRType(sdr_type_str)
except ValueError: except ValueError:
@@ -408,7 +411,7 @@ def start_morse() -> Response:
for device_pos, candidate_device_index in enumerate(candidate_device_indices, start=1): for device_pos, candidate_device_index in enumerate(candidate_device_indices, start=1):
if candidate_device_index != active_device_index: if candidate_device_index != active_device_index:
prev_device = active_device_index prev_device = active_device_index
claim_error = app_module.claim_sdr_device(candidate_device_index, 'morse') claim_error = app_module.claim_sdr_device(candidate_device_index, 'morse', sdr_type_str)
if claim_error: if claim_error:
msg = f'{_device_label(candidate_device_index)} unavailable: {claim_error}' msg = f'{_device_label(candidate_device_index)} unavailable: {claim_error}'
attempt_errors.append(msg) attempt_errors.append(msg)
@@ -417,7 +420,7 @@ def start_morse() -> Response:
continue continue
if prev_device is not None: if prev_device is not None:
app_module.release_sdr_device(prev_device) app_module.release_sdr_device(prev_device, morse_active_sdr_type or 'rtlsdr')
active_device_index = candidate_device_index active_device_index = candidate_device_index
with app_module.morse_lock: with app_module.morse_lock:
morse_active_device = active_device_index morse_active_device = active_device_index
@@ -634,8 +637,9 @@ def start_morse() -> Response:
logger.error('Morse startup failed: %s', msg) logger.error('Morse startup failed: %s', msg)
with app_module.morse_lock: with app_module.morse_lock:
if morse_active_device is not None: if morse_active_device is not None:
app_module.release_sdr_device(morse_active_device) app_module.release_sdr_device(morse_active_device, morse_active_sdr_type or 'rtlsdr')
morse_active_device = None morse_active_device = None
morse_active_sdr_type = None
morse_last_error = msg morse_last_error = msg
_set_state(MORSE_ERROR, msg) _set_state(MORSE_ERROR, msg)
_set_state(MORSE_IDLE, 'Idle') _set_state(MORSE_IDLE, 'Idle')
@@ -675,8 +679,9 @@ def start_morse() -> Response:
) )
with app_module.morse_lock: with app_module.morse_lock:
if morse_active_device is not None: if morse_active_device is not None:
app_module.release_sdr_device(morse_active_device) app_module.release_sdr_device(morse_active_device, morse_active_sdr_type or 'rtlsdr')
morse_active_device = None morse_active_device = None
morse_active_sdr_type = None
morse_last_error = f'Tool not found: {e.filename}' morse_last_error = f'Tool not found: {e.filename}'
_set_state(MORSE_ERROR, morse_last_error) _set_state(MORSE_ERROR, morse_last_error)
_set_state(MORSE_IDLE, 'Idle') _set_state(MORSE_IDLE, 'Idle')
@@ -692,8 +697,9 @@ def start_morse() -> Response:
) )
with app_module.morse_lock: with app_module.morse_lock:
if morse_active_device is not None: if morse_active_device is not None:
app_module.release_sdr_device(morse_active_device) app_module.release_sdr_device(morse_active_device, morse_active_sdr_type or 'rtlsdr')
morse_active_device = None morse_active_device = None
morse_active_sdr_type = None
morse_last_error = str(e) morse_last_error = str(e)
_set_state(MORSE_ERROR, morse_last_error) _set_state(MORSE_ERROR, morse_last_error)
_set_state(MORSE_IDLE, 'Idle') _set_state(MORSE_IDLE, 'Idle')
@@ -702,7 +708,7 @@ def start_morse() -> Response:
@morse_bp.route('/morse/stop', methods=['POST']) @morse_bp.route('/morse/stop', methods=['POST'])
def stop_morse() -> Response: def stop_morse() -> Response:
global morse_active_device, morse_decoder_worker, morse_stderr_worker global morse_active_device, morse_active_sdr_type, morse_decoder_worker, morse_stderr_worker
global morse_stop_event, morse_control_queue global morse_stop_event, morse_control_queue
stop_started = time.perf_counter() stop_started = time.perf_counter()
@@ -717,6 +723,7 @@ def stop_morse() -> Response:
stderr_thread = morse_stderr_worker or getattr(rtl_proc, '_stderr_thread', None) stderr_thread = morse_stderr_worker or getattr(rtl_proc, '_stderr_thread', None)
control_queue = morse_control_queue or getattr(rtl_proc, '_control_queue', None) control_queue = morse_control_queue or getattr(rtl_proc, '_control_queue', None)
active_device = morse_active_device active_device = morse_active_device
active_sdr_type = morse_active_sdr_type
if ( if (
not rtl_proc not rtl_proc
@@ -768,7 +775,7 @@ def stop_morse() -> Response:
_mark(f'stderr thread joined={stderr_joined}') _mark(f'stderr thread joined={stderr_joined}')
if active_device is not None: if active_device is not None:
app_module.release_sdr_device(active_device) app_module.release_sdr_device(active_device, active_sdr_type or 'rtlsdr')
_mark(f'SDR device {active_device} released') _mark(f'SDR device {active_device} released')
stop_ms = round((time.perf_counter() - stop_started) * 1000.0, 1) stop_ms = round((time.perf_counter() - stop_started) * 1000.0, 1)
@@ -782,6 +789,7 @@ def stop_morse() -> Response:
with app_module.morse_lock: with app_module.morse_lock:
morse_active_device = None morse_active_device = None
morse_active_sdr_type = None
_set_state(MORSE_IDLE, 'Stopped', extra={ _set_state(MORSE_IDLE, 'Stopped', extra={
'stop_ms': stop_ms, 'stop_ms': stop_ms,
'cleanup_steps': cleanup_steps, 'cleanup_steps': cleanup_steps,

View File

@@ -24,7 +24,7 @@ from utils.validation import (
validate_frequency, validate_device_index, validate_gain, validate_ppm, validate_frequency, validate_device_index, validate_gain, validate_ppm,
validate_rtl_tcp_host, validate_rtl_tcp_port validate_rtl_tcp_host, validate_rtl_tcp_port
) )
from utils.sse import sse_stream_fanout from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
from utils.process import safe_terminate, register_process, unregister_process from utils.process import safe_terminate, register_process, unregister_process
from utils.sdr import SDRFactory, SDRType, SDRValidationError from utils.sdr import SDRFactory, SDRType, SDRValidationError
@@ -34,6 +34,7 @@ pager_bp = Blueprint('pager', __name__)
# Track which device is being used # Track which device is being used
pager_active_device: int | None = None pager_active_device: int | None = None
pager_active_sdr_type: str | None = None
def parse_multimon_output(line: str) -> dict[str, str] | None: def parse_multimon_output(line: str) -> dict[str, str] | None:
@@ -96,7 +97,7 @@ def parse_multimon_output(line: str) -> dict[str, str] | None:
return None return None
def log_message(msg: dict[str, Any]) -> None: def log_message(msg: dict[str, Any]) -> None:
"""Log a message to file if logging is enabled.""" """Log a message to file if logging is enabled."""
if not app_module.logging_enabled: if not app_module.logging_enabled:
return return
@@ -104,39 +105,39 @@ def log_message(msg: dict[str, Any]) -> None:
with open(app_module.log_file_path, 'a') as f: with open(app_module.log_file_path, 'a') as f:
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
f.write(f"{timestamp} | {msg.get('protocol', 'UNKNOWN')} | {msg.get('address', '')} | {msg.get('message', '')}\n") f.write(f"{timestamp} | {msg.get('protocol', 'UNKNOWN')} | {msg.get('address', '')} | {msg.get('message', '')}\n")
except Exception as e: except Exception as e:
logger.error(f"Failed to log message: {e}") logger.error(f"Failed to log message: {e}")
def _encode_scope_waveform(samples: tuple[int, ...], window_size: int = 256) -> list[int]: def _encode_scope_waveform(samples: tuple[int, ...], window_size: int = 256) -> list[int]:
"""Compress recent PCM samples into a signed 8-bit waveform for SSE.""" """Compress recent PCM samples into a signed 8-bit waveform for SSE."""
if not samples: if not samples:
return [] return []
window = samples[-window_size:] if len(samples) > window_size else samples window = samples[-window_size:] if len(samples) > window_size else samples
waveform: list[int] = [] waveform: list[int] = []
for sample in window: for sample in window:
# Convert int16 PCM to int8 range for lightweight transport. # Convert int16 PCM to int8 range for lightweight transport.
packed = int(round(sample / 256)) packed = int(round(sample / 256))
waveform.append(max(-127, min(127, packed))) waveform.append(max(-127, min(127, packed)))
return waveform return waveform
def audio_relay_thread( def audio_relay_thread(
rtl_stdout, rtl_stdout,
multimon_stdin, multimon_stdin,
output_queue: queue.Queue, output_queue: queue.Queue,
stop_event: threading.Event, stop_event: threading.Event,
) -> None: ) -> None:
"""Relay audio from rtl_fm to multimon-ng while computing signal levels. """Relay audio from rtl_fm to multimon-ng while computing signal levels.
Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight
through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope
event plus a compact waveform sample onto *output_queue*. event plus a compact waveform sample onto *output_queue*.
""" """
CHUNK = 4096 # bytes 2048 samples at 16-bit mono CHUNK = 4096 # bytes 2048 samples at 16-bit mono
INTERVAL = 0.1 # seconds between scope updates INTERVAL = 0.1 # seconds between scope updates
last_scope = time.monotonic() last_scope = time.monotonic()
try: try:
while not stop_event.is_set(): while not stop_event.is_set():
@@ -160,16 +161,16 @@ def audio_relay_thread(
if n_samples == 0: if n_samples == 0:
continue continue
samples = struct.unpack(f'<{n_samples}h', data[:n_samples * 2]) samples = struct.unpack(f'<{n_samples}h', data[:n_samples * 2])
peak = max(abs(s) for s in samples) peak = max(abs(s) for s in samples)
rms = int(math.sqrt(sum(s * s for s in samples) / n_samples)) rms = int(math.sqrt(sum(s * s for s in samples) / n_samples))
output_queue.put_nowait({ output_queue.put_nowait({
'type': 'scope', 'type': 'scope',
'rms': rms, 'rms': rms,
'peak': peak, 'peak': peak,
'waveform': _encode_scope_waveform(samples), 'waveform': _encode_scope_waveform(samples),
}) })
except (struct.error, ValueError, queue.Full): except (struct.error, ValueError, queue.Full):
pass pass
except Exception as e: except Exception as e:
logger.debug(f"Audio relay error: {e}") logger.debug(f"Audio relay error: {e}")
finally: finally:
@@ -220,7 +221,7 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
except Exception as e: except Exception as e:
app_module.output_queue.put({'type': 'error', 'text': str(e)}) app_module.output_queue.put({'type': 'error', 'text': str(e)})
finally: finally:
global pager_active_device global pager_active_device, pager_active_sdr_type
try: try:
os.close(master_fd) os.close(master_fd)
except OSError: except OSError:
@@ -249,13 +250,14 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
app_module.current_process = None app_module.current_process = None
# Release SDR device # Release SDR device
if pager_active_device is not None: if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device) app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
pager_active_device = None pager_active_device = None
pager_active_sdr_type = None
@pager_bp.route('/start', methods=['POST']) @pager_bp.route('/start', methods=['POST'])
def start_decoding() -> Response: def start_decoding() -> Response:
global pager_active_device global pager_active_device, pager_active_sdr_type
with app_module.process_lock: with app_module.process_lock:
if app_module.current_process: if app_module.current_process:
@@ -284,10 +286,13 @@ def start_decoding() -> Response:
rtl_tcp_host = data.get('rtl_tcp_host') rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234) rtl_tcp_port = data.get('rtl_tcp_port', 1234)
# Get SDR type early so we can pass it to claim/release
sdr_type_str = data.get('sdr_type', 'rtlsdr')
# Claim local device if not using remote rtl_tcp # Claim local device if not using remote rtl_tcp
if not rtl_tcp_host: if not rtl_tcp_host:
device_int = int(device) device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'pager') error = app_module.claim_sdr_device(device_int, 'pager', sdr_type_str)
if error: if error:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -295,14 +300,16 @@ def start_decoding() -> Response:
'message': error 'message': error
}), 409 }), 409
pager_active_device = device_int pager_active_device = device_int
pager_active_sdr_type = sdr_type_str
# Validate protocols # Validate protocols
valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX'] valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX']
protocols = data.get('protocols', valid_protocols) protocols = data.get('protocols', valid_protocols)
if not isinstance(protocols, list): if not isinstance(protocols, list):
if pager_active_device is not None: if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device) app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
pager_active_device = None pager_active_device = None
pager_active_sdr_type = None
return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400 return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400
protocols = [p for p in protocols if p in valid_protocols] protocols = [p for p in protocols if p in valid_protocols]
if not protocols: if not protocols:
@@ -327,8 +334,7 @@ def start_decoding() -> Response:
elif proto == 'FLEX': elif proto == 'FLEX':
decoders.extend(['-a', 'FLEX']) decoders.extend(['-a', 'FLEX'])
# Get SDR type and build command via abstraction layer # Build command via SDR abstraction layer
sdr_type_str = data.get('sdr_type', 'rtlsdr')
try: try:
sdr_type = SDRType(sdr_type_str) sdr_type = SDRType(sdr_type_str)
except ValueError: except ValueError:
@@ -443,8 +449,9 @@ def start_decoding() -> Response:
pass pass
# Release device on failure # Release device on failure
if pager_active_device is not None: if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device) app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
pager_active_device = None pager_active_device = None
pager_active_sdr_type = None
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'}) return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
except Exception as e: except Exception as e:
# Kill orphaned rtl_fm process if it was started # Kill orphaned rtl_fm process if it was started
@@ -458,14 +465,15 @@ def start_decoding() -> Response:
pass pass
# Release device on failure # Release device on failure
if pager_active_device is not None: if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device) app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
pager_active_device = None pager_active_device = None
pager_active_sdr_type = None
return jsonify({'status': 'error', 'message': str(e)}) return jsonify({'status': 'error', 'message': str(e)})
@pager_bp.route('/stop', methods=['POST']) @pager_bp.route('/stop', methods=['POST'])
def stop_decoding() -> Response: def stop_decoding() -> Response:
global pager_active_device global pager_active_device, pager_active_sdr_type
with app_module.process_lock: with app_module.process_lock:
if app_module.current_process: if app_module.current_process:
@@ -502,8 +510,9 @@ def stop_decoding() -> Response:
# Release device from registry # Release device from registry
if pager_active_device is not None: if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device) app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
pager_active_device = None pager_active_device = None
pager_active_sdr_type = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
@@ -553,22 +562,22 @@ def toggle_logging() -> Response:
return jsonify({'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path}) return jsonify({'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path})
@pager_bp.route('/stream') @pager_bp.route('/stream')
def stream() -> Response: def stream() -> Response:
def _on_msg(msg: dict[str, Any]) -> None: def _on_msg(msg: dict[str, Any]) -> None:
process_event('pager', msg, msg.get('type')) process_event('pager', msg, msg.get('type'))
response = Response( response = Response(
sse_stream_fanout( sse_stream_fanout(
source_queue=app_module.output_queue, source_queue=app_module.output_queue,
channel_key='pager', channel_key='pager',
timeout=1.0, timeout=1.0,
keepalive_interval=30.0, keepalive_interval=30.0,
on_message=_on_msg, on_message=_on_msg,
), ),
mimetype='text/event-stream', mimetype='text/event-stream',
) )
response.headers['Cache-Control'] = 'no-cache' response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no' response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive' response.headers['Connection'] = 'keep-alive'
return response return response

655
routes/radiosonde.py Normal file
View File

@@ -0,0 +1,655 @@
"""Radiosonde weather balloon tracking routes.
Uses radiosonde_auto_rx to automatically scan for and decode radiosonde
telemetry (position, altitude, temperature, humidity, pressure) on the
400-406 MHz band. Telemetry arrives as JSON over UDP.
"""
from __future__ import annotations
import json
import os
import queue
import shutil
import socket
import sys
import subprocess
import threading
import time
from typing import Any
from flask import Blueprint, Response, jsonify, request
import app as app_module
from utils.constants import (
MAX_RADIOSONDE_AGE_SECONDS,
PROCESS_TERMINATE_TIMEOUT,
RADIOSONDE_TERMINATE_TIMEOUT,
RADIOSONDE_UDP_PORT,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
)
from utils.logging import get_logger
from utils.sdr import SDRFactory, SDRType
from utils.sse import sse_stream_fanout
from utils.validation import validate_device_index, validate_gain
logger = get_logger('intercept.radiosonde')
radiosonde_bp = Blueprint('radiosonde', __name__, url_prefix='/radiosonde')
# Track radiosonde state
radiosonde_running = False
radiosonde_active_device: int | None = None
radiosonde_active_sdr_type: str | None = None
# Active balloon data: serial -> telemetry dict
radiosonde_balloons: dict[str, dict[str, Any]] = {}
_balloons_lock = threading.Lock()
# UDP listener socket reference (so /stop can close it)
_udp_socket: socket.socket | None = None
# Common installation paths for radiosonde_auto_rx
AUTO_RX_PATHS = [
'/opt/radiosonde_auto_rx/auto_rx/auto_rx.py',
'/usr/local/bin/radiosonde_auto_rx',
'/opt/auto_rx/auto_rx.py',
]
def find_auto_rx() -> str | None:
"""Find radiosonde_auto_rx script/binary."""
# Check PATH first
path = shutil.which('radiosonde_auto_rx')
if path:
return path
# Check common locations
for p in AUTO_RX_PATHS:
if os.path.isfile(p) and os.access(p, os.X_OK):
return p
# Check for Python script (not executable but runnable)
for p in AUTO_RX_PATHS:
if os.path.isfile(p):
return p
return None
def generate_station_cfg(
freq_min: float = 400.0,
freq_max: float = 406.0,
gain: float = 40.0,
device_index: int = 0,
ppm: int = 0,
bias_t: bool = False,
udp_port: int = RADIOSONDE_UDP_PORT,
) -> str:
"""Generate a station.cfg for radiosonde_auto_rx and return the file path."""
cfg_dir = os.path.abspath(os.path.join('data', 'radiosonde'))
log_dir = os.path.join(cfg_dir, 'logs')
os.makedirs(log_dir, exist_ok=True)
cfg_path = os.path.join(cfg_dir, 'station.cfg')
# Full station.cfg based on radiosonde_auto_rx v1.8+ example config.
# All sections and keys included to avoid missing-key crashes.
cfg = f"""# Auto-generated by INTERCEPT for radiosonde_auto_rx
[sdr]
sdr_type = RTLSDR
sdr_quantity = 1
sdr_hostname = localhost
sdr_port = 5555
[sdr_1]
device_idx = {device_index}
ppm = {ppm}
gain = {gain}
bias = {str(bias_t)}
[search_params]
min_freq = {freq_min}
max_freq = {freq_max}
rx_timeout = 180
only_scan = []
never_scan = []
always_scan = []
always_decode = []
[location]
station_lat = 0.0
station_lon = 0.0
station_alt = 0.0
gpsd_enabled = False
gpsd_host = localhost
gpsd_port = 2947
[habitat]
uploader_callsign = INTERCEPT
upload_listener_position = False
uploader_antenna = unknown
[sondehub]
sondehub_enabled = False
sondehub_upload_rate = 15
sondehub_contact_email = none@none.com
[aprs]
aprs_enabled = False
aprs_user = N0CALL
aprs_pass = 00000
upload_rate = 30
aprs_server = radiosondy.info
aprs_port = 14580
station_beacon_enabled = False
station_beacon_rate = 30
station_beacon_comment = radiosonde_auto_rx
station_beacon_icon = /`
aprs_object_id = <id>
aprs_use_custom_object_id = False
aprs_custom_comment = <type> <freq>
[oziplotter]
ozi_enabled = False
ozi_update_rate = 5
ozi_host = 127.0.0.1
ozi_port = 8942
payload_summary_enabled = True
payload_summary_host = 127.0.0.1
payload_summary_port = {udp_port}
[email]
email_enabled = False
launch_notifications = True
landing_notifications = True
encrypted_sonde_notifications = True
landing_range_threshold = 30
landing_altitude_threshold = 1000
error_notifications = False
smtp_server = localhost
smtp_port = 25
smtp_authentication = None
smtp_login = None
smtp_password = None
from = sonde@localhost
to = none@none.com
subject = Sonde launch detected
[rotator]
rotator_enabled = False
update_rate = 30
rotation_threshold = 5.0
rotator_hostname = 127.0.0.1
rotator_port = 4533
rotator_homing_enabled = False
rotator_homing_delay = 10
rotator_home_azimuth = 0.0
rotator_home_elevation = 0.0
azimuth_only = False
[logging]
per_sonde_log = True
save_system_log = False
enable_debug_logging = False
save_cal_data = False
[web]
web_host = 127.0.0.1
web_port = 0
archive_age = 120
web_control = False
web_password = none
kml_refresh_rate = 10
[debugging]
save_detection_audio = False
save_decode_audio = False
save_decode_iq = False
save_raw_hex = False
[advanced]
search_step = 800
snr_threshold = 10
max_peaks = 10
min_distance = 1000
scan_dwell_time = 20
detect_dwell_time = 5
scan_delay = 10
quantization = 10000
decoder_spacing_limit = 15000
temporary_block_time = 120
max_async_scan_workers = 4
synchronous_upload = True
payload_id_valid = 3
sdr_fm_path = rtl_fm
sdr_power_path = rtl_power
ss_iq_path = ./ss_iq
ss_power_path = ./ss_power
[filtering]
max_altitude = 50000
max_radius_km = 1000
min_radius_km = 0
radius_temporary_block = False
sonde_time_threshold = 3
"""
with open(cfg_path, 'w') as f:
f.write(cfg)
logger.info(f"Generated station.cfg at {cfg_path}")
return cfg_path
def parse_radiosonde_udp(udp_port: int) -> None:
"""Thread function: listen for radiosonde_auto_rx UDP JSON telemetry."""
global radiosonde_running, _udp_socket
logger.info(f"Radiosonde UDP listener started on port {udp_port}")
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(('0.0.0.0', udp_port))
sock.settimeout(2.0)
_udp_socket = sock
except OSError as e:
logger.error(f"Failed to bind UDP port {udp_port}: {e}")
return
while radiosonde_running:
try:
data, _addr = sock.recvfrom(4096)
except socket.timeout:
# Clean up stale balloons
_cleanup_stale_balloons()
continue
except OSError:
break
try:
msg = json.loads(data.decode('utf-8', errors='ignore'))
except (json.JSONDecodeError, UnicodeDecodeError):
continue
balloon = _process_telemetry(msg)
if balloon:
serial = balloon.get('id', '')
if serial:
with _balloons_lock:
radiosonde_balloons[serial] = balloon
try:
app_module.radiosonde_queue.put_nowait({
'type': 'balloon',
**balloon,
})
except queue.Full:
pass
try:
sock.close()
except OSError:
pass
_udp_socket = None
logger.info("Radiosonde UDP listener stopped")
def _process_telemetry(msg: dict) -> dict | None:
"""Extract relevant fields from a radiosonde_auto_rx UDP telemetry packet."""
# auto_rx broadcasts packets with a 'type' field
# Telemetry packets have type 'payload_summary' or individual sonde data
serial = msg.get('id') or msg.get('serial')
if not serial:
return None
balloon: dict[str, Any] = {'id': str(serial)}
# Sonde type (RS41, RS92, DFM, M10, etc.)
if 'type' in msg:
balloon['sonde_type'] = msg['type']
if 'subtype' in msg:
balloon['sonde_type'] = msg['subtype']
# Timestamp
if 'datetime' in msg:
balloon['datetime'] = msg['datetime']
# Position
for key in ('lat', 'latitude'):
if key in msg:
try:
balloon['lat'] = float(msg[key])
except (ValueError, TypeError):
pass
break
for key in ('lon', 'longitude'):
if key in msg:
try:
balloon['lon'] = float(msg[key])
except (ValueError, TypeError):
pass
break
# Altitude (metres)
if 'alt' in msg:
try:
balloon['alt'] = float(msg['alt'])
except (ValueError, TypeError):
pass
# Meteorological data
for field in ('temp', 'humidity', 'pressure'):
if field in msg:
try:
balloon[field] = float(msg[field])
except (ValueError, TypeError):
pass
# Velocity
if 'vel_h' in msg:
try:
balloon['vel_h'] = float(msg['vel_h'])
except (ValueError, TypeError):
pass
if 'vel_v' in msg:
try:
balloon['vel_v'] = float(msg['vel_v'])
except (ValueError, TypeError):
pass
if 'heading' in msg:
try:
balloon['heading'] = float(msg['heading'])
except (ValueError, TypeError):
pass
# GPS satellites
if 'sats' in msg:
try:
balloon['sats'] = int(msg['sats'])
except (ValueError, TypeError):
pass
# Battery voltage
if 'batt' in msg:
try:
balloon['batt'] = float(msg['batt'])
except (ValueError, TypeError):
pass
# Frequency
if 'freq' in msg:
try:
balloon['freq'] = float(msg['freq'])
except (ValueError, TypeError):
pass
balloon['last_seen'] = time.time()
return balloon
def _cleanup_stale_balloons() -> None:
"""Remove balloons not seen within the retention window."""
now = time.time()
with _balloons_lock:
stale = [
k for k, v in radiosonde_balloons.items()
if now - v.get('last_seen', 0) > MAX_RADIOSONDE_AGE_SECONDS
]
for k in stale:
del radiosonde_balloons[k]
@radiosonde_bp.route('/tools')
def check_tools():
"""Check for radiosonde decoding tools and hardware."""
auto_rx_path = find_auto_rx()
devices = SDRFactory.detect_devices()
has_rtlsdr = any(d.sdr_type == SDRType.RTL_SDR for d in devices)
return jsonify({
'auto_rx': auto_rx_path is not None,
'auto_rx_path': auto_rx_path,
'has_rtlsdr': has_rtlsdr,
'device_count': len(devices),
})
@radiosonde_bp.route('/status')
def radiosonde_status():
"""Get radiosonde tracking status."""
process_running = False
if app_module.radiosonde_process:
process_running = app_module.radiosonde_process.poll() is None
with _balloons_lock:
balloon_count = len(radiosonde_balloons)
balloons_snapshot = dict(radiosonde_balloons)
return jsonify({
'tracking_active': radiosonde_running,
'active_device': radiosonde_active_device,
'balloon_count': balloon_count,
'balloons': balloons_snapshot,
'queue_size': app_module.radiosonde_queue.qsize(),
'auto_rx_path': find_auto_rx(),
'process_running': process_running,
})
@radiosonde_bp.route('/start', methods=['POST'])
def start_radiosonde():
"""Start radiosonde tracking."""
global radiosonde_running, radiosonde_active_device, radiosonde_active_sdr_type
with app_module.radiosonde_lock:
if radiosonde_running:
return jsonify({
'status': 'already_running',
'message': 'Radiosonde tracking already active',
}), 409
data = request.json or {}
# Validate inputs
try:
gain = float(validate_gain(data.get('gain', '40')))
device = validate_device_index(data.get('device', '0'))
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
freq_min = data.get('freq_min', 400.0)
freq_max = data.get('freq_max', 406.0)
try:
freq_min = float(freq_min)
freq_max = float(freq_max)
if not (380.0 <= freq_min <= 410.0) or not (380.0 <= freq_max <= 410.0):
raise ValueError("Frequency out of range")
if freq_min >= freq_max:
raise ValueError("Min frequency must be less than max")
except (ValueError, TypeError) as e:
return jsonify({'status': 'error', 'message': f'Invalid frequency range: {e}'}), 400
bias_t = data.get('bias_t', False)
ppm = int(data.get('ppm', 0))
# Find auto_rx
auto_rx_path = find_auto_rx()
if not auto_rx_path:
return jsonify({
'status': 'error',
'message': 'radiosonde_auto_rx not found. Install from https://github.com/projecthorus/radiosonde_auto_rx',
}), 400
# Get SDR type
sdr_type_str = data.get('sdr_type', 'rtlsdr')
# Kill any existing process
if app_module.radiosonde_process:
try:
pgid = os.getpgid(app_module.radiosonde_process.pid)
os.killpg(pgid, 15)
app_module.radiosonde_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
except (subprocess.TimeoutExpired, ProcessLookupError, OSError):
try:
pgid = os.getpgid(app_module.radiosonde_process.pid)
os.killpg(pgid, 9)
except (ProcessLookupError, OSError):
pass
app_module.radiosonde_process = None
logger.info("Killed existing radiosonde process")
# Claim SDR device
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'radiosonde', sdr_type_str)
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error,
}), 409
# Generate config
cfg_path = generate_station_cfg(
freq_min=freq_min,
freq_max=freq_max,
gain=gain,
device_index=device_int,
ppm=ppm,
bias_t=bias_t,
)
# Build command - auto_rx -c expects a file path, not a directory
cfg_abs = os.path.abspath(cfg_path)
if auto_rx_path.endswith('.py'):
cmd = [sys.executable, auto_rx_path, '-c', cfg_abs]
else:
cmd = [auto_rx_path, '-c', cfg_abs]
# Set cwd to the auto_rx directory so 'from autorx.scan import ...' works
auto_rx_dir = os.path.dirname(os.path.abspath(auto_rx_path))
try:
logger.info(f"Starting radiosonde_auto_rx: {' '.join(cmd)}")
app_module.radiosonde_process = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
start_new_session=True,
cwd=auto_rx_dir,
)
# Wait briefly for process to start
time.sleep(2.0)
if app_module.radiosonde_process.poll() is not None:
app_module.release_sdr_device(device_int, sdr_type_str)
stderr_output = ''
if app_module.radiosonde_process.stderr:
try:
stderr_output = app_module.radiosonde_process.stderr.read().decode(
'utf-8', errors='ignore'
).strip()
except Exception:
pass
error_msg = 'radiosonde_auto_rx failed to start. Check SDR device connection.'
if stderr_output:
error_msg += f' Error: {stderr_output[:200]}'
return jsonify({'status': 'error', 'message': error_msg}), 500
radiosonde_running = True
radiosonde_active_device = device_int
radiosonde_active_sdr_type = sdr_type_str
# Clear stale data
with _balloons_lock:
radiosonde_balloons.clear()
# Start UDP listener thread
udp_thread = threading.Thread(
target=parse_radiosonde_udp,
args=(RADIOSONDE_UDP_PORT,),
daemon=True,
)
udp_thread.start()
return jsonify({
'status': 'started',
'message': 'Radiosonde tracking started',
'device': device,
})
except Exception as e:
app_module.release_sdr_device(device_int, sdr_type_str)
logger.error(f"Failed to start radiosonde_auto_rx: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
@radiosonde_bp.route('/stop', methods=['POST'])
def stop_radiosonde():
"""Stop radiosonde tracking."""
global radiosonde_running, radiosonde_active_device, radiosonde_active_sdr_type, _udp_socket
with app_module.radiosonde_lock:
if app_module.radiosonde_process:
try:
pgid = os.getpgid(app_module.radiosonde_process.pid)
os.killpg(pgid, 15)
app_module.radiosonde_process.wait(timeout=RADIOSONDE_TERMINATE_TIMEOUT)
except (subprocess.TimeoutExpired, ProcessLookupError, OSError):
try:
pgid = os.getpgid(app_module.radiosonde_process.pid)
os.killpg(pgid, 9)
except (ProcessLookupError, OSError):
pass
app_module.radiosonde_process = None
logger.info("Radiosonde process stopped")
# Close UDP socket to unblock listener thread
if _udp_socket:
try:
_udp_socket.close()
except OSError:
pass
_udp_socket = None
# Release SDR device
if radiosonde_active_device is not None:
app_module.release_sdr_device(
radiosonde_active_device,
radiosonde_active_sdr_type or 'rtlsdr',
)
radiosonde_running = False
radiosonde_active_device = None
radiosonde_active_sdr_type = None
with _balloons_lock:
radiosonde_balloons.clear()
return jsonify({'status': 'stopped'})
@radiosonde_bp.route('/stream')
def stream_radiosonde():
"""SSE stream for radiosonde telemetry."""
response = Response(
sse_stream_fanout(
source_queue=app_module.radiosonde_queue,
channel_key='radiosonde',
timeout=SSE_QUEUE_TIMEOUT,
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
return response
@radiosonde_bp.route('/balloons')
def get_balloons():
"""Get current balloon data."""
with _balloons_lock:
return jsonify({
'status': 'success',
'count': len(radiosonde_balloons),
'balloons': dict(radiosonde_balloons),
})

View File

@@ -1,5 +1,5 @@
"""RTL_433 sensor monitoring routes.""" """RTL_433 sensor monitoring routes."""
from __future__ import annotations from __future__ import annotations
import json import json
@@ -10,25 +10,26 @@ import threading
import time import time
from datetime import datetime from datetime import datetime
from typing import Any, Generator from typing import Any, Generator
from flask import Blueprint, jsonify, request, Response from flask import Blueprint, jsonify, request, Response
import app as app_module import app as app_module
from utils.logging import sensor_logger as logger from utils.logging import sensor_logger as logger
from utils.validation import ( from utils.validation import (
validate_frequency, validate_device_index, validate_gain, validate_ppm, validate_frequency, validate_device_index, validate_gain, validate_ppm,
validate_rtl_tcp_host, validate_rtl_tcp_port validate_rtl_tcp_host, validate_rtl_tcp_port
) )
from utils.sse import sse_stream_fanout from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
from utils.process import safe_terminate, register_process, unregister_process from utils.process import safe_terminate, register_process, unregister_process
from utils.sdr import SDRFactory, SDRType from utils.sdr import SDRFactory, SDRType
sensor_bp = Blueprint('sensor', __name__) sensor_bp = Blueprint('sensor', __name__)
# Track which device is being used # Track which device is being used
sensor_active_device: int | None = None sensor_active_device: int | None = None
sensor_active_sdr_type: str | None = None
# RSSI history per device (model_id -> list of (timestamp, rssi)) # RSSI history per device (model_id -> list of (timestamp, rssi))
sensor_rssi_history: dict[str, list[tuple[float, float]]] = {} sensor_rssi_history: dict[str, list[tuple[float, float]]] = {}
_MAX_RSSI_HISTORY = 60 _MAX_RSSI_HISTORY = 60
@@ -65,36 +66,36 @@ def _build_scope_waveform(rssi: float, snr: float, noise: float, points: int = 2
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None: def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
"""Stream rtl_433 JSON output to queue.""" """Stream rtl_433 JSON output to queue."""
try: try:
app_module.sensor_queue.put({'type': 'status', 'text': 'started'}) app_module.sensor_queue.put({'type': 'status', 'text': 'started'})
for line in iter(process.stdout.readline, b''): for line in iter(process.stdout.readline, b''):
line = line.decode('utf-8', errors='replace').strip() line = line.decode('utf-8', errors='replace').strip()
if not line: if not line:
continue continue
try: try:
# rtl_433 outputs JSON objects, one per line # rtl_433 outputs JSON objects, one per line
data = json.loads(line) data = json.loads(line)
data['type'] = 'sensor' data['type'] = 'sensor'
app_module.sensor_queue.put(data) app_module.sensor_queue.put(data)
# Track RSSI history per device # Track RSSI history per device
_model = data.get('model', '') _model = data.get('model', '')
_dev_id = data.get('id', '') _dev_id = data.get('id', '')
_rssi_val = data.get('rssi') _rssi_val = data.get('rssi')
if _rssi_val is not None and _model: if _rssi_val is not None and _model:
_hist_key = f"{_model}_{_dev_id}" _hist_key = f"{_model}_{_dev_id}"
hist = sensor_rssi_history.setdefault(_hist_key, []) hist = sensor_rssi_history.setdefault(_hist_key, [])
hist.append((time.time(), float(_rssi_val))) hist.append((time.time(), float(_rssi_val)))
if len(hist) > _MAX_RSSI_HISTORY: if len(hist) > _MAX_RSSI_HISTORY:
del hist[: len(hist) - _MAX_RSSI_HISTORY] del hist[: len(hist) - _MAX_RSSI_HISTORY]
# Push scope event when signal level data is present # Push scope event when signal level data is present
rssi = data.get('rssi') rssi = data.get('rssi')
snr = data.get('snr') snr = data.get('snr')
noise = data.get('noise') noise = data.get('noise')
if rssi is not None or snr is not None: if rssi is not None or snr is not None:
try: try:
rssi_value = float(rssi) if rssi is not None else 0.0 rssi_value = float(rssi) if rssi is not None else 0.0
@@ -113,204 +114,211 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
}) })
except (TypeError, ValueError, queue.Full): except (TypeError, ValueError, queue.Full):
pass pass
# Log if enabled # Log if enabled
if app_module.logging_enabled: if app_module.logging_enabled:
try: try:
with open(app_module.log_file_path, 'a') as f: with open(app_module.log_file_path, 'a') as f:
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
f.write(f"{timestamp} | {data.get('model', 'Unknown')} | {json.dumps(data)}\n") f.write(f"{timestamp} | {data.get('model', 'Unknown')} | {json.dumps(data)}\n")
except Exception: except Exception:
pass pass
except json.JSONDecodeError: except json.JSONDecodeError:
# Not JSON, send as raw # Not JSON, send as raw
app_module.sensor_queue.put({'type': 'raw', 'text': line}) app_module.sensor_queue.put({'type': 'raw', 'text': line})
except Exception as e: except Exception as e:
app_module.sensor_queue.put({'type': 'error', 'text': str(e)}) app_module.sensor_queue.put({'type': 'error', 'text': str(e)})
finally: finally:
global sensor_active_device global sensor_active_device, sensor_active_sdr_type
# Ensure process is terminated # Ensure process is terminated
try: try:
process.terminate() process.terminate()
process.wait(timeout=2) process.wait(timeout=2)
except Exception: except Exception:
try: try:
process.kill() process.kill()
except Exception: except Exception:
pass pass
unregister_process(process) unregister_process(process)
app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'}) app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'})
with app_module.sensor_lock: with app_module.sensor_lock:
app_module.sensor_process = None app_module.sensor_process = None
# Release SDR device # Release SDR device
if sensor_active_device is not None: if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device) app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
sensor_active_device = None sensor_active_device = None
sensor_active_sdr_type = None
@sensor_bp.route('/sensor/status')
def sensor_status() -> Response: @sensor_bp.route('/sensor/status')
"""Check if sensor decoder is currently running.""" def sensor_status() -> Response:
with app_module.sensor_lock: """Check if sensor decoder is currently running."""
running = app_module.sensor_process is not None and app_module.sensor_process.poll() is None with app_module.sensor_lock:
return jsonify({'running': running}) running = app_module.sensor_process is not None and app_module.sensor_process.poll() is None
return jsonify({'running': running})
@sensor_bp.route('/start_sensor', methods=['POST'])
def start_sensor() -> Response: @sensor_bp.route('/start_sensor', methods=['POST'])
global sensor_active_device def start_sensor() -> Response:
global sensor_active_device, sensor_active_sdr_type
with app_module.sensor_lock:
if app_module.sensor_process: with app_module.sensor_lock:
return jsonify({'status': 'error', 'message': 'Sensor already running'}), 409 if app_module.sensor_process:
return jsonify({'status': 'error', 'message': 'Sensor already running'}), 409
data = request.json or {}
data = request.json or {}
# Validate inputs
try: # Validate inputs
freq = validate_frequency(data.get('frequency', '433.92')) try:
gain = validate_gain(data.get('gain', '0')) freq = validate_frequency(data.get('frequency', '433.92'))
ppm = validate_ppm(data.get('ppm', '0')) gain = validate_gain(data.get('gain', '0'))
device = validate_device_index(data.get('device', '0')) ppm = validate_ppm(data.get('ppm', '0'))
except ValueError as e: device = validate_device_index(data.get('device', '0'))
return jsonify({'status': 'error', 'message': str(e)}), 400 except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
# Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host') # Check for rtl_tcp (remote SDR) connection
rtl_tcp_port = data.get('rtl_tcp_port', 1234) rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
# Claim local device if not using remote rtl_tcp
if not rtl_tcp_host: # Get SDR type early so we can pass it to claim/release
device_int = int(device) sdr_type_str = data.get('sdr_type', 'rtlsdr')
error = app_module.claim_sdr_device(device_int, 'sensor')
if error: # Claim local device if not using remote rtl_tcp
return jsonify({ if not rtl_tcp_host:
'status': 'error', device_int = int(device)
'error_type': 'DEVICE_BUSY', error = app_module.claim_sdr_device(device_int, 'sensor', sdr_type_str)
'message': error if error:
}), 409 return jsonify({
sensor_active_device = device_int 'status': 'error',
'error_type': 'DEVICE_BUSY',
# Clear queue 'message': error
while not app_module.sensor_queue.empty(): }), 409
try: sensor_active_device = device_int
app_module.sensor_queue.get_nowait() sensor_active_sdr_type = sdr_type_str
except queue.Empty:
break # Clear queue
while not app_module.sensor_queue.empty():
# Get SDR type and build command via abstraction layer try:
sdr_type_str = data.get('sdr_type', 'rtlsdr') app_module.sensor_queue.get_nowait()
try: except queue.Empty:
sdr_type = SDRType(sdr_type_str) break
except ValueError:
sdr_type = SDRType.RTL_SDR # Build command via SDR abstraction layer
try:
if rtl_tcp_host: sdr_type = SDRType(sdr_type_str)
# Validate and create network device except ValueError:
try: sdr_type = SDRType.RTL_SDR
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port) if rtl_tcp_host:
except ValueError as e: # Validate and create network device
return jsonify({'status': 'error', 'message': str(e)}), 400 try:
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port) rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}") except ValueError as e:
else: return jsonify({'status': 'error', 'message': str(e)}), 400
# Create local device object
sdr_device = SDRFactory.create_default_device(sdr_type, index=device) sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
builder = SDRFactory.get_builder(sdr_device.sdr_type) else:
# Create local device object
# Build ISM band decoder command sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
bias_t = data.get('bias_t', False)
cmd = builder.build_ism_command( builder = SDRFactory.get_builder(sdr_device.sdr_type)
device=sdr_device,
frequency_mhz=freq, # Build ISM band decoder command
gain=float(gain) if gain and gain != 0 else None, bias_t = data.get('bias_t', False)
ppm=int(ppm) if ppm and ppm != 0 else None, cmd = builder.build_ism_command(
bias_t=bias_t device=sdr_device,
) frequency_mhz=freq,
gain=float(gain) if gain and gain != 0 else None,
full_cmd = ' '.join(cmd) ppm=int(ppm) if ppm and ppm != 0 else None,
logger.info(f"Running: {full_cmd}") bias_t=bias_t
)
# Add signal level metadata so the frontend scope can display RSSI/SNR
# Disable stats reporting to suppress "row count limit 50 reached" warnings full_cmd = ' '.join(cmd)
cmd.extend(['-M', 'level', '-M', 'stats:0']) logger.info(f"Running: {full_cmd}")
try: # Add signal level metadata so the frontend scope can display RSSI/SNR
app_module.sensor_process = subprocess.Popen( # Disable stats reporting to suppress "row count limit 50 reached" warnings
cmd, cmd.extend(['-M', 'level', '-M', 'stats:0'])
stdout=subprocess.PIPE,
stderr=subprocess.PIPE try:
) app_module.sensor_process = subprocess.Popen(
register_process(app_module.sensor_process) cmd,
stdout=subprocess.PIPE,
# Start output thread stderr=subprocess.PIPE
thread = threading.Thread(target=stream_sensor_output, args=(app_module.sensor_process,)) )
thread.daemon = True register_process(app_module.sensor_process)
thread.start()
# Start output thread
# Monitor stderr thread = threading.Thread(target=stream_sensor_output, args=(app_module.sensor_process,))
# Filter noisy rtl_433 diagnostics that aren't useful to display thread.daemon = True
_stderr_noise = ( thread.start()
'bitbuffer_add_bit',
'row count limit', # Monitor stderr
) # Filter noisy rtl_433 diagnostics that aren't useful to display
_stderr_noise = (
def monitor_stderr(): 'bitbuffer_add_bit',
for line in app_module.sensor_process.stderr: 'row count limit',
err = line.decode('utf-8', errors='replace').strip() )
if err and not any(noise in err for noise in _stderr_noise):
logger.debug(f"[rtl_433] {err}") def monitor_stderr():
app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'}) for line in app_module.sensor_process.stderr:
err = line.decode('utf-8', errors='replace').strip()
stderr_thread = threading.Thread(target=monitor_stderr) if err and not any(noise in err for noise in _stderr_noise):
stderr_thread.daemon = True logger.debug(f"[rtl_433] {err}")
stderr_thread.start() app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'})
app_module.sensor_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'}) stderr_thread = threading.Thread(target=monitor_stderr)
stderr_thread.daemon = True
return jsonify({'status': 'started', 'command': full_cmd}) stderr_thread.start()
except FileNotFoundError: app_module.sensor_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'})
# Release device on failure
if sensor_active_device is not None: return jsonify({'status': 'started', 'command': full_cmd})
app_module.release_sdr_device(sensor_active_device)
sensor_active_device = None except FileNotFoundError:
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'}) # Release device on failure
except Exception as e: if sensor_active_device is not None:
# Release device on failure app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
if sensor_active_device is not None: sensor_active_device = None
app_module.release_sdr_device(sensor_active_device) sensor_active_sdr_type = None
sensor_active_device = None return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
return jsonify({'status': 'error', 'message': str(e)}) except Exception as e:
# Release device on failure
if sensor_active_device is not None:
@sensor_bp.route('/stop_sensor', methods=['POST']) app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
def stop_sensor() -> Response: sensor_active_device = None
global sensor_active_device sensor_active_sdr_type = None
return jsonify({'status': 'error', 'message': str(e)})
with app_module.sensor_lock:
if app_module.sensor_process:
app_module.sensor_process.terminate() @sensor_bp.route('/stop_sensor', methods=['POST'])
try: def stop_sensor() -> Response:
app_module.sensor_process.wait(timeout=2) global sensor_active_device, sensor_active_sdr_type
except subprocess.TimeoutExpired:
app_module.sensor_process.kill() with app_module.sensor_lock:
app_module.sensor_process = None if app_module.sensor_process:
app_module.sensor_process.terminate()
# Release device from registry try:
if sensor_active_device is not None: app_module.sensor_process.wait(timeout=2)
app_module.release_sdr_device(sensor_active_device) except subprocess.TimeoutExpired:
sensor_active_device = None app_module.sensor_process.kill()
app_module.sensor_process = None
return jsonify({'status': 'stopped'})
# Release device from registry
return jsonify({'status': 'not_running'}) if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
sensor_active_device = None
sensor_active_sdr_type = None
return jsonify({'status': 'stopped'})
return jsonify({'status': 'not_running'})
@sensor_bp.route('/stream_sensor') @sensor_bp.route('/stream_sensor')
def stream_sensor() -> Response: def stream_sensor() -> Response:
def _on_msg(msg: dict[str, Any]) -> None: def _on_msg(msg: dict[str, Any]) -> None:
@@ -330,12 +338,12 @@ def stream_sensor() -> Response:
response.headers['X-Accel-Buffering'] = 'no' response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive' response.headers['Connection'] = 'keep-alive'
return response return response
@sensor_bp.route('/sensor/rssi_history') @sensor_bp.route('/sensor/rssi_history')
def get_rssi_history() -> Response: def get_rssi_history() -> Response:
"""Return RSSI history for all tracked sensor devices.""" """Return RSSI history for all tracked sensor devices."""
result = {} result = {}
for key, entries in sensor_rssi_history.items(): for key, entries in sensor_rssi_history.items():
result[key] = [{'t': round(t, 1), 'rssi': rssi} for t, rssi in entries] result[key] = [{'t': round(t, 1), 'rssi': rssi} for t, rssi in entries]
return jsonify({'status': 'success', 'devices': result}) return jsonify({'status': 'success', 'devices': result})

View File

@@ -1,7 +1,8 @@
"""System Health monitoring blueprint. """System Health monitoring blueprint.
Provides real-time system metrics (CPU, memory, disk, temperatures), Provides real-time system metrics (CPU, memory, disk, temperatures,
active process status, and SDR device enumeration via SSE streaming. network, battery, fans), active process status, SDR device enumeration,
location, and weather data via SSE streaming and REST endpoints.
""" """
from __future__ import annotations from __future__ import annotations
@@ -11,11 +12,13 @@ import os
import platform import platform
import queue import queue
import socket import socket
import subprocess
import threading import threading
import time import time
from pathlib import Path
from typing import Any from typing import Any
from flask import Blueprint, Response, jsonify from flask import Blueprint, Response, jsonify, request
from utils.constants import SSE_KEEPALIVE_INTERVAL, SSE_QUEUE_TIMEOUT from utils.constants import SSE_KEEPALIVE_INTERVAL, SSE_QUEUE_TIMEOUT
from utils.logging import sensor_logger as logger from utils.logging import sensor_logger as logger
@@ -29,6 +32,11 @@ except ImportError:
psutil = None # type: ignore[assignment] psutil = None # type: ignore[assignment]
_HAS_PSUTIL = False _HAS_PSUTIL = False
try:
import requests as _requests
except ImportError:
_requests = None # type: ignore[assignment]
system_bp = Blueprint('system', __name__, url_prefix='/system') system_bp = Blueprint('system', __name__, url_prefix='/system')
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -40,6 +48,11 @@ _collector_started = False
_collector_lock = threading.Lock() _collector_lock = threading.Lock()
_app_start_time: float | None = None _app_start_time: float | None = None
# Weather cache
_weather_cache: dict[str, Any] = {}
_weather_cache_time: float = 0.0
_WEATHER_CACHE_TTL = 600 # 10 minutes
def _get_app_start_time() -> float: def _get_app_start_time() -> float:
"""Return the application start timestamp from the main app module.""" """Return the application start timestamp from the main app module."""
@@ -138,6 +151,38 @@ def _collect_process_status() -> dict[str, bool]:
return {} return {}
def _collect_throttle_flags() -> str | None:
"""Read Raspberry Pi throttle flags via vcgencmd (Linux/Pi only)."""
try:
result = subprocess.run(
['vcgencmd', 'get_throttled'],
capture_output=True,
text=True,
timeout=2,
)
if result.returncode == 0 and 'throttled=' in result.stdout:
return result.stdout.strip().split('=', 1)[1]
except Exception:
pass
return None
def _collect_power_draw() -> float | None:
"""Read power draw in watts from sysfs (Linux only)."""
try:
power_supply = Path('/sys/class/power_supply')
if not power_supply.exists():
return None
for supply_dir in power_supply.iterdir():
power_file = supply_dir / 'power_now'
if power_file.exists():
val = int(power_file.read_text().strip())
return round(val / 1_000_000, 2) # microwatts to watts
except Exception:
pass
return None
def _collect_metrics() -> dict[str, Any]: def _collect_metrics() -> dict[str, Any]:
"""Gather a snapshot of system metrics.""" """Gather a snapshot of system metrics."""
now = time.time() now = time.time()
@@ -159,7 +204,7 @@ def _collect_metrics() -> dict[str, Any]:
} }
if _HAS_PSUTIL: if _HAS_PSUTIL:
# CPU # CPU — overall + per-core + frequency
cpu_percent = psutil.cpu_percent(interval=None) cpu_percent = psutil.cpu_percent(interval=None)
cpu_count = psutil.cpu_count() or 1 cpu_count = psutil.cpu_count() or 1
try: try:
@@ -167,12 +212,28 @@ def _collect_metrics() -> dict[str, Any]:
except (OSError, AttributeError): except (OSError, AttributeError):
load_1 = load_5 = load_15 = 0.0 load_1 = load_5 = load_15 = 0.0
per_core = []
with contextlib.suppress(Exception):
per_core = psutil.cpu_percent(interval=None, percpu=True)
freq_data = None
with contextlib.suppress(Exception):
freq = psutil.cpu_freq()
if freq:
freq_data = {
'current': round(freq.current, 0),
'min': round(freq.min, 0),
'max': round(freq.max, 0),
}
metrics['cpu'] = { metrics['cpu'] = {
'percent': cpu_percent, 'percent': cpu_percent,
'count': cpu_count, 'count': cpu_count,
'load_1': round(load_1, 2), 'load_1': round(load_1, 2),
'load_5': round(load_5, 2), 'load_5': round(load_5, 2),
'load_15': round(load_15, 2), 'load_15': round(load_15, 2),
'per_core': per_core,
'freq': freq_data,
} }
# Memory # Memory
@@ -191,7 +252,7 @@ def _collect_metrics() -> dict[str, Any]:
'percent': swap.percent, 'percent': swap.percent,
} }
# Disk # Disk — usage + I/O counters
try: try:
disk = psutil.disk_usage('/') disk = psutil.disk_usage('/')
metrics['disk'] = { metrics['disk'] = {
@@ -204,6 +265,18 @@ def _collect_metrics() -> dict[str, Any]:
except Exception: except Exception:
metrics['disk'] = None metrics['disk'] = None
disk_io = None
with contextlib.suppress(Exception):
dio = psutil.disk_io_counters()
if dio:
disk_io = {
'read_bytes': dio.read_bytes,
'write_bytes': dio.write_bytes,
'read_count': dio.read_count,
'write_count': dio.write_count,
}
metrics['disk_io'] = disk_io
# Temperatures # Temperatures
try: try:
temps = psutil.sensors_temperatures() temps = psutil.sensors_temperatures()
@@ -224,12 +297,102 @@ def _collect_metrics() -> dict[str, Any]:
metrics['temperatures'] = None metrics['temperatures'] = None
except (AttributeError, Exception): except (AttributeError, Exception):
metrics['temperatures'] = None metrics['temperatures'] = None
# Fans
fans_data = None
with contextlib.suppress(Exception):
fans = psutil.sensors_fans()
if fans:
fans_data = {}
for chip, entries in fans.items():
fans_data[chip] = [
{'label': e.label or chip, 'current': e.current}
for e in entries
]
metrics['fans'] = fans_data
# Battery
battery_data = None
with contextlib.suppress(Exception):
bat = psutil.sensors_battery()
if bat:
battery_data = {
'percent': bat.percent,
'plugged': bat.power_plugged,
'secs_left': bat.secsleft if bat.secsleft != psutil.POWER_TIME_UNLIMITED else None,
}
metrics['battery'] = battery_data
# Network interfaces
net_ifaces: list[dict[str, Any]] = []
with contextlib.suppress(Exception):
addrs = psutil.net_if_addrs()
stats = psutil.net_if_stats()
for iface_name in sorted(addrs.keys()):
if iface_name == 'lo':
continue
iface_info: dict[str, Any] = {'name': iface_name}
# Get addresses
for addr in addrs[iface_name]:
if addr.family == socket.AF_INET:
iface_info['ipv4'] = addr.address
elif addr.family == socket.AF_INET6:
iface_info.setdefault('ipv6', addr.address)
elif addr.family == psutil.AF_LINK:
iface_info['mac'] = addr.address
# Get stats
if iface_name in stats:
st = stats[iface_name]
iface_info['is_up'] = st.isup
iface_info['speed'] = st.speed # Mbps
iface_info['mtu'] = st.mtu
net_ifaces.append(iface_info)
metrics['network'] = {'interfaces': net_ifaces}
# Network I/O counters (raw — JS computes deltas)
net_io = None
with contextlib.suppress(Exception):
counters = psutil.net_io_counters(pernic=True)
if counters:
net_io = {}
for nic, c in counters.items():
if nic == 'lo':
continue
net_io[nic] = {
'bytes_sent': c.bytes_sent,
'bytes_recv': c.bytes_recv,
}
metrics['network']['io'] = net_io
# Connection count
conn_count = 0
with contextlib.suppress(Exception):
conn_count = len(psutil.net_connections())
metrics['network']['connections'] = conn_count
# Boot time
boot_ts = None
with contextlib.suppress(Exception):
boot_ts = psutil.boot_time()
metrics['boot_time'] = boot_ts
# Power / throttle (Pi-specific)
metrics['power'] = {
'throttled': _collect_throttle_flags(),
'draw_watts': _collect_power_draw(),
}
else: else:
metrics['cpu'] = None metrics['cpu'] = None
metrics['memory'] = None metrics['memory'] = None
metrics['swap'] = None metrics['swap'] = None
metrics['disk'] = None metrics['disk'] = None
metrics['disk_io'] = None
metrics['temperatures'] = None metrics['temperatures'] = None
metrics['fans'] = None
metrics['battery'] = None
metrics['network'] = None
metrics['boot_time'] = None
metrics['power'] = None
return metrics return metrics
@@ -270,6 +433,47 @@ def _ensure_collector() -> None:
logger.info('System metrics collector started') logger.info('System metrics collector started')
def _get_observer_location() -> dict[str, Any]:
"""Get observer location from GPS state or config defaults."""
lat, lon, source = None, None, 'none'
gps_meta: dict[str, Any] = {}
# Try GPS via utils.gps
with contextlib.suppress(Exception):
from utils.gps import get_current_position
pos = get_current_position()
if pos and pos.fix_quality >= 2:
lat, lon, source = pos.latitude, pos.longitude, 'gps'
gps_meta['fix_quality'] = pos.fix_quality
gps_meta['satellites'] = pos.satellites
if pos.epx is not None and pos.epy is not None:
gps_meta['accuracy'] = round(max(pos.epx, pos.epy), 1)
if pos.altitude is not None:
gps_meta['altitude'] = round(pos.altitude, 1)
# Fall back to config env vars
if lat is None:
with contextlib.suppress(Exception):
from config import DEFAULT_LATITUDE, DEFAULT_LONGITUDE
if DEFAULT_LATITUDE != 0.0 or DEFAULT_LONGITUDE != 0.0:
lat, lon, source = DEFAULT_LATITUDE, DEFAULT_LONGITUDE, 'config'
# Fall back to hardcoded constants (London)
if lat is None:
with contextlib.suppress(Exception):
from utils.constants import DEFAULT_LATITUDE as CONST_LAT
from utils.constants import DEFAULT_LONGITUDE as CONST_LON
lat, lon, source = CONST_LAT, CONST_LON, 'default'
result: dict[str, Any] = {'lat': lat, 'lon': lon, 'source': source}
if gps_meta:
result['gps'] = gps_meta
return result
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Routes # Routes
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -321,3 +525,59 @@ def get_sdr_devices() -> Response:
except Exception as exc: except Exception as exc:
logger.warning('SDR device detection failed: %s', exc) logger.warning('SDR device detection failed: %s', exc)
return jsonify({'devices': [], 'error': str(exc)}) return jsonify({'devices': [], 'error': str(exc)})
@system_bp.route('/location')
def get_location() -> Response:
"""Return observer location from GPS or config."""
return jsonify(_get_observer_location())
@system_bp.route('/weather')
def get_weather() -> Response:
"""Proxy weather from wttr.in, cached for 10 minutes."""
global _weather_cache, _weather_cache_time
now = time.time()
if _weather_cache and (now - _weather_cache_time) < _WEATHER_CACHE_TTL:
return jsonify(_weather_cache)
lat = request.args.get('lat', type=float)
lon = request.args.get('lon', type=float)
if lat is None or lon is None:
loc = _get_observer_location()
lat, lon = loc.get('lat'), loc.get('lon')
if lat is None or lon is None:
return jsonify({'error': 'No location available'})
if _requests is None:
return jsonify({'error': 'requests library not available'})
try:
resp = _requests.get(
f'https://wttr.in/{lat},{lon}?format=j1',
timeout=5,
headers={'User-Agent': 'INTERCEPT-SystemHealth/1.0'},
)
resp.raise_for_status()
data = resp.json()
current = data.get('current_condition', [{}])[0]
weather = {
'temp_c': current.get('temp_C'),
'temp_f': current.get('temp_F'),
'condition': current.get('weatherDesc', [{}])[0].get('value', ''),
'humidity': current.get('humidity'),
'wind_mph': current.get('windspeedMiles'),
'wind_dir': current.get('winddir16Point'),
'feels_like_c': current.get('FeelsLikeC'),
'visibility': current.get('visibility'),
'pressure': current.get('pressure'),
}
_weather_cache = weather
_weather_cache_time = now
return jsonify(weather)
except Exception as exc:
logger.debug('Weather fetch failed: %s', exc)
return jsonify({'error': str(exc)})

View File

@@ -48,6 +48,7 @@ vdl2_last_message_time = None
# Track which device is being used # Track which device is being used
vdl2_active_device: int | None = None vdl2_active_device: int | None = None
vdl2_active_sdr_type: str | None = None
def find_dumpvdl2(): def find_dumpvdl2():
@@ -126,7 +127,7 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) ->
logger.error(f"VDL2 stream error: {e}") logger.error(f"VDL2 stream error: {e}")
app_module.vdl2_queue.put({'type': 'error', 'message': str(e)}) app_module.vdl2_queue.put({'type': 'error', 'message': str(e)})
finally: finally:
global vdl2_active_device global vdl2_active_device, vdl2_active_sdr_type
# Ensure process is terminated # Ensure process is terminated
try: try:
process.terminate() process.terminate()
@@ -142,8 +143,9 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) ->
app_module.vdl2_process = None app_module.vdl2_process = None
# Release SDR device # Release SDR device
if vdl2_active_device is not None: if vdl2_active_device is not None:
app_module.release_sdr_device(vdl2_active_device) app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr')
vdl2_active_device = None vdl2_active_device = None
vdl2_active_sdr_type = None
@vdl2_bp.route('/tools') @vdl2_bp.route('/tools')
@@ -175,7 +177,7 @@ def vdl2_status() -> Response:
@vdl2_bp.route('/start', methods=['POST']) @vdl2_bp.route('/start', methods=['POST'])
def start_vdl2() -> Response: def start_vdl2() -> Response:
"""Start VDL2 decoder.""" """Start VDL2 decoder."""
global vdl2_message_count, vdl2_last_message_time, vdl2_active_device global vdl2_message_count, vdl2_last_message_time, vdl2_active_device, vdl2_active_sdr_type
with app_module.vdl2_lock: with app_module.vdl2_lock:
if app_module.vdl2_process and app_module.vdl2_process.poll() is None: if app_module.vdl2_process and app_module.vdl2_process.poll() is None:
@@ -202,9 +204,16 @@ def start_vdl2() -> Response:
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return jsonify({'status': 'error', 'message': str(e)}), 400
# Resolve SDR type for device selection
sdr_type_str = data.get('sdr_type', 'rtlsdr')
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
# Check if device is available # Check if device is available
device_int = int(device) device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'vdl2') error = app_module.claim_sdr_device(device_int, 'vdl2', sdr_type_str)
if error: if error:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -213,6 +222,7 @@ def start_vdl2() -> Response:
}), 409 }), 409
vdl2_active_device = device_int vdl2_active_device = device_int
vdl2_active_sdr_type = sdr_type_str
# Get frequencies - use provided or defaults # Get frequencies - use provided or defaults
# dumpvdl2 expects frequencies in Hz (integers) # dumpvdl2 expects frequencies in Hz (integers)
@@ -231,13 +241,6 @@ def start_vdl2() -> Response:
vdl2_message_count = 0 vdl2_message_count = 0
vdl2_last_message_time = None vdl2_last_message_time = None
# Resolve SDR type for device selection
sdr_type_str = data.get('sdr_type', 'rtlsdr')
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
is_soapy = sdr_type not in (SDRType.RTL_SDR,) is_soapy = sdr_type not in (SDRType.RTL_SDR,)
# Build dumpvdl2 command # Build dumpvdl2 command
@@ -297,8 +300,9 @@ def start_vdl2() -> Response:
if process.poll() is not None: if process.poll() is not None:
# Process died - release device # Process died - release device
if vdl2_active_device is not None: if vdl2_active_device is not None:
app_module.release_sdr_device(vdl2_active_device) app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr')
vdl2_active_device = None vdl2_active_device = None
vdl2_active_sdr_type = None
stderr = '' stderr = ''
if process.stderr: if process.stderr:
stderr = process.stderr.read().decode('utf-8', errors='replace') stderr = process.stderr.read().decode('utf-8', errors='replace')
@@ -329,8 +333,9 @@ def start_vdl2() -> Response:
except Exception as e: except Exception as e:
# Release device on failure # Release device on failure
if vdl2_active_device is not None: if vdl2_active_device is not None:
app_module.release_sdr_device(vdl2_active_device) app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr')
vdl2_active_device = None vdl2_active_device = None
vdl2_active_sdr_type = None
logger.error(f"Failed to start VDL2 decoder: {e}") logger.error(f"Failed to start VDL2 decoder: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500 return jsonify({'status': 'error', 'message': str(e)}), 500
@@ -338,7 +343,7 @@ def start_vdl2() -> Response:
@vdl2_bp.route('/stop', methods=['POST']) @vdl2_bp.route('/stop', methods=['POST'])
def stop_vdl2() -> Response: def stop_vdl2() -> Response:
"""Stop VDL2 decoder.""" """Stop VDL2 decoder."""
global vdl2_active_device global vdl2_active_device, vdl2_active_sdr_type
with app_module.vdl2_lock: with app_module.vdl2_lock:
if not app_module.vdl2_process: if not app_module.vdl2_process:
@@ -359,8 +364,9 @@ def stop_vdl2() -> Response:
# Release device from registry # Release device from registry
if vdl2_active_device is not None: if vdl2_active_device is not None:
app_module.release_sdr_device(vdl2_active_device) app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr')
vdl2_active_device = None vdl2_active_device = None
vdl2_active_sdr_type = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
@@ -386,6 +392,7 @@ def stream_vdl2() -> Response:
return response return response
@vdl2_bp.route('/messages') @vdl2_bp.route('/messages')
def get_vdl2_messages() -> Response: def get_vdl2_messages() -> Response:
"""Get recent VDL2 messages from correlator (for history reload).""" """Get recent VDL2 messages from correlator (for history reload)."""

View File

@@ -367,6 +367,7 @@ def init_waterfall_websocket(app: Flask):
reader_thread = None reader_thread = None
stop_event = threading.Event() stop_event = threading.Event()
claimed_device = None claimed_device = None
claimed_sdr_type = 'rtlsdr'
my_generation = None # tracks which capture generation this handler owns my_generation = None # tracks which capture generation this handler owns
capture_center_mhz = 0.0 capture_center_mhz = 0.0
capture_start_freq = 0.0 capture_start_freq = 0.0
@@ -430,8 +431,9 @@ def init_waterfall_websocket(app: Flask):
unregister_process(iq_process) unregister_process(iq_process)
iq_process = None iq_process = None
if claimed_device is not None: if claimed_device is not None:
app_module.release_sdr_device(claimed_device) app_module.release_sdr_device(claimed_device, claimed_sdr_type)
claimed_device = None claimed_device = None
claimed_sdr_type = 'rtlsdr'
_set_shared_capture_state(running=False, generation=my_generation) _set_shared_capture_state(running=False, generation=my_generation)
my_generation = None my_generation = None
stop_event.clear() stop_event.clear()
@@ -513,7 +515,7 @@ def init_waterfall_websocket(app: Flask):
max_claim_attempts = 4 if was_restarting else 1 max_claim_attempts = 4 if was_restarting else 1
claim_err = None claim_err = None
for _claim_attempt in range(max_claim_attempts): for _claim_attempt in range(max_claim_attempts):
claim_err = app_module.claim_sdr_device(device_index, 'waterfall') claim_err = app_module.claim_sdr_device(device_index, 'waterfall', sdr_type_str)
if not claim_err: if not claim_err:
break break
if _claim_attempt < max_claim_attempts - 1: if _claim_attempt < max_claim_attempts - 1:
@@ -526,6 +528,7 @@ def init_waterfall_websocket(app: Flask):
})) }))
continue continue
claimed_device = device_index claimed_device = device_index
claimed_sdr_type = sdr_type_str
# Build I/Q capture command # Build I/Q capture command
try: try:
@@ -539,8 +542,9 @@ def init_waterfall_websocket(app: Flask):
bias_t=bias_t, bias_t=bias_t,
) )
except NotImplementedError as e: except NotImplementedError as e:
app_module.release_sdr_device(device_index) app_module.release_sdr_device(device_index, sdr_type_str)
claimed_device = None claimed_device = None
claimed_sdr_type = 'rtlsdr'
ws.send(json.dumps({ ws.send(json.dumps({
'status': 'error', 'status': 'error',
'message': str(e), 'message': str(e),
@@ -549,8 +553,9 @@ def init_waterfall_websocket(app: Flask):
# Pre-flight: check the capture binary exists # Pre-flight: check the capture binary exists
if not shutil.which(iq_cmd[0]): if not shutil.which(iq_cmd[0]):
app_module.release_sdr_device(device_index) app_module.release_sdr_device(device_index, sdr_type_str)
claimed_device = None claimed_device = None
claimed_sdr_type = 'rtlsdr'
ws.send(json.dumps({ ws.send(json.dumps({
'status': 'error', 'status': 'error',
'message': f'Required tool "{iq_cmd[0]}" not found. Install SoapySDR tools (rx_sdr).', 'message': f'Required tool "{iq_cmd[0]}" not found. Install SoapySDR tools (rx_sdr).',
@@ -602,8 +607,9 @@ def init_waterfall_websocket(app: Flask):
safe_terminate(iq_process) safe_terminate(iq_process)
unregister_process(iq_process) unregister_process(iq_process)
iq_process = None iq_process = None
app_module.release_sdr_device(device_index) app_module.release_sdr_device(device_index, sdr_type_str)
claimed_device = None claimed_device = None
claimed_sdr_type = 'rtlsdr'
ws.send(json.dumps({ ws.send(json.dumps({
'status': 'error', 'status': 'error',
'message': f'Failed to start I/Q capture: {e}', 'message': f'Failed to start I/Q capture: {e}',
@@ -806,8 +812,9 @@ def init_waterfall_websocket(app: Flask):
unregister_process(iq_process) unregister_process(iq_process)
iq_process = None iq_process = None
if claimed_device is not None: if claimed_device is not None:
app_module.release_sdr_device(claimed_device) app_module.release_sdr_device(claimed_device, claimed_sdr_type)
claimed_device = None claimed_device = None
claimed_sdr_type = 'rtlsdr'
_set_shared_capture_state(running=False, generation=my_generation) _set_shared_capture_state(running=False, generation=my_generation)
my_generation = None my_generation = None
stop_event.clear() stop_event.clear()
@@ -825,7 +832,7 @@ def init_waterfall_websocket(app: Flask):
safe_terminate(iq_process) safe_terminate(iq_process)
unregister_process(iq_process) unregister_process(iq_process)
if claimed_device is not None: if claimed_device is not None:
app_module.release_sdr_device(claimed_device) app_module.release_sdr_device(claimed_device, claimed_sdr_type)
_set_shared_capture_state(running=False, generation=my_generation) _set_shared_capture_state(running=False, generation=my_generation)
# Complete WebSocket close handshake, then shut down the # Complete WebSocket close handshake, then shut down the
# raw socket so Werkzeug cannot write its HTTP 200 response # raw socket so Werkzeug cannot write its HTTP 200 response

View File

@@ -33,11 +33,12 @@ _wefax_queue: queue.Queue = queue.Queue(maxsize=100)
# Track active SDR device # Track active SDR device
wefax_active_device: int | None = None wefax_active_device: int | None = None
wefax_active_sdr_type: str | None = None
def _progress_callback(data: dict) -> None: def _progress_callback(data: dict) -> None:
"""Callback to queue progress updates for SSE stream.""" """Callback to queue progress updates for SSE stream."""
global wefax_active_device global wefax_active_device, wefax_active_sdr_type
try: try:
_wefax_queue.put_nowait(data) _wefax_queue.put_nowait(data)
@@ -56,8 +57,9 @@ def _progress_callback(data: dict) -> None:
and data.get('status') in ('complete', 'error', 'stopped') and data.get('status') in ('complete', 'error', 'stopped')
and wefax_active_device is not None and wefax_active_device is not None
): ):
app_module.release_sdr_device(wefax_active_device) app_module.release_sdr_device(wefax_active_device, wefax_active_sdr_type or 'rtlsdr')
wefax_active_device = None wefax_active_device = None
wefax_active_sdr_type = None
@wefax_bp.route('/status') @wefax_bp.route('/status')
@@ -169,9 +171,9 @@ def start_decoder():
}), 400 }), 400
# Claim SDR device # Claim SDR device
global wefax_active_device global wefax_active_device, wefax_active_sdr_type
device_int = int(device_index) device_int = int(device_index)
error = app_module.claim_sdr_device(device_int, 'wefax') error = app_module.claim_sdr_device(device_int, 'wefax', sdr_type_str)
if error: if error:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -194,6 +196,7 @@ def start_decoder():
if success: if success:
wefax_active_device = device_int wefax_active_device = device_int
wefax_active_sdr_type = sdr_type_str
return jsonify({ return jsonify({
'status': 'started', 'status': 'started',
'frequency_khz': frequency_khz, 'frequency_khz': frequency_khz,
@@ -209,7 +212,7 @@ def start_decoder():
'device': device_int, 'device': device_int,
}) })
else: else:
app_module.release_sdr_device(device_int) app_module.release_sdr_device(device_int, sdr_type_str)
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': 'Failed to start decoder', 'message': 'Failed to start decoder',
@@ -219,13 +222,14 @@ def start_decoder():
@wefax_bp.route('/stop', methods=['POST']) @wefax_bp.route('/stop', methods=['POST'])
def stop_decoder(): def stop_decoder():
"""Stop WeFax decoder.""" """Stop WeFax decoder."""
global wefax_active_device global wefax_active_device, wefax_active_sdr_type
decoder = get_wefax_decoder() decoder = get_wefax_decoder()
decoder.stop() decoder.stop()
if wefax_active_device is not None: if wefax_active_device is not None:
app_module.release_sdr_device(wefax_active_device) app_module.release_sdr_device(wefax_active_device, wefax_active_sdr_type or 'rtlsdr')
wefax_active_device = None wefax_active_device = None
wefax_active_sdr_type = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})

View File

@@ -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,53 @@ WRAPPER
) )
} }
install_radiosonde_auto_rx() {
info "Installing radiosonde_auto_rx (weather balloon decoder)..."
local install_dir="/opt/radiosonde_auto_rx"
local project_dir="$(pwd)"
(
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
info "Cloning radiosonde_auto_rx..."
if ! git clone --depth 1 https://github.com/projecthorus/radiosonde_auto_rx.git "$tmp_dir/radiosonde_auto_rx"; then
warn "Failed to clone radiosonde_auto_rx"
exit 1
fi
info "Installing Python dependencies..."
cd "$tmp_dir/radiosonde_auto_rx/auto_rx"
# Use project venv pip to avoid PEP 668 externally-managed-environment errors
if [ -x "$project_dir/venv/bin/pip" ]; then
"$project_dir/venv/bin/pip" install --quiet -r requirements.txt || {
warn "Failed to install radiosonde_auto_rx Python dependencies"
exit 1
}
else
pip3 install --quiet --break-system-packages -r requirements.txt 2>/dev/null \
|| pip3 install --quiet -r requirements.txt || {
warn "Failed to install radiosonde_auto_rx Python dependencies"
exit 1
}
fi
info "Building radiosonde_auto_rx C decoders..."
if ! bash build.sh; then
warn "Failed to build radiosonde_auto_rx decoders"
exit 1
fi
info "Installing to ${install_dir}..."
refresh_sudo
$SUDO mkdir -p "$install_dir/auto_rx"
$SUDO cp -r . "$install_dir/auto_rx/"
$SUDO chmod +x "$install_dir/auto_rx/auto_rx.py"
ok "radiosonde_auto_rx installed to ${install_dir}"
)
}
install_macos_packages() { install_macos_packages() {
need_sudo need_sudo
@@ -825,7 +873,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 +960,20 @@ 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 ] \
|| { [ -f /opt/radiosonde_auto_rx/auto_rx/auto_rx.py ] && [ ! -f /opt/radiosonde_auto_rx/auto_rx/dft_detect ]; }; then
echo
info "radiosonde_auto_rx is used for weather balloon (radiosonde) tracking."
if ask_yes_no "Do you want to install radiosonde_auto_rx?"; then
install_radiosonde_auto_rx || warn "radiosonde_auto_rx installation failed. Radiosonde tracking will not be available."
else
warn "Skipping radiosonde_auto_rx. You can install it later if needed."
fi
else
ok "radiosonde_auto_rx already installed"
fi
progress "Installing aircrack-ng" progress "Installing aircrack-ng"
brew_install aircrack-ng brew_install aircrack-ng
@@ -1303,7 +1365,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 +1547,20 @@ 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 ] \
|| { [ -f /opt/radiosonde_auto_rx/auto_rx/auto_rx.py ] && [ ! -f /opt/radiosonde_auto_rx/auto_rx/dft_detect ]; }; then
echo
info "radiosonde_auto_rx is used for weather balloon (radiosonde) tracking."
if ask_yes_no "Do you want to install radiosonde_auto_rx?"; then
install_radiosonde_auto_rx || warn "radiosonde_auto_rx installation failed. Radiosonde tracking will not be available."
else
warn "Skipping radiosonde_auto_rx. You can install it later if needed."
fi
else
ok "radiosonde_auto_rx already installed"
fi
progress "Configuring udev rules" progress "Configuring udev rules"
setup_udev_rules_debian setup_udev_rules_debian

View File

@@ -0,0 +1,152 @@
/* ============================================
RADIOSONDE MODE — Scoped Styles
============================================ */
/* Visuals container */
.radiosonde-visuals-container {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
min-height: 0;
overflow: hidden;
padding: 8px;
}
/* Map container */
#radiosondeMapContainer {
flex: 1;
min-height: 300px;
border-radius: 6px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
}
/* Card container below map */
.radiosonde-card-container {
display: flex;
flex-wrap: wrap;
gap: 8px;
max-height: 200px;
overflow-y: auto;
padding: 4px 0;
}
/* Individual balloon card */
.radiosonde-card {
background: var(--bg-card, #1a1e2e);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 10px 12px;
cursor: pointer;
flex: 1 1 280px;
min-width: 260px;
max-width: 400px;
transition: border-color 0.2s ease, background 0.2s ease;
}
.radiosonde-card:hover {
border-color: var(--accent-cyan);
background: rgba(0, 204, 255, 0.04);
}
.radiosonde-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border-color);
}
.radiosonde-serial {
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 13px;
font-weight: 600;
color: var(--accent-cyan);
letter-spacing: 0.5px;
}
.radiosonde-type {
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 10px;
font-weight: 600;
color: var(--text-dim);
background: rgba(255, 255, 255, 0.06);
padding: 2px 6px;
border-radius: 3px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Telemetry stat grid */
.radiosonde-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
.radiosonde-stat {
display: flex;
flex-direction: column;
align-items: center;
padding: 4px;
}
.radiosonde-stat-value {
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
}
.radiosonde-stat-label {
font-size: 9px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.8px;
margin-top: 2px;
}
/* Leaflet popup overrides for radiosonde */
#radiosondeMapContainer .leaflet-popup-content-wrapper {
background: var(--bg-card, #1a1e2e);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 11px;
}
#radiosondeMapContainer .leaflet-popup-tip {
background: var(--bg-card, #1a1e2e);
border: 1px solid var(--border-color);
}
/* Scrollbar for card container */
.radiosonde-card-container::-webkit-scrollbar {
width: 4px;
}
.radiosonde-card-container::-webkit-scrollbar-track {
background: transparent;
}
.radiosonde-card-container::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 2px;
}
/* Responsive: stack cards on narrow screens */
@media (max-width: 600px) {
.radiosonde-card {
flex: 1 1 100%;
max-width: 100%;
}
.radiosonde-stats {
grid-template-columns: repeat(2, 1fr);
}
}

View File

@@ -1,20 +1,45 @@
/* System Health Mode Styles */ /* System Health Mode Styles — Enhanced Dashboard */
.sys-dashboard { .sys-dashboard {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); grid-template-columns: repeat(3, 1fr);
gap: 16px; gap: 16px;
padding: 16px; padding: 16px;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
} }
/* Group headers span full width */
.sys-group-header {
grid-column: 1 / -1;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--accent-cyan, #00d4ff);
border-bottom: 1px solid rgba(0, 212, 255, 0.2);
padding-bottom: 6px;
margin-top: 8px;
}
.sys-group-header:first-child {
margin-top: 0;
}
/* Cards */
.sys-card { .sys-card {
background: var(--bg-card, #1a1a2e); background: var(--bg-card, #1a1a2e);
border: 1px solid var(--border-color, #2a2a4a); border: 1px solid var(--border-color, #2a2a4a);
border-radius: 6px; border-radius: 6px;
padding: 16px; padding: 16px;
min-height: 120px; }
.sys-card-wide {
grid-column: span 2;
}
.sys-card-full {
grid-column: 1 / -1;
} }
.sys-card-header { .sys-card-header {
@@ -99,7 +124,285 @@
font-size: 11px; font-size: 11px;
} }
/* Process items */ /* SVG Arc Gauge */
.sys-gauge-wrap {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 12px;
}
.sys-gauge-arc {
position: relative;
width: 110px;
height: 110px;
flex-shrink: 0;
}
.sys-gauge-arc svg {
width: 100%;
height: 100%;
}
.sys-gauge-arc .arc-bg {
fill: none;
stroke: var(--bg-primary, #0d0d1a);
stroke-width: 8;
stroke-linecap: round;
}
.sys-gauge-arc .arc-fill {
fill: none;
stroke-width: 8;
stroke-linecap: round;
transition: stroke-dashoffset 0.6s ease, stroke 0.3s ease;
filter: drop-shadow(0 0 4px currentColor);
}
.sys-gauge-arc .arc-fill.ok { stroke: var(--accent-green, #00ff88); }
.sys-gauge-arc .arc-fill.warn { stroke: var(--accent-yellow, #ffcc00); }
.sys-gauge-arc .arc-fill.crit { stroke: var(--accent-red, #ff3366); }
.sys-gauge-label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 22px;
font-weight: 700;
font-family: var(--font-mono, 'JetBrains Mono', monospace);
color: var(--text-primary, #e0e0ff);
}
.sys-gauge-details {
flex: 1;
font-size: 12px;
}
/* Per-core bars */
.sys-core-bars {
display: flex;
gap: 4px;
align-items: flex-end;
height: 48px;
margin-top: 12px;
}
.sys-core-bar {
flex: 1;
background: var(--bg-primary, #0d0d1a);
border-radius: 3px;
position: relative;
min-width: 6px;
max-width: 32px;
height: 100%;
}
.sys-core-bar-fill {
position: absolute;
bottom: 0;
left: 0;
right: 0;
border-radius: 2px;
transition: height 0.4s ease, background 0.3s ease;
}
/* Temperature sparkline */
.sys-sparkline-wrap {
margin: 8px 0;
}
.sys-sparkline-wrap svg {
width: 100%;
height: 40px;
}
.sys-sparkline-line {
fill: none;
stroke: var(--accent-cyan, #00d4ff);
stroke-width: 1.5;
filter: drop-shadow(0 0 2px rgba(0, 212, 255, 0.4));
}
.sys-sparkline-area {
fill: url(#sparkGradient);
opacity: 0.3;
}
.sys-temp-big {
font-size: 28px;
font-weight: 700;
font-family: var(--font-mono, 'JetBrains Mono', monospace);
color: var(--text-primary, #e0e0ff);
margin-bottom: 4px;
}
/* Network interface rows */
.sys-net-iface {
padding: 6px 0;
border-bottom: 1px solid var(--border-color, #2a2a4a);
}
.sys-net-iface:last-child {
border-bottom: none;
}
.sys-net-iface-name {
font-weight: 700;
color: var(--accent-cyan, #00d4ff);
font-size: 11px;
}
.sys-net-iface-ip {
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 12px;
color: var(--text-primary, #e0e0ff);
}
.sys-net-iface-detail {
font-size: 10px;
color: var(--text-dim, #8888aa);
}
/* Bandwidth arrows */
.sys-bandwidth {
display: flex;
gap: 12px;
margin-top: 4px;
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 11px;
}
.sys-bw-up {
color: var(--accent-green, #00ff88);
}
.sys-bw-down {
color: var(--accent-cyan, #00d4ff);
}
/* Globe container — compact vertical layout */
.sys-location-inner {
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
}
.sys-globe-wrap {
width: 200px;
height: 200px;
flex-shrink: 0;
background: #000;
border-radius: 8px;
overflow: hidden;
position: relative;
}
.sys-location-details {
width: 100%;
display: flex;
flex-direction: column;
gap: 6px;
}
/* GPS status indicator */
.sys-gps-status {
display: flex;
align-items: center;
gap: 6px;
font-size: 10px;
color: var(--text-dim, #8888aa);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.sys-gps-dot {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
}
.sys-gps-dot.fix-3d {
background: var(--accent-green, #00ff88);
box-shadow: 0 0 4px rgba(0, 255, 136, 0.4);
}
.sys-gps-dot.fix-2d {
background: var(--accent-yellow, #ffcc00);
box-shadow: 0 0 4px rgba(255, 204, 0, 0.4);
}
.sys-gps-dot.no-fix {
background: var(--text-dim, #555);
}
.sys-location-coords {
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 13px;
color: var(--text-primary, #e0e0ff);
}
.sys-location-source {
font-size: 10px;
color: var(--text-dim, #8888aa);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Weather overlay */
.sys-weather {
margin-top: auto;
padding: 10px;
background: rgba(0, 0, 0, 0.3);
border-radius: 6px;
border: 1px solid var(--border-color, #2a2a4a);
}
.sys-weather-temp {
font-size: 24px;
font-weight: 700;
font-family: var(--font-mono, 'JetBrains Mono', monospace);
color: var(--text-primary, #e0e0ff);
}
.sys-weather-condition {
font-size: 12px;
color: var(--text-dim, #8888aa);
margin-top: 2px;
}
.sys-weather-detail {
font-size: 10px;
color: var(--text-dim, #8888aa);
margin-top: 2px;
}
/* Disk I/O indicators */
.sys-disk-io {
display: flex;
gap: 16px;
margin-top: 8px;
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 11px;
}
.sys-disk-io-read {
color: var(--accent-cyan, #00d4ff);
}
.sys-disk-io-write {
color: var(--accent-green, #00ff88);
}
/* Process grid — dot-matrix style */
.sys-process-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px 12px;
}
.sys-process-item { .sys-process-item {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -128,6 +431,12 @@
background: var(--text-dim, #555); background: var(--text-dim, #555);
} }
.sys-process-summary {
margin-top: 8px;
font-size: 11px;
color: var(--text-dim, #8888aa);
}
/* SDR Devices */ /* SDR Devices */
.sys-sdr-device { .sys-sdr-device {
padding: 6px 0; padding: 6px 0;
@@ -154,6 +463,39 @@
background: var(--bg-primary, #0d0d1a); background: var(--bg-primary, #0d0d1a);
} }
/* System info — vertical layout to fill card */
.sys-info-grid {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
color: var(--text-dim, #8888aa);
}
.sys-info-item {
display: flex;
justify-content: space-between;
padding: 2px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.sys-info-item:last-child {
border-bottom: none;
}
.sys-info-item strong {
color: var(--text-primary, #e0e0ff);
font-weight: 600;
}
/* Battery indicator */
.sys-battery-inline {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
}
/* Sidebar Quick Grid */ /* Sidebar Quick Grid */
.sys-quick-grid { .sys-quick-grid {
display: grid; display: grid;
@@ -206,10 +548,32 @@
padding: 8px; padding: 8px;
gap: 10px; gap: 10px;
} }
.sys-card-wide,
.sys-card-full {
grid-column: 1;
}
.sys-globe-wrap {
width: 100%;
height: 180px;
}
.sys-process-grid {
grid-template-columns: 1fr;
}
} }
@media (max-width: 1024px) and (min-width: 769px) { @media (max-width: 1024px) and (min-width: 769px) {
.sys-dashboard { .sys-dashboard {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
.sys-card-wide {
grid-column: span 2;
}
.sys-card-full {
grid-column: 1 / -1;
}
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1909,7 +1909,42 @@ const BtLocate = (function() {
handleDetection, handleDetection,
invalidateMap, invalidateMap,
fetchPairedIrks, fetchPairedIrks,
destroy,
}; };
/**
* Destroy — close SSE stream and clear all timers for clean mode switching.
*/
function destroy() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
if (durationTimer) {
clearInterval(durationTimer);
durationTimer = null;
}
if (mapStabilizeTimer) {
clearInterval(mapStabilizeTimer);
mapStabilizeTimer = null;
}
if (queuedDetectionTimer) {
clearTimeout(queuedDetectionTimer);
queuedDetectionTimer = null;
}
if (crosshairResetTimer) {
clearTimeout(crosshairResetTimer);
crosshairResetTimer = null;
}
if (beepTimer) {
clearInterval(beepTimer);
beepTimer = null;
}
}
})(); })();
window.BtLocate = BtLocate; window.BtLocate = BtLocate;

View File

@@ -117,13 +117,13 @@ const Meshtastic = (function() {
Settings.createTileLayer().addTo(meshMap); Settings.createTileLayer().addTo(meshMap);
Settings.registerMap(meshMap); Settings.registerMap(meshMap);
} else { } else {
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', { L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>', attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 19, maxZoom: 19,
subdomains: 'abcd', subdomains: 'abcd',
className: 'tile-layer-cyan' className: 'tile-layer-cyan'
}).addTo(meshMap); }).addTo(meshMap);
} }
// Handle resize // Handle resize
setTimeout(() => { setTimeout(() => {
@@ -401,10 +401,10 @@ const Meshtastic = (function() {
// Position is nested in the response // Position is nested in the response
const pos = info.position; const pos = info.position;
if (pos && pos.latitude !== undefined && pos.latitude !== null && pos.longitude !== undefined && pos.longitude !== null) { if (pos && pos.latitude !== undefined && pos.latitude !== null && pos.longitude !== undefined && pos.longitude !== null) {
if (posRow) posRow.style.display = 'flex'; if (posRow) posRow.style.display = 'flex';
if (posEl) posEl.textContent = `${pos.latitude.toFixed(5)}, ${pos.longitude.toFixed(5)}`; if (posEl) posEl.textContent = `${pos.latitude.toFixed(5)}, ${pos.longitude.toFixed(5)}`;
} else { } else {
if (posRow) posRow.style.display = 'none'; if (posRow) posRow.style.display = 'none';
} }
} }
@@ -2295,7 +2295,8 @@ const Meshtastic = (function() {
// Store & Forward // Store & Forward
showStoreForwardModal, showStoreForwardModal,
requestStoreForward, requestStoreForward,
closeStoreForwardModal closeStoreForwardModal,
destroy
}; };
/** /**
@@ -2306,6 +2307,13 @@ const Meshtastic = (function() {
setTimeout(() => meshMap.invalidateSize(), 100); setTimeout(() => meshMap.invalidateSize(), 100);
} }
} }
/**
* Destroy — tear down SSE, timers, and event listeners for clean mode switching.
*/
function destroy() {
stopStream();
}
})(); })();
// Initialize when DOM is ready (will be called by selectMode) // Initialize when DOM is ready (will be called by selectMode)

View File

@@ -515,6 +515,13 @@ const SpyStations = (function() {
} }
} }
/**
* Destroy — no-op placeholder for consistent lifecycle interface.
*/
function destroy() {
// SpyStations has no background timers or streams to clean up.
}
// Public API // Public API
return { return {
init, init,
@@ -524,7 +531,8 @@ const SpyStations = (function() {
showDetails, showDetails,
closeDetails, closeDetails,
showHelp, showHelp,
closeHelp closeHelp,
destroy
}; };
})(); })();

View File

@@ -858,6 +858,13 @@ const SSTVGeneral = (function() {
} }
} }
/**
* Destroy — close SSE stream and stop scope animation for clean mode switching.
*/
function destroy() {
stopStream();
}
// Public API // Public API
return { return {
init, init,
@@ -869,6 +876,7 @@ const SSTVGeneral = (function() {
deleteImage, deleteImage,
deleteAllImages, deleteAllImages,
downloadImage, downloadImage,
selectPreset selectPreset,
destroy
}; };
})(); })();

View File

@@ -12,12 +12,12 @@ const SSTV = (function() {
let progress = 0; let progress = 0;
let issMap = null; let issMap = null;
let issMarker = null; let issMarker = null;
let issTrackLine = null; let issTrackLine = null;
let issPosition = null; let issPosition = null;
let issUpdateInterval = null; let issUpdateInterval = null;
let countdownInterval = null; let countdownInterval = null;
let nextPassData = null; let nextPassData = null;
let pendingMapInvalidate = false; let pendingMapInvalidate = false;
// ISS frequency // ISS frequency
const ISS_FREQ = 145.800; const ISS_FREQ = 145.800;
@@ -38,31 +38,31 @@ const SSTV = (function() {
/** /**
* Initialize the SSTV mode * Initialize the SSTV mode
*/ */
function init() { function init() {
checkStatus(); checkStatus();
loadImages(); loadImages();
loadLocationInputs(); loadLocationInputs();
loadIssSchedule(); loadIssSchedule();
initMap(); initMap();
startIssTracking(); startIssTracking();
startCountdown(); startCountdown();
// Ensure Leaflet recomputes dimensions after the SSTV pane becomes visible. // Ensure Leaflet recomputes dimensions after the SSTV pane becomes visible.
setTimeout(() => invalidateMap(), 80); setTimeout(() => invalidateMap(), 80);
setTimeout(() => invalidateMap(), 260); setTimeout(() => invalidateMap(), 260);
} }
function isMapContainerVisible() { function isMapContainerVisible() {
if (!issMap || typeof issMap.getContainer !== 'function') return false; if (!issMap || typeof issMap.getContainer !== 'function') return false;
const container = issMap.getContainer(); const container = issMap.getContainer();
if (!container) return false; if (!container) return false;
if (container.offsetWidth <= 0 || container.offsetHeight <= 0) return false; if (container.offsetWidth <= 0 || container.offsetHeight <= 0) return false;
if (container.style && container.style.display === 'none') return false; if (container.style && container.style.display === 'none') return false;
if (typeof window.getComputedStyle === 'function') { if (typeof window.getComputedStyle === 'function') {
const style = window.getComputedStyle(container); const style = window.getComputedStyle(container);
if (style.display === 'none' || style.visibility === 'hidden') return false; if (style.display === 'none' || style.visibility === 'hidden') return false;
} }
return true; return true;
} }
/** /**
* Load location into input fields * Load location into input fields
@@ -189,9 +189,9 @@ const SSTV = (function() {
/** /**
* Initialize Leaflet map for ISS tracking * Initialize Leaflet map for ISS tracking
*/ */
async function initMap() { async function initMap() {
const mapContainer = document.getElementById('sstvIssMap'); const mapContainer = document.getElementById('sstvIssMap');
if (!mapContainer || issMap) return; if (!mapContainer || issMap) return;
// Create map // Create map
issMap = L.map('sstvIssMap', { issMap = L.map('sstvIssMap', {
@@ -231,21 +231,21 @@ const SSTV = (function() {
issMarker = L.marker([0, 0], { icon: issIcon }).addTo(issMap); issMarker = L.marker([0, 0], { icon: issIcon }).addTo(issMap);
// Create ground track line // Create ground track line
issTrackLine = L.polyline([], { issTrackLine = L.polyline([], {
color: '#00d4ff', color: '#00d4ff',
weight: 2, weight: 2,
opacity: 0.6, opacity: 0.6,
dashArray: '5, 5' dashArray: '5, 5'
}).addTo(issMap); }).addTo(issMap);
issMap.on('resize moveend zoomend', () => { issMap.on('resize moveend zoomend', () => {
if (pendingMapInvalidate) invalidateMap(); if (pendingMapInvalidate) invalidateMap();
}); });
// Initial layout passes for first-time mode load. // Initial layout passes for first-time mode load.
setTimeout(() => invalidateMap(), 40); setTimeout(() => invalidateMap(), 40);
setTimeout(() => invalidateMap(), 180); setTimeout(() => invalidateMap(), 180);
} }
/** /**
* Start ISS position tracking * Start ISS position tracking
@@ -454,9 +454,9 @@ const SSTV = (function() {
/** /**
* Update map with ISS position * Update map with ISS position
*/ */
function updateMap() { function updateMap() {
if (!issMap || !issPosition) return; if (!issMap || !issPosition) return;
if (pendingMapInvalidate) invalidateMap(); if (pendingMapInvalidate) invalidateMap();
const lat = issPosition.lat; const lat = issPosition.lat;
const lon = issPosition.lon; const lon = issPosition.lon;
@@ -516,13 +516,13 @@ const SSTV = (function() {
issTrackLine.setLatLngs(segments.length > 0 ? segments : []); issTrackLine.setLatLngs(segments.length > 0 ? segments : []);
} }
// Pan map to follow ISS only when the map pane is currently renderable. // Pan map to follow ISS only when the map pane is currently renderable.
if (isMapContainerVisible()) { if (isMapContainerVisible()) {
issMap.panTo([lat, lon], { animate: true, duration: 0.5 }); issMap.panTo([lat, lon], { animate: true, duration: 0.5 });
} else { } else {
pendingMapInvalidate = true; pendingMapInvalidate = true;
} }
} }
/** /**
* Check current decoder status * Check current decoder status
@@ -1335,27 +1335,27 @@ const SSTV = (function() {
/** /**
* Show status message * Show status message
*/ */
function showStatusMessage(message, type) { function showStatusMessage(message, type) {
if (typeof showNotification === 'function') { if (typeof showNotification === 'function') {
showNotification('SSTV', message); showNotification('SSTV', message);
} else { } else {
console.log(`[SSTV ${type}] ${message}`); console.log(`[SSTV ${type}] ${message}`);
} }
} }
/** /**
* Invalidate ISS map size after pane/layout changes. * Invalidate ISS map size after pane/layout changes.
*/ */
function invalidateMap() { function invalidateMap() {
if (!issMap) return false; if (!issMap) return false;
if (!isMapContainerVisible()) { if (!isMapContainerVisible()) {
pendingMapInvalidate = true; pendingMapInvalidate = true;
return false; return false;
} }
issMap.invalidateSize({ pan: false, animate: false }); issMap.invalidateSize({ pan: false, animate: false });
pendingMapInvalidate = false; pendingMapInvalidate = false;
return true; return true;
} }
// Public API // Public API
return { return {
@@ -1370,12 +1370,25 @@ const SSTV = (function() {
deleteAllImages, deleteAllImages,
downloadImage, downloadImage,
useGPS, useGPS,
updateTLE, updateTLE,
stopIssTracking, stopIssTracking,
stopCountdown, stopCountdown,
invalidateMap invalidateMap,
}; destroy
})(); };
/**
* Destroy — close SSE stream and clear ISS tracking/countdown timers for clean mode switching.
*/
function destroy() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
stopIssTracking();
stopCountdown();
}
})();
// Initialize when DOM is ready (will be called by selectMode) // Initialize when DOM is ready (will be called by selectMode)
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {

View File

@@ -1,8 +1,9 @@
/** /**
* System Health IIFE module * System Health Enhanced Dashboard IIFE module
* *
* Always-on monitoring that auto-connects when the mode is entered. * Streams real-time system metrics via SSE with rich visualizations:
* Streams real-time system metrics via SSE and provides SDR device enumeration. * SVG arc gauge, per-core bars, temperature sparkline, network bandwidth,
* disk I/O, 3D globe, weather, and process grid.
*/ */
const SystemHealth = (function () { const SystemHealth = (function () {
'use strict'; 'use strict';
@@ -11,19 +12,46 @@ const SystemHealth = (function () {
let connected = false; let connected = false;
let lastMetrics = null; let lastMetrics = null;
// Temperature sparkline ring buffer (last 20 readings)
const SPARKLINE_SIZE = 20;
let tempHistory = [];
// Network I/O delta tracking
let prevNetIo = null;
let prevNetTimestamp = null;
// Disk I/O delta tracking
let prevDiskIo = null;
let prevDiskTimestamp = null;
// Location & weather state
let locationData = null;
let weatherData = null;
let weatherTimer = null;
let globeInstance = null;
let globeDestroyed = false;
const GLOBE_SCRIPT_URL = 'https://cdn.jsdelivr.net/npm/globe.gl@2.33.1/dist/globe.gl.min.js';
const GLOBE_TEXTURE_URL = '/static/images/globe/earth-dark.jpg';
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Helpers // Helpers
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
function formatBytes(bytes) { function formatBytes(bytes) {
if (bytes == null) return '--'; if (bytes == null) return '--';
const units = ['B', 'KB', 'MB', 'GB', 'TB']; var units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0; var i = 0;
let val = bytes; var val = bytes;
while (val >= 1024 && i < units.length - 1) { val /= 1024; i++; } while (val >= 1024 && i < units.length - 1) { val /= 1024; i++; }
return val.toFixed(1) + ' ' + units[i]; return val.toFixed(1) + ' ' + units[i];
} }
function formatRate(bytesPerSec) {
if (bytesPerSec == null) return '--';
return formatBytes(bytesPerSec) + '/s';
}
function barClass(pct) { function barClass(pct) {
if (pct >= 85) return 'crit'; if (pct >= 85) return 'crit';
if (pct >= 60) return 'warn'; if (pct >= 60) return 'warn';
@@ -32,8 +60,8 @@ const SystemHealth = (function () {
function barHtml(pct, label) { function barHtml(pct, label) {
if (pct == null) return '<span class="sys-metric-na">N/A</span>'; if (pct == null) return '<span class="sys-metric-na">N/A</span>';
const cls = barClass(pct); var cls = barClass(pct);
const rounded = Math.round(pct); var rounded = Math.round(pct);
return '<div class="sys-metric-bar-wrap">' + return '<div class="sys-metric-bar-wrap">' +
(label ? '<span class="sys-metric-bar-label">' + label + '</span>' : '') + (label ? '<span class="sys-metric-bar-label">' + label + '</span>' : '') +
'<div class="sys-metric-bar"><div class="sys-metric-bar-fill ' + cls + '" style="width:' + rounded + '%"></div></div>' + '<div class="sys-metric-bar"><div class="sys-metric-bar-fill ' + cls + '" style="width:' + rounded + '%"></div></div>' +
@@ -41,71 +69,531 @@ const SystemHealth = (function () {
'</div>'; '</div>';
} }
function escHtml(s) {
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Rendering // SVG Arc Gauge
// -----------------------------------------------------------------------
function arcGaugeSvg(pct) {
var radius = 36;
var cx = 45, cy = 45;
var startAngle = -225;
var endAngle = 45;
var totalAngle = endAngle - startAngle; // 270 degrees
var fillAngle = startAngle + (totalAngle * Math.min(pct, 100) / 100);
function polarToCart(angle) {
var r = angle * Math.PI / 180;
return { x: cx + radius * Math.cos(r), y: cy + radius * Math.sin(r) };
}
var bgStart = polarToCart(startAngle);
var bgEnd = polarToCart(endAngle);
var fillEnd = polarToCart(fillAngle);
var largeArcBg = totalAngle > 180 ? 1 : 0;
var fillArc = (fillAngle - startAngle) > 180 ? 1 : 0;
var cls = barClass(pct);
return '<svg viewBox="0 0 90 90">' +
'<path class="arc-bg" d="M ' + bgStart.x + ' ' + bgStart.y +
' A ' + radius + ' ' + radius + ' 0 ' + largeArcBg + ' 1 ' + bgEnd.x + ' ' + bgEnd.y + '"/>' +
'<path class="arc-fill ' + cls + '" d="M ' + bgStart.x + ' ' + bgStart.y +
' A ' + radius + ' ' + radius + ' 0 ' + fillArc + ' 1 ' + fillEnd.x + ' ' + fillEnd.y + '"/>' +
'</svg>';
}
// -----------------------------------------------------------------------
// Temperature Sparkline
// -----------------------------------------------------------------------
function sparklineSvg(values) {
if (!values || values.length < 2) return '';
var w = 200, h = 40;
var min = Math.min.apply(null, values);
var max = Math.max.apply(null, values);
var range = max - min || 1;
var step = w / (values.length - 1);
var points = values.map(function (v, i) {
var x = Math.round(i * step);
var y = Math.round(h - ((v - min) / range) * (h - 4) - 2);
return x + ',' + y;
});
var areaPoints = points.join(' ') + ' ' + w + ',' + h + ' 0,' + h;
return '<svg viewBox="0 0 ' + w + ' ' + h + '" preserveAspectRatio="none">' +
'<defs><linearGradient id="sparkGradient" x1="0" y1="0" x2="0" y2="1">' +
'<stop offset="0%" stop-color="var(--accent-cyan, #00d4ff)" stop-opacity="0.3"/>' +
'<stop offset="100%" stop-color="var(--accent-cyan, #00d4ff)" stop-opacity="0.0"/>' +
'</linearGradient></defs>' +
'<polygon class="sys-sparkline-area" points="' + areaPoints + '"/>' +
'<polyline class="sys-sparkline-line" points="' + points.join(' ') + '"/>' +
'</svg>';
}
// -----------------------------------------------------------------------
// Rendering — CPU Card
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
function renderCpuCard(m) { function renderCpuCard(m) {
const el = document.getElementById('sysCardCpu'); var el = document.getElementById('sysCardCpu');
if (!el) return; if (!el) return;
const cpu = m.cpu; var cpu = m.cpu;
if (!cpu) { el.innerHTML = '<div class="sys-card-body"><span class="sys-metric-na">psutil not available</span></div>'; return; } if (!cpu) { el.innerHTML = '<div class="sys-card-body"><span class="sys-metric-na">psutil not available</span></div>'; return; }
var pct = Math.round(cpu.percent);
var coreHtml = '';
if (cpu.per_core && cpu.per_core.length) {
coreHtml = '<div class="sys-core-bars">';
cpu.per_core.forEach(function (c) {
var cls = barClass(c);
var h = Math.max(3, Math.round(c / 100 * 48));
coreHtml += '<div class="sys-core-bar"><div class="sys-core-bar-fill ' + cls +
'" style="height:' + h + 'px;background:var(--accent-' +
(cls === 'ok' ? 'green' : cls === 'warn' ? 'yellow' : 'red') +
', #00ff88)"></div></div>';
});
coreHtml += '</div>';
}
var freqHtml = '';
if (cpu.freq) {
var freqGhz = (cpu.freq.current / 1000).toFixed(2);
freqHtml = '<div class="sys-card-detail">Freq: ' + freqGhz + ' GHz</div>';
}
el.innerHTML = el.innerHTML =
'<div class="sys-card-header">CPU</div>' + '<div class="sys-card-header">CPU</div>' +
'<div class="sys-card-body">' + '<div class="sys-card-body">' +
barHtml(cpu.percent, '') + '<div class="sys-gauge-wrap">' +
'<div class="sys-gauge-arc">' + arcGaugeSvg(pct) +
'<div class="sys-gauge-label">' + pct + '%</div></div>' +
'<div class="sys-gauge-details">' +
'<div class="sys-card-detail">Load: ' + cpu.load_1 + ' / ' + cpu.load_5 + ' / ' + cpu.load_15 + '</div>' + '<div class="sys-card-detail">Load: ' + cpu.load_1 + ' / ' + cpu.load_5 + ' / ' + cpu.load_15 + '</div>' +
'<div class="sys-card-detail">Cores: ' + cpu.count + '</div>' + '<div class="sys-card-detail">Cores: ' + cpu.count + '</div>' +
freqHtml +
'</div></div>' +
coreHtml +
'</div>'; '</div>';
} }
// -----------------------------------------------------------------------
// Memory Card
// -----------------------------------------------------------------------
function renderMemoryCard(m) { function renderMemoryCard(m) {
const el = document.getElementById('sysCardMemory'); var el = document.getElementById('sysCardMemory');
if (!el) return; if (!el) return;
const mem = m.memory; var mem = m.memory;
if (!mem) { el.innerHTML = '<div class="sys-card-body"><span class="sys-metric-na">N/A</span></div>'; return; } if (!mem) { el.innerHTML = '<div class="sys-card-body"><span class="sys-metric-na">N/A</span></div>'; return; }
const swap = m.swap || {}; var swap = m.swap || {};
el.innerHTML = el.innerHTML =
'<div class="sys-card-header">Memory</div>' + '<div class="sys-card-header">Memory</div>' +
'<div class="sys-card-body">' + '<div class="sys-card-body">' +
barHtml(mem.percent, '') + barHtml(mem.percent, 'RAM') +
'<div class="sys-card-detail">' + formatBytes(mem.used) + ' / ' + formatBytes(mem.total) + '</div>' + '<div class="sys-card-detail">' + formatBytes(mem.used) + ' / ' + formatBytes(mem.total) + '</div>' +
'<div class="sys-card-detail">Swap: ' + formatBytes(swap.used) + ' / ' + formatBytes(swap.total) + '</div>' + (swap.total > 0 ? barHtml(swap.percent, 'Swap') +
'<div class="sys-card-detail">' + formatBytes(swap.used) + ' / ' + formatBytes(swap.total) + '</div>' : '') +
'</div>'; '</div>';
} }
function renderDiskCard(m) { // -----------------------------------------------------------------------
const el = document.getElementById('sysCardDisk'); // Temperature & Power Card
if (!el) return; // -----------------------------------------------------------------------
const disk = m.disk;
if (!disk) { el.innerHTML = '<div class="sys-card-body"><span class="sys-metric-na">N/A</span></div>'; return; }
el.innerHTML =
'<div class="sys-card-header">Disk</div>' +
'<div class="sys-card-body">' +
barHtml(disk.percent, '') +
'<div class="sys-card-detail">' + formatBytes(disk.used) + ' / ' + formatBytes(disk.total) + '</div>' +
'<div class="sys-card-detail">Path: ' + (disk.path || '/') + '</div>' +
'</div>';
}
function _extractPrimaryTemp(temps) { function _extractPrimaryTemp(temps) {
if (!temps) return null; if (!temps) return null;
// Prefer common chip names var preferred = ['cpu_thermal', 'coretemp', 'k10temp', 'acpitz', 'soc_thermal'];
const preferred = ['cpu_thermal', 'coretemp', 'k10temp', 'acpitz', 'soc_thermal']; for (var i = 0; i < preferred.length; i++) {
for (const name of preferred) { if (temps[preferred[i]] && temps[preferred[i]].length) return temps[preferred[i]][0];
if (temps[name] && temps[name].length) return temps[name][0];
} }
// Fall back to first available for (var key in temps) {
for (const key of Object.keys(temps)) {
if (temps[key] && temps[key].length) return temps[key][0]; if (temps[key] && temps[key].length) return temps[key][0];
} }
return null; return null;
} }
function renderSdrCard(devices) { function renderTempCard(m) {
const el = document.getElementById('sysCardSdr'); var el = document.getElementById('sysCardTemp');
if (!el) return; if (!el) return;
let html = '<div class="sys-card-header">SDR Devices <button class="sys-rescan-btn" onclick="SystemHealth.refreshSdr()">Rescan</button></div>';
var temp = _extractPrimaryTemp(m.temperatures);
var html = '<div class="sys-card-header">Temperature &amp; Power</div><div class="sys-card-body">';
if (temp) {
// Update sparkline history
tempHistory.push(temp.current);
if (tempHistory.length > SPARKLINE_SIZE) tempHistory.shift();
html += '<div class="sys-temp-big">' + Math.round(temp.current) + '&deg;C</div>';
html += '<div class="sys-sparkline-wrap">' + sparklineSvg(tempHistory) + '</div>';
// Additional sensors
if (m.temperatures) {
for (var chip in m.temperatures) {
m.temperatures[chip].forEach(function (s) {
html += '<div class="sys-card-detail">' + escHtml(s.label) + ': ' + Math.round(s.current) + '&deg;C</div>';
});
}
}
} else {
html += '<span class="sys-metric-na">No temperature sensors</span>';
}
// Fans
if (m.fans) {
for (var fChip in m.fans) {
m.fans[fChip].forEach(function (f) {
html += '<div class="sys-card-detail">Fan ' + escHtml(f.label) + ': ' + f.current + ' RPM</div>';
});
}
}
// Battery
if (m.battery) {
html += '<div class="sys-card-detail" style="margin-top:8px">' +
'Battery: ' + Math.round(m.battery.percent) + '%' +
(m.battery.plugged ? ' (plugged)' : '') + '</div>';
}
// Throttle flags (Pi)
if (m.power && m.power.throttled) {
html += '<div class="sys-card-detail" style="color:var(--accent-yellow,#ffcc00)">Throttle: 0x' + m.power.throttled + '</div>';
}
// Power draw
if (m.power && m.power.draw_watts != null) {
html += '<div class="sys-card-detail">Power: ' + m.power.draw_watts + ' W</div>';
}
html += '</div>';
el.innerHTML = html;
}
// -----------------------------------------------------------------------
// Disk Card
// -----------------------------------------------------------------------
function renderDiskCard(m) {
var el = document.getElementById('sysCardDisk');
if (!el) return;
var disk = m.disk;
if (!disk) { el.innerHTML = '<div class="sys-card-header">Disk &amp; Storage</div><div class="sys-card-body"><span class="sys-metric-na">N/A</span></div>'; return; }
var html = '<div class="sys-card-header">Disk &amp; Storage</div><div class="sys-card-body">';
html += barHtml(disk.percent, '');
html += '<div class="sys-card-detail">' + formatBytes(disk.used) + ' / ' + formatBytes(disk.total) + '</div>';
// Disk I/O rates
if (m.disk_io && prevDiskIo && prevDiskTimestamp) {
var dt = (m.timestamp - prevDiskTimestamp);
if (dt > 0) {
var readRate = (m.disk_io.read_bytes - prevDiskIo.read_bytes) / dt;
var writeRate = (m.disk_io.write_bytes - prevDiskIo.write_bytes) / dt;
var readIops = Math.round((m.disk_io.read_count - prevDiskIo.read_count) / dt);
var writeIops = Math.round((m.disk_io.write_count - prevDiskIo.write_count) / dt);
html += '<div class="sys-disk-io">' +
'<span class="sys-disk-io-read">R: ' + formatRate(Math.max(0, readRate)) + '</span>' +
'<span class="sys-disk-io-write">W: ' + formatRate(Math.max(0, writeRate)) + '</span>' +
'</div>';
html += '<div class="sys-card-detail">IOPS: ' + Math.max(0, readIops) + 'r / ' + Math.max(0, writeIops) + 'w</div>';
}
}
if (m.disk_io) {
prevDiskIo = m.disk_io;
prevDiskTimestamp = m.timestamp;
}
html += '</div>';
el.innerHTML = html;
}
// -----------------------------------------------------------------------
// Network Card
// -----------------------------------------------------------------------
function renderNetworkCard(m) {
var el = document.getElementById('sysCardNetwork');
if (!el) return;
var net = m.network;
if (!net) { el.innerHTML = '<div class="sys-card-header">Network</div><div class="sys-card-body"><span class="sys-metric-na">N/A</span></div>'; return; }
var html = '<div class="sys-card-header">Network</div><div class="sys-card-body">';
// Interfaces
var ifaces = net.interfaces || [];
if (ifaces.length === 0) {
html += '<span class="sys-metric-na">No interfaces</span>';
} else {
ifaces.forEach(function (iface) {
html += '<div class="sys-net-iface">';
html += '<div class="sys-net-iface-name">' + escHtml(iface.name) +
(iface.is_up ? '' : ' <span style="color:var(--text-dim)">(down)</span>') + '</div>';
if (iface.ipv4) html += '<div class="sys-net-iface-ip">' + escHtml(iface.ipv4) + '</div>';
var details = [];
if (iface.mac) details.push('MAC: ' + iface.mac);
if (iface.speed) details.push(iface.speed + ' Mbps');
if (details.length) html += '<div class="sys-net-iface-detail">' + escHtml(details.join(' | ')) + '</div>';
// Bandwidth for this interface
if (net.io && net.io[iface.name] && prevNetIo && prevNetIo[iface.name] && prevNetTimestamp) {
var dt = (m.timestamp - prevNetTimestamp);
if (dt > 0) {
var prev = prevNetIo[iface.name];
var cur = net.io[iface.name];
var upRate = (cur.bytes_sent - prev.bytes_sent) / dt;
var downRate = (cur.bytes_recv - prev.bytes_recv) / dt;
html += '<div class="sys-bandwidth">' +
'<span class="sys-bw-up">&uarr; ' + formatRate(Math.max(0, upRate)) + '</span>' +
'<span class="sys-bw-down">&darr; ' + formatRate(Math.max(0, downRate)) + '</span>' +
'</div>';
}
}
html += '</div>';
});
}
// Connection count
if (net.connections != null) {
html += '<div class="sys-card-detail" style="margin-top:8px">Connections: ' + net.connections + '</div>';
}
// Save for next delta
if (net.io) {
prevNetIo = net.io;
prevNetTimestamp = m.timestamp;
}
html += '</div>';
el.innerHTML = html;
}
// -----------------------------------------------------------------------
// Location & Weather Card
// -----------------------------------------------------------------------
function renderLocationCard() {
var el = document.getElementById('sysCardLocation');
if (!el) return;
// Preserve the globe DOM node if it already has a canvas
var existingGlobe = document.getElementById('sysGlobeContainer');
var savedGlobe = null;
if (existingGlobe && existingGlobe.querySelector('canvas')) {
savedGlobe = existingGlobe;
existingGlobe.parentNode.removeChild(existingGlobe);
}
var html = '<div class="sys-card-header">Location &amp; Weather</div><div class="sys-card-body">';
html += '<div class="sys-location-inner">';
// Globe placeholder (will be replaced with saved node or initialized fresh)
if (!savedGlobe) {
html += '<div class="sys-globe-wrap" id="sysGlobeContainer"></div>';
} else {
html += '<div id="sysGlobePlaceholder"></div>';
}
// Details below globe
html += '<div class="sys-location-details">';
if (locationData && locationData.lat != null) {
html += '<div class="sys-location-coords">' +
locationData.lat.toFixed(4) + '&deg;' + (locationData.lat >= 0 ? 'N' : 'S') + ', ' +
locationData.lon.toFixed(4) + '&deg;' + (locationData.lon >= 0 ? 'E' : 'W') + '</div>';
// GPS status indicator
if (locationData.source === 'gps' && locationData.gps) {
var gps = locationData.gps;
var fixLabel = gps.fix_quality === 3 ? '3D Fix' : '2D Fix';
var dotCls = gps.fix_quality === 3 ? 'fix-3d' : 'fix-2d';
html += '<div class="sys-gps-status">' +
'<span class="sys-gps-dot ' + dotCls + '"></span> ' + fixLabel;
if (gps.satellites != null) html += ' &middot; ' + gps.satellites + ' sats';
if (gps.accuracy != null) html += ' &middot; &plusmn;' + gps.accuracy + 'm';
html += '</div>';
} else {
html += '<div class="sys-location-source">Source: ' + escHtml(locationData.source || 'unknown') + '</div>';
}
} else {
html += '<div class="sys-location-coords" style="color:var(--text-dim)">No location</div>';
}
// Weather
if (weatherData && !weatherData.error) {
html += '<div class="sys-weather">';
html += '<div class="sys-weather-temp">' + (weatherData.temp_c || '--') + '&deg;C</div>';
html += '<div class="sys-weather-condition">' + escHtml(weatherData.condition || '') + '</div>';
var details = [];
if (weatherData.humidity) details.push('Humidity: ' + weatherData.humidity + '%');
if (weatherData.wind_mph) details.push('Wind: ' + weatherData.wind_mph + ' mph ' + (weatherData.wind_dir || ''));
if (weatherData.feels_like_c) details.push('Feels like: ' + weatherData.feels_like_c + '°C');
details.forEach(function (d) {
html += '<div class="sys-weather-detail">' + escHtml(d) + '</div>';
});
html += '</div>';
} else if (weatherData && weatherData.error) {
html += '<div class="sys-weather"><div class="sys-weather-condition" style="color:var(--text-dim)">Weather unavailable</div></div>';
}
html += '</div>'; // .sys-location-details
html += '</div>'; // .sys-location-inner
html += '</div>';
el.innerHTML = html;
// Re-insert saved globe or initialize fresh
if (savedGlobe) {
var placeholder = document.getElementById('sysGlobePlaceholder');
if (placeholder) placeholder.parentNode.replaceChild(savedGlobe, placeholder);
} else {
requestAnimationFrame(function () { initGlobe(); });
}
}
// -----------------------------------------------------------------------
// Globe (reuses globe.gl from GPS mode)
// -----------------------------------------------------------------------
function ensureGlobeLibrary() {
return new Promise(function (resolve, reject) {
if (typeof window.Globe === 'function') { resolve(true); return; }
// Check if script already exists
var existing = document.querySelector(
'script[data-intercept-globe-src="' + GLOBE_SCRIPT_URL + '"], ' +
'script[src="' + GLOBE_SCRIPT_URL + '"]'
);
if (existing) {
if (existing.dataset.loaded === 'true') { resolve(true); return; }
if (existing.dataset.failed === 'true') { resolve(false); return; }
existing.addEventListener('load', function () { resolve(true); }, { once: true });
existing.addEventListener('error', function () { resolve(false); }, { once: true });
return;
}
var script = document.createElement('script');
script.src = GLOBE_SCRIPT_URL;
script.async = true;
script.crossOrigin = 'anonymous';
script.dataset.interceptGlobeSrc = GLOBE_SCRIPT_URL;
script.onload = function () { script.dataset.loaded = 'true'; resolve(true); };
script.onerror = function () { script.dataset.failed = 'true'; resolve(false); };
document.head.appendChild(script);
});
}
function initGlobe() {
var container = document.getElementById('sysGlobeContainer');
if (!container || globeDestroyed) return;
// Don't reinitialize if globe canvas is still alive in this container
if (globeInstance && container.querySelector('canvas')) return;
// Clear stale reference if canvas was destroyed by innerHTML replacement
if (globeInstance && !container.querySelector('canvas')) {
globeInstance = null;
}
ensureGlobeLibrary().then(function (ready) {
if (!ready || typeof window.Globe !== 'function' || globeDestroyed) return;
// Wait for layout — container may have 0 dimensions right after
// display:none is removed by switchMode(). Use RAF retry like GPS mode.
var attempts = 0;
function tryInit() {
if (globeDestroyed) return;
container = document.getElementById('sysGlobeContainer');
if (!container) return;
if ((!container.clientWidth || !container.clientHeight) && attempts < 8) {
attempts++;
requestAnimationFrame(tryInit);
return;
}
if (!container.clientWidth || !container.clientHeight) return;
container.innerHTML = '';
container.style.background = 'radial-gradient(circle, rgba(10,20,40,0.9), rgba(2,4,8,0.98) 70%)';
try {
globeInstance = window.Globe()(container)
.backgroundColor('rgba(0,0,0,0)')
.globeImageUrl(GLOBE_TEXTURE_URL)
.showAtmosphere(true)
.atmosphereColor('#3bb9ff')
.atmosphereAltitude(0.12)
.pointsData([])
.pointRadius(0.8)
.pointAltitude(0.01)
.pointColor(function () { return '#00d4ff'; });
var controls = globeInstance.controls();
if (controls) {
controls.autoRotate = true;
controls.autoRotateSpeed = 0.5;
controls.enablePan = false;
controls.minDistance = 120;
controls.maxDistance = 300;
}
// Size the globe
globeInstance.width(container.clientWidth);
globeInstance.height(container.clientHeight);
updateGlobePosition();
} catch (e) {
// Globe.gl / WebGL init failed — show static fallback
container.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;width:100%;height:100%;color:var(--text-dim);font-size:11px;">Globe unavailable</div>';
}
}
requestAnimationFrame(tryInit);
});
}
function updateGlobePosition() {
if (!globeInstance || !locationData || locationData.lat == null) return;
// Observer point
globeInstance.pointsData([{
lat: locationData.lat,
lng: locationData.lon,
size: 0.8,
color: '#00d4ff',
}]);
// Snap view
globeInstance.pointOfView({ lat: locationData.lat, lng: locationData.lon, altitude: 2.0 }, 1000);
// Stop auto-rotate when we have a fix
var controls = globeInstance.controls();
if (controls) controls.autoRotate = false;
}
function destroyGlobe() {
globeDestroyed = true;
if (globeInstance) {
var container = document.getElementById('sysGlobeContainer');
if (container) container.innerHTML = '';
globeInstance = null;
}
}
// -----------------------------------------------------------------------
// SDR Card
// -----------------------------------------------------------------------
function renderSdrCard(devices) {
var el = document.getElementById('sysCardSdr');
if (!el) return;
var html = '<div class="sys-card-header">SDR Devices <button class="sys-rescan-btn" onclick="SystemHealth.refreshSdr()">Rescan</button></div>';
html += '<div class="sys-card-body">'; html += '<div class="sys-card-body">';
if (!devices || !devices.length) { if (!devices || !devices.length) {
html += '<span class="sys-metric-na">No devices found</span>'; html += '<span class="sys-metric-na">No devices found</span>';
@@ -113,9 +601,9 @@ const SystemHealth = (function () {
devices.forEach(function (d) { devices.forEach(function (d) {
html += '<div class="sys-sdr-device">' + html += '<div class="sys-sdr-device">' +
'<span class="sys-process-dot running"></span> ' + '<span class="sys-process-dot running"></span> ' +
'<strong>' + d.type + ' #' + d.index + '</strong>' + '<strong>' + escHtml(d.type) + ' #' + d.index + '</strong>' +
'<div class="sys-card-detail">' + (d.name || 'Unknown') + '</div>' + '<div class="sys-card-detail">' + escHtml(d.name || 'Unknown') + '</div>' +
(d.serial ? '<div class="sys-card-detail">S/N: ' + d.serial + '</div>' : '') + (d.serial ? '<div class="sys-card-detail">S/N: ' + escHtml(d.serial) + '</div>' : '') +
'</div>'; '</div>';
}); });
} }
@@ -123,93 +611,197 @@ const SystemHealth = (function () {
el.innerHTML = html; el.innerHTML = html;
} }
// -----------------------------------------------------------------------
// Process Card
// -----------------------------------------------------------------------
function renderProcessCard(m) { function renderProcessCard(m) {
const el = document.getElementById('sysCardProcesses'); var el = document.getElementById('sysCardProcesses');
if (!el) return; if (!el) return;
const procs = m.processes || {}; var procs = m.processes || {};
const keys = Object.keys(procs).sort(); var keys = Object.keys(procs).sort();
let html = '<div class="sys-card-header">Processes</div><div class="sys-card-body">'; var html = '<div class="sys-card-header">Active Processes</div><div class="sys-card-body">';
if (!keys.length) { if (!keys.length) {
html += '<span class="sys-metric-na">No data</span>'; html += '<span class="sys-metric-na">No data</span>';
} else { } else {
var running = 0, stopped = 0;
html += '<div class="sys-process-grid">';
keys.forEach(function (k) { keys.forEach(function (k) {
const running = procs[k]; var isRunning = procs[k];
const dotCls = running ? 'running' : 'stopped'; if (isRunning) running++; else stopped++;
const label = k.charAt(0).toUpperCase() + k.slice(1); var dotCls = isRunning ? 'running' : 'stopped';
var label = k.charAt(0).toUpperCase() + k.slice(1);
html += '<div class="sys-process-item">' + html += '<div class="sys-process-item">' +
'<span class="sys-process-dot ' + dotCls + '"></span> ' + '<span class="sys-process-dot ' + dotCls + '"></span> ' +
'<span class="sys-process-name">' + label + '</span>' + '<span class="sys-process-name">' + escHtml(label) + '</span>' +
'</div>'; '</div>';
}); });
html += '</div>';
html += '<div class="sys-process-summary">' + running + ' running / ' + stopped + ' idle</div>';
} }
html += '</div>'; html += '</div>';
el.innerHTML = html; el.innerHTML = html;
} }
// -----------------------------------------------------------------------
// System Info Card
// -----------------------------------------------------------------------
function renderSystemInfoCard(m) { function renderSystemInfoCard(m) {
const el = document.getElementById('sysCardInfo'); var el = document.getElementById('sysCardInfo');
if (!el) return; if (!el) return;
const sys = m.system || {}; var sys = m.system || {};
const temp = _extractPrimaryTemp(m.temperatures); var html = '<div class="sys-card-header">System Info</div><div class="sys-card-body"><div class="sys-info-grid">';
let html = '<div class="sys-card-header">System Info</div><div class="sys-card-body">';
html += '<div class="sys-card-detail">Host: ' + (sys.hostname || '--') + '</div>'; html += '<div class="sys-info-item"><strong>Host</strong><span>' + escHtml(sys.hostname || '--') + '</span></div>';
html += '<div class="sys-card-detail">OS: ' + (sys.platform || '--') + '</div>'; html += '<div class="sys-info-item"><strong>OS</strong><span>' + escHtml((sys.platform || '--').replace(/-with-glibc[\d.]+/, '')) + '</span></div>';
html += '<div class="sys-card-detail">Python: ' + (sys.python || '--') + '</div>'; html += '<div class="sys-info-item"><strong>Python</strong><span>' + escHtml(sys.python || '--') + '</span></div>';
html += '<div class="sys-card-detail">App: v' + (sys.version || '--') + '</div>'; html += '<div class="sys-info-item"><strong>App</strong><span>v' + escHtml(sys.version || '--') + '</span></div>';
html += '<div class="sys-card-detail">Uptime: ' + (sys.uptime_human || '--') + '</div>'; html += '<div class="sys-info-item"><strong>Uptime</strong><span>' + escHtml(sys.uptime_human || '--') + '</span></div>';
if (temp) {
html += '<div class="sys-card-detail">Temp: ' + Math.round(temp.current) + '&deg;C'; if (m.boot_time) {
if (temp.high) html += ' / ' + Math.round(temp.high) + '&deg;C max'; var bootDate = new Date(m.boot_time * 1000);
html += '</div>'; html += '<div class="sys-info-item"><strong>Boot</strong><span>' + escHtml(bootDate.toLocaleString()) + '</span></div>';
} }
html += '</div>';
if (m.network && m.network.connections != null) {
html += '<div class="sys-info-item"><strong>Connections</strong><span>' + m.network.connections + '</span></div>';
}
html += '</div></div>';
el.innerHTML = html; el.innerHTML = html;
} }
// -----------------------------------------------------------------------
// Sidebar Updates
// -----------------------------------------------------------------------
function updateSidebarQuickStats(m) { function updateSidebarQuickStats(m) {
const cpuEl = document.getElementById('sysQuickCpu'); var cpuEl = document.getElementById('sysQuickCpu');
const tempEl = document.getElementById('sysQuickTemp'); var tempEl = document.getElementById('sysQuickTemp');
const ramEl = document.getElementById('sysQuickRam'); var ramEl = document.getElementById('sysQuickRam');
const diskEl = document.getElementById('sysQuickDisk'); var diskEl = document.getElementById('sysQuickDisk');
if (cpuEl) cpuEl.textContent = m.cpu ? Math.round(m.cpu.percent) + '%' : '--'; if (cpuEl) cpuEl.textContent = m.cpu ? Math.round(m.cpu.percent) + '%' : '--';
if (ramEl) ramEl.textContent = m.memory ? Math.round(m.memory.percent) + '%' : '--'; if (ramEl) ramEl.textContent = m.memory ? Math.round(m.memory.percent) + '%' : '--';
if (diskEl) diskEl.textContent = m.disk ? Math.round(m.disk.percent) + '%' : '--'; if (diskEl) diskEl.textContent = m.disk ? Math.round(m.disk.percent) + '%' : '--';
const temp = _extractPrimaryTemp(m.temperatures); var temp = _extractPrimaryTemp(m.temperatures);
if (tempEl) tempEl.innerHTML = temp ? Math.round(temp.current) + '&deg;C' : '--'; if (tempEl) tempEl.innerHTML = temp ? Math.round(temp.current) + '&deg;C' : '--';
// Color-code values // Color-code values
[cpuEl, ramEl, diskEl].forEach(function (el) { [cpuEl, ramEl, diskEl].forEach(function (el) {
if (!el) return; if (!el) return;
const val = parseInt(el.textContent); var val = parseInt(el.textContent);
el.classList.remove('sys-val-ok', 'sys-val-warn', 'sys-val-crit'); el.classList.remove('sys-val-ok', 'sys-val-warn', 'sys-val-crit');
if (!isNaN(val)) el.classList.add('sys-val-' + barClass(val)); if (!isNaN(val)) el.classList.add('sys-val-' + barClass(val));
}); });
} }
function updateSidebarProcesses(m) { function updateSidebarProcesses(m) {
const el = document.getElementById('sysProcessList'); var el = document.getElementById('sysProcessList');
if (!el) return; if (!el) return;
const procs = m.processes || {}; var procs = m.processes || {};
const keys = Object.keys(procs).sort(); var keys = Object.keys(procs).sort();
if (!keys.length) { el.textContent = 'No data'; return; } if (!keys.length) { el.textContent = 'No data'; return; }
const running = keys.filter(function (k) { return procs[k]; }); var running = keys.filter(function (k) { return procs[k]; });
const stopped = keys.filter(function (k) { return !procs[k]; }); var stopped = keys.filter(function (k) { return !procs[k]; });
el.innerHTML = el.innerHTML =
(running.length ? '<span style="color: var(--accent-green, #00ff88);">' + running.length + ' running</span>' : '') + (running.length ? '<span style="color: var(--accent-green, #00ff88);">' + running.length + ' running</span>' : '') +
(running.length && stopped.length ? ' &middot; ' : '') + (running.length && stopped.length ? ' &middot; ' : '') +
(stopped.length ? '<span style="color: var(--text-dim);">' + stopped.length + ' stopped</span>' : ''); (stopped.length ? '<span style="color: var(--text-dim);">' + stopped.length + ' stopped</span>' : '');
} }
function updateSidebarNetwork(m) {
var el = document.getElementById('sysQuickNet');
if (!el || !m.network) return;
var ifaces = m.network.interfaces || [];
var ips = [];
ifaces.forEach(function (iface) {
if (iface.ipv4 && iface.is_up) {
ips.push(iface.name + ': ' + iface.ipv4);
}
});
el.textContent = ips.length ? ips.join(', ') : '--';
}
function updateSidebarBattery(m) {
var section = document.getElementById('sysQuickBatterySection');
var el = document.getElementById('sysQuickBattery');
if (!section || !el) return;
if (m.battery) {
section.style.display = '';
el.textContent = Math.round(m.battery.percent) + '%' + (m.battery.plugged ? ' (plugged)' : '');
} else {
section.style.display = 'none';
}
}
function updateSidebarLocation() {
var el = document.getElementById('sysQuickLocation');
if (!el) return;
if (locationData && locationData.lat != null) {
el.textContent = locationData.lat.toFixed(4) + ', ' + locationData.lon.toFixed(4) + ' (' + locationData.source + ')';
} else {
el.textContent = 'No location';
}
}
// -----------------------------------------------------------------------
// Render all
// -----------------------------------------------------------------------
function renderAll(m) { function renderAll(m) {
renderCpuCard(m); renderCpuCard(m);
renderMemoryCard(m); renderMemoryCard(m);
renderTempCard(m);
renderDiskCard(m); renderDiskCard(m);
renderNetworkCard(m);
renderProcessCard(m); renderProcessCard(m);
renderSystemInfoCard(m); renderSystemInfoCard(m);
updateSidebarQuickStats(m); updateSidebarQuickStats(m);
updateSidebarProcesses(m); updateSidebarProcesses(m);
updateSidebarNetwork(m);
updateSidebarBattery(m);
}
// -----------------------------------------------------------------------
// Location & Weather Fetching
// -----------------------------------------------------------------------
function fetchLocation() {
fetch('/system/location')
.then(function (r) { return r.json(); })
.then(function (data) {
// If server only has default/none, check client-side saved location
if ((data.source === 'default' || data.source === 'none') &&
window.ObserverLocation && ObserverLocation.getShared) {
var shared = ObserverLocation.getShared();
if (shared && shared.lat && shared.lon) {
data.lat = shared.lat;
data.lon = shared.lon;
data.source = 'manual';
}
}
locationData = data;
updateSidebarLocation();
renderLocationCard();
if (data.lat != null) fetchWeather();
})
.catch(function () {
renderLocationCard();
});
}
function fetchWeather() {
if (!locationData || locationData.lat == null) return;
fetch('/system/weather?lat=' + locationData.lat + '&lon=' + locationData.lon)
.then(function (r) { return r.json(); })
.then(function (data) {
weatherData = data;
renderLocationCard();
})
.catch(function () {});
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -267,7 +859,7 @@ const SystemHealth = (function () {
var html = ''; var html = '';
devices.forEach(function (d) { devices.forEach(function (d) {
html += '<div style="margin-bottom: 4px;"><span class="sys-process-dot running"></span> ' + html += '<div style="margin-bottom: 4px;"><span class="sys-process-dot running"></span> ' +
d.type + ' #' + d.index + ' &mdash; ' + (d.name || 'Unknown') + '</div>'; escHtml(d.type) + ' #' + d.index + ' &mdash; ' + escHtml(d.name || 'Unknown') + '</div>';
}); });
sidebarEl.innerHTML = html; sidebarEl.innerHTML = html;
} }
@@ -284,12 +876,24 @@ const SystemHealth = (function () {
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
function init() { function init() {
globeDestroyed = false;
connect(); connect();
refreshSdr(); refreshSdr();
fetchLocation();
// Refresh weather every 10 minutes
weatherTimer = setInterval(function () {
fetchWeather();
}, 600000);
} }
function destroy() { function destroy() {
disconnect(); disconnect();
destroyGlobe();
if (weatherTimer) {
clearInterval(weatherTimer);
weatherTimer = null;
}
} }
return { return {

View File

@@ -1005,6 +1005,15 @@ function escapeHtmlWebsdr(str) {
// ============== EXPORTS ============== // ============== EXPORTS ==============
/**
* Destroy — disconnect audio and clear S-meter timer for clean mode switching.
*/
function destroyWebSDR() {
disconnectFromReceiver();
}
const WebSDR = { destroy: destroyWebSDR };
window.initWebSDR = initWebSDR; window.initWebSDR = initWebSDR;
window.searchReceivers = searchReceivers; window.searchReceivers = searchReceivers;
window.selectReceiver = selectReceiver; window.selectReceiver = selectReceiver;
@@ -1015,3 +1024,4 @@ window.disconnectFromReceiver = disconnectFromReceiver;
window.tuneKiwi = tuneKiwi; window.tuneKiwi = tuneKiwi;
window.tuneFromBar = tuneFromBar; window.tuneFromBar = tuneFromBar;
window.setKiwiVolume = setKiwiVolume; window.setKiwiVolume = setKiwiVolume;
window.WebSDR = WebSDR;

View File

@@ -28,9 +28,9 @@ const WiFiMode = (function() {
maxProbes: 1000, maxProbes: 1000,
}; };
// ========================================================================== // ==========================================================================
// Agent Support // Agent Support
// ========================================================================== // ==========================================================================
/** /**
* Get the API base URL, routing through agent proxy if agent is selected. * Get the API base URL, routing through agent proxy if agent is selected.
@@ -59,49 +59,49 @@ const WiFiMode = (function() {
/** /**
* Check for agent mode conflicts before starting WiFi scan. * Check for agent mode conflicts before starting WiFi scan.
*/ */
function checkAgentConflicts() { function checkAgentConflicts() {
if (typeof currentAgent === 'undefined' || currentAgent === 'local') { if (typeof currentAgent === 'undefined' || currentAgent === 'local') {
return true; return true;
} }
if (typeof checkAgentModeConflict === 'function') { if (typeof checkAgentModeConflict === 'function') {
return checkAgentModeConflict('wifi'); return checkAgentModeConflict('wifi');
} }
return true; return true;
} }
function getChannelPresetList(preset) { function getChannelPresetList(preset) {
switch (preset) { switch (preset) {
case '2.4-common': case '2.4-common':
return '1,6,11'; return '1,6,11';
case '2.4-all': case '2.4-all':
return '1,2,3,4,5,6,7,8,9,10,11,12,13'; return '1,2,3,4,5,6,7,8,9,10,11,12,13';
case '5-low': case '5-low':
return '36,40,44,48'; return '36,40,44,48';
case '5-mid': case '5-mid':
return '52,56,60,64'; return '52,56,60,64';
case '5-high': case '5-high':
return '149,153,157,161,165'; return '149,153,157,161,165';
default: default:
return ''; return '';
} }
} }
function buildChannelConfig() { function buildChannelConfig() {
const preset = document.getElementById('wifiChannelPreset')?.value || ''; const preset = document.getElementById('wifiChannelPreset')?.value || '';
const listInput = document.getElementById('wifiChannelList')?.value || ''; const listInput = document.getElementById('wifiChannelList')?.value || '';
const singleInput = document.getElementById('wifiChannel')?.value || ''; const singleInput = document.getElementById('wifiChannel')?.value || '';
const listValue = listInput.trim(); const listValue = listInput.trim();
const presetValue = getChannelPresetList(preset); const presetValue = getChannelPresetList(preset);
const channels = listValue || presetValue || ''; const channels = listValue || presetValue || '';
const channel = channels ? null : (singleInput.trim() ? parseInt(singleInput.trim()) : null); const channel = channels ? null : (singleInput.trim() ? parseInt(singleInput.trim()) : null);
return { return {
channels: channels || null, channels: channels || null,
channel: Number.isFinite(channel) ? channel : null, channel: Number.isFinite(channel) ? channel : null,
}; };
} }
// ========================================================================== // ==========================================================================
// State // State
@@ -120,23 +120,23 @@ const WiFiMode = (function() {
let channelStats = []; let channelStats = [];
let recommendations = []; let recommendations = [];
// UI state // UI state
let selectedNetwork = null; let selectedNetwork = null;
let currentFilter = 'all'; let currentFilter = 'all';
let currentSort = { field: 'rssi', order: 'desc' }; let currentSort = { field: 'rssi', order: 'desc' };
let renderFramePending = false; let renderFramePending = false;
const pendingRender = { const pendingRender = {
table: false, table: false,
stats: false, stats: false,
radar: false, radar: false,
chart: false, chart: false,
detail: false, detail: false,
}; };
const listenersBound = { const listenersBound = {
scanTabs: false, scanTabs: false,
filters: false, filters: false,
sort: false, sort: false,
}; };
// Agent state // Agent state
let showAllAgentsMode = false; // Show combined results from all agents let showAllAgentsMode = false; // Show combined results from all agents
@@ -165,11 +165,11 @@ const WiFiMode = (function() {
// Initialize components // Initialize components
initScanModeTabs(); initScanModeTabs();
initNetworkFilters(); initNetworkFilters();
initSortControls(); initSortControls();
initProximityRadar(); initProximityRadar();
initChannelChart(); initChannelChart();
scheduleRender({ table: true, stats: true, radar: true, chart: true }); scheduleRender({ table: true, stats: true, radar: true, chart: true });
// Check if already scanning // Check if already scanning
checkScanStatus(); checkScanStatus();
@@ -378,16 +378,16 @@ const WiFiMode = (function() {
// Scan Mode Tabs // Scan Mode Tabs
// ========================================================================== // ==========================================================================
function initScanModeTabs() { function initScanModeTabs() {
if (listenersBound.scanTabs) return; if (listenersBound.scanTabs) return;
if (elements.scanModeQuick) { if (elements.scanModeQuick) {
elements.scanModeQuick.addEventListener('click', () => setScanMode('quick')); elements.scanModeQuick.addEventListener('click', () => setScanMode('quick'));
} }
if (elements.scanModeDeep) { if (elements.scanModeDeep) {
elements.scanModeDeep.addEventListener('click', () => setScanMode('deep')); elements.scanModeDeep.addEventListener('click', () => setScanMode('deep'));
} }
listenersBound.scanTabs = true; listenersBound.scanTabs = true;
} }
function setScanMode(mode) { function setScanMode(mode) {
scanMode = mode; scanMode = mode;
@@ -511,10 +511,10 @@ const WiFiMode = (function() {
setScanning(true, 'deep'); setScanning(true, 'deep');
try { try {
const iface = elements.interfaceSelect?.value || null; const iface = elements.interfaceSelect?.value || null;
const band = document.getElementById('wifiBand')?.value || 'all'; const band = document.getElementById('wifiBand')?.value || 'all';
const channelConfig = buildChannelConfig(); const channelConfig = buildChannelConfig();
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
let response; let response;
if (isAgentMode) { if (isAgentMode) {
@@ -523,25 +523,25 @@ const WiFiMode = (function() {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
interface: iface, interface: iface,
scan_type: 'deep', scan_type: 'deep',
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5', band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
channel: channelConfig.channel, channel: channelConfig.channel,
channels: channelConfig.channels, channels: channelConfig.channels,
}), }),
}); });
} else { } else {
response = await fetch(`${CONFIG.apiBase}/scan/start`, { response = await fetch(`${CONFIG.apiBase}/scan/start`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
interface: iface, interface: iface,
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5', band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
channel: channelConfig.channel, channel: channelConfig.channel,
channels: channelConfig.channels, channels: channelConfig.channels,
}), }),
}); });
} }
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
@@ -572,8 +572,8 @@ const WiFiMode = (function() {
} }
} }
async function stopScan() { async function stopScan() {
console.log('[WiFiMode] Stopping scan...'); console.log('[WiFiMode] Stopping scan...');
// Stop polling // Stop polling
if (pollTimer) { if (pollTimer) {
@@ -585,41 +585,41 @@ const WiFiMode = (function() {
stopAgentDeepScanPolling(); stopAgentDeepScanPolling();
// Close event stream // Close event stream
if (eventSource) { if (eventSource) {
eventSource.close(); eventSource.close();
eventSource = null; eventSource = null;
} }
// Update UI immediately so mode transitions are responsive even if the // Update UI immediately so mode transitions are responsive even if the
// backend needs extra time to terminate subprocesses. // backend needs extra time to terminate subprocesses.
setScanning(false); setScanning(false);
// Stop scan on server (local or agent) // Stop scan on server (local or agent)
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
const timeoutMs = isAgentMode ? 8000 : 2200; const timeoutMs = isAgentMode ? 8000 : 2200;
const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null; const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null; const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
try { try {
if (isAgentMode) { if (isAgentMode) {
await fetch(`/controller/agents/${currentAgent}/wifi/stop`, { await fetch(`/controller/agents/${currentAgent}/wifi/stop`, {
method: 'POST', method: 'POST',
...(controller ? { signal: controller.signal } : {}), ...(controller ? { signal: controller.signal } : {}),
}); });
} else if (scanMode === 'deep') { } else if (scanMode === 'deep') {
await fetch(`${CONFIG.apiBase}/scan/stop`, { await fetch(`${CONFIG.apiBase}/scan/stop`, {
method: 'POST', method: 'POST',
...(controller ? { signal: controller.signal } : {}), ...(controller ? { signal: controller.signal } : {}),
}); });
} }
} catch (error) { } catch (error) {
console.warn('[WiFiMode] Error stopping scan:', error); console.warn('[WiFiMode] Error stopping scan:', error);
} finally { } finally {
if (timeoutId) { if (timeoutId) {
clearTimeout(timeoutId); clearTimeout(timeoutId);
} }
} }
} }
function setScanning(scanning, mode = null) { function setScanning(scanning, mode = null) {
isScanning = scanning; isScanning = scanning;
@@ -713,10 +713,10 @@ const WiFiMode = (function() {
}, CONFIG.pollInterval); }, CONFIG.pollInterval);
} }
function processQuickScanResult(result) { function processQuickScanResult(result) {
// Update networks // Update networks
result.access_points.forEach(ap => { result.access_points.forEach(ap => {
networks.set(ap.bssid, ap); networks.set(ap.bssid, ap);
}); });
// Update channel stats (calculate from networks if not provided by API) // Update channel stats (calculate from networks if not provided by API)
@@ -724,12 +724,12 @@ const WiFiMode = (function() {
recommendations = result.recommendations || []; recommendations = result.recommendations || [];
// If no channel stats from API, calculate from networks // If no channel stats from API, calculate from networks
if (channelStats.length === 0 && networks.size > 0) { if (channelStats.length === 0 && networks.size > 0) {
channelStats = calculateChannelStats(); channelStats = calculateChannelStats();
} }
// Update UI // Update UI
scheduleRender({ table: true, stats: true, radar: true, chart: true }); scheduleRender({ table: true, stats: true, radar: true, chart: true });
// Callbacks // Callbacks
result.access_points.forEach(ap => { result.access_points.forEach(ap => {
@@ -938,25 +938,25 @@ const WiFiMode = (function() {
} }
} }
function handleNetworkUpdate(network) { function handleNetworkUpdate(network) {
networks.set(network.bssid, network); networks.set(network.bssid, network);
scheduleRender({ scheduleRender({
table: true, table: true,
stats: true, stats: true,
radar: true, radar: true,
chart: true, chart: true,
detail: selectedNetwork === network.bssid, detail: selectedNetwork === network.bssid,
}); });
if (onNetworkUpdate) onNetworkUpdate(network); if (onNetworkUpdate) onNetworkUpdate(network);
} }
function handleClientUpdate(client) { function handleClientUpdate(client) {
clients.set(client.mac, client); clients.set(client.mac, client);
scheduleRender({ stats: true }); scheduleRender({ stats: true });
// Update client display if this client belongs to the selected network // Update client display if this client belongs to the selected network
updateClientInList(client); updateClientInList(client);
if (onClientUpdate) onClientUpdate(client); if (onClientUpdate) onClientUpdate(client);
} }
@@ -970,37 +970,37 @@ const WiFiMode = (function() {
if (onProbeRequest) onProbeRequest(probe); if (onProbeRequest) onProbeRequest(probe);
} }
function handleHiddenRevealed(bssid, revealedSsid) { function handleHiddenRevealed(bssid, revealedSsid) {
const network = networks.get(bssid); const network = networks.get(bssid);
if (network) { if (network) {
network.revealed_essid = revealedSsid; network.revealed_essid = revealedSsid;
network.display_name = `${revealedSsid} (revealed)`; network.display_name = `${revealedSsid} (revealed)`;
scheduleRender({ scheduleRender({
table: true, table: true,
detail: selectedNetwork === bssid, detail: selectedNetwork === bssid,
}); });
// Show notification // Show notification
showInfo(`Hidden SSID revealed: ${revealedSsid}`); showInfo(`Hidden SSID revealed: ${revealedSsid}`);
} }
} }
// ========================================================================== // ==========================================================================
// Network Table // Network Table
// ========================================================================== // ==========================================================================
function initNetworkFilters() { function initNetworkFilters() {
if (listenersBound.filters) return; if (listenersBound.filters) return;
if (!elements.networkFilters) return; if (!elements.networkFilters) return;
elements.networkFilters.addEventListener('click', (e) => { elements.networkFilters.addEventListener('click', (e) => {
if (e.target.matches('.wifi-filter-btn')) { if (e.target.matches('.wifi-filter-btn')) {
const filter = e.target.dataset.filter; const filter = e.target.dataset.filter;
setNetworkFilter(filter); setNetworkFilter(filter);
} }
}); });
listenersBound.filters = true; listenersBound.filters = true;
} }
function setNetworkFilter(filter) { function setNetworkFilter(filter) {
currentFilter = filter; currentFilter = filter;
@@ -1015,11 +1015,11 @@ const WiFiMode = (function() {
updateNetworkTable(); updateNetworkTable();
} }
function initSortControls() { function initSortControls() {
if (listenersBound.sort) return; if (listenersBound.sort) return;
if (!elements.networkTable) return; if (!elements.networkTable) return;
elements.networkTable.addEventListener('click', (e) => { elements.networkTable.addEventListener('click', (e) => {
const th = e.target.closest('th[data-sort]'); const th = e.target.closest('th[data-sort]');
if (th) { if (th) {
const field = th.dataset.sort; const field = th.dataset.sort;
@@ -1029,54 +1029,54 @@ const WiFiMode = (function() {
currentSort.field = field; currentSort.field = field;
currentSort.order = 'desc'; currentSort.order = 'desc';
} }
updateNetworkTable(); updateNetworkTable();
} }
}); });
if (elements.networkTableBody) { if (elements.networkTableBody) {
elements.networkTableBody.addEventListener('click', (e) => { elements.networkTableBody.addEventListener('click', (e) => {
const row = e.target.closest('tr[data-bssid]'); const row = e.target.closest('tr[data-bssid]');
if (!row) return; if (!row) return;
selectNetwork(row.dataset.bssid); selectNetwork(row.dataset.bssid);
}); });
} }
listenersBound.sort = true; listenersBound.sort = true;
} }
function scheduleRender(flags = {}) { function scheduleRender(flags = {}) {
pendingRender.table = pendingRender.table || Boolean(flags.table); pendingRender.table = pendingRender.table || Boolean(flags.table);
pendingRender.stats = pendingRender.stats || Boolean(flags.stats); pendingRender.stats = pendingRender.stats || Boolean(flags.stats);
pendingRender.radar = pendingRender.radar || Boolean(flags.radar); pendingRender.radar = pendingRender.radar || Boolean(flags.radar);
pendingRender.chart = pendingRender.chart || Boolean(flags.chart); pendingRender.chart = pendingRender.chart || Boolean(flags.chart);
pendingRender.detail = pendingRender.detail || Boolean(flags.detail); pendingRender.detail = pendingRender.detail || Boolean(flags.detail);
if (renderFramePending) return; if (renderFramePending) return;
renderFramePending = true; renderFramePending = true;
requestAnimationFrame(() => { requestAnimationFrame(() => {
renderFramePending = false; renderFramePending = false;
if (pendingRender.table) updateNetworkTable(); if (pendingRender.table) updateNetworkTable();
if (pendingRender.stats) updateStats(); if (pendingRender.stats) updateStats();
if (pendingRender.radar) updateProximityRadar(); if (pendingRender.radar) updateProximityRadar();
if (pendingRender.chart) updateChannelChart(); if (pendingRender.chart) updateChannelChart();
if (pendingRender.detail && selectedNetwork) { if (pendingRender.detail && selectedNetwork) {
updateDetailPanel(selectedNetwork, { refreshClients: false }); updateDetailPanel(selectedNetwork, { refreshClients: false });
} }
pendingRender.table = false; pendingRender.table = false;
pendingRender.stats = false; pendingRender.stats = false;
pendingRender.radar = false; pendingRender.radar = false;
pendingRender.chart = false; pendingRender.chart = false;
pendingRender.detail = false; pendingRender.detail = false;
}); });
} }
function updateNetworkTable() { function updateNetworkTable() {
if (!elements.networkTableBody) return; if (!elements.networkTableBody) return;
// Filter networks // Filter networks
let filtered = Array.from(networks.values()); let filtered = Array.from(networks.values());
switch (currentFilter) { switch (currentFilter) {
case 'hidden': case 'hidden':
@@ -1126,44 +1126,44 @@ const WiFiMode = (function() {
return bVal > aVal ? 1 : bVal < aVal ? -1 : 0; return bVal > aVal ? 1 : bVal < aVal ? -1 : 0;
} else { } else {
return aVal > bVal ? 1 : aVal < bVal ? -1 : 0; return aVal > bVal ? 1 : aVal < bVal ? -1 : 0;
} }
}); });
if (filtered.length === 0) {
let message = 'Start scanning to discover networks';
let type = 'empty';
if (isScanning) {
message = 'Scanning for networks...';
type = 'loading';
} else if (networks.size > 0) {
message = 'No networks match current filters';
}
if (typeof renderCollectionState === 'function') {
renderCollectionState(elements.networkTableBody, {
type,
message,
columns: 7,
});
} else {
elements.networkTableBody.innerHTML = `<tr class="wifi-network-placeholder"><td colspan="7"><div class="placeholder-text">${escapeHtml(message)}</div></td></tr>`;
}
return;
}
// Render table
elements.networkTableBody.innerHTML = filtered.map(n => createNetworkRow(n)).join('');
}
function createNetworkRow(network) { if (filtered.length === 0) {
const rssi = network.rssi_current; let message = 'Start scanning to discover networks';
const security = network.security || 'Unknown'; let type = 'empty';
const signalClass = rssi >= -50 ? 'signal-strong' : if (isScanning) {
rssi >= -70 ? 'signal-medium' : message = 'Scanning for networks...';
rssi >= -85 ? 'signal-weak' : 'signal-very-weak'; type = 'loading';
} else if (networks.size > 0) {
const securityClass = security === 'Open' ? 'security-open' : message = 'No networks match current filters';
security === 'WEP' ? 'security-wep' : }
security.includes('WPA3') ? 'security-wpa3' : 'security-wpa'; if (typeof renderCollectionState === 'function') {
renderCollectionState(elements.networkTableBody, {
type,
message,
columns: 7,
});
} else {
elements.networkTableBody.innerHTML = `<tr class="wifi-network-placeholder"><td colspan="7"><div class="placeholder-text">${escapeHtml(message)}</div></td></tr>`;
}
return;
}
// Render table
elements.networkTableBody.innerHTML = filtered.map(n => createNetworkRow(n)).join('');
}
function createNetworkRow(network) {
const rssi = network.rssi_current;
const security = network.security || 'Unknown';
const signalClass = rssi >= -50 ? 'signal-strong' :
rssi >= -70 ? 'signal-medium' :
rssi >= -85 ? 'signal-weak' : 'signal-very-weak';
const securityClass = security === 'Open' ? 'security-open' :
security === 'WEP' ? 'security-wep' :
security.includes('WPA3') ? 'security-wpa3' : 'security-wpa';
const hiddenBadge = network.is_hidden ? '<span class="badge badge-hidden">Hidden</span>' : ''; const hiddenBadge = network.is_hidden ? '<span class="badge badge-hidden">Hidden</span>' : '';
const newBadge = network.is_new ? '<span class="badge badge-new">New</span>' : ''; const newBadge = network.is_new ? '<span class="badge badge-new">New</span>' : '';
@@ -1172,25 +1172,25 @@ const WiFiMode = (function() {
const agentName = network._agent || 'Local'; const agentName = network._agent || 'Local';
const agentClass = agentName === 'Local' ? 'agent-local' : 'agent-remote'; const agentClass = agentName === 'Local' ? 'agent-local' : 'agent-remote';
return ` return `
<tr class="wifi-network-row ${network.bssid === selectedNetwork ? 'selected' : ''}" <tr class="wifi-network-row ${network.bssid === selectedNetwork ? 'selected' : ''}"
data-bssid="${escapeHtml(network.bssid)}" data-bssid="${escapeHtml(network.bssid)}"
role="button" role="button"
tabindex="0" tabindex="0"
data-keyboard-activate="true" data-keyboard-activate="true"
aria-label="Select network ${escapeHtml(network.display_name || network.essid || '[Hidden]')}"> aria-label="Select network ${escapeHtml(network.display_name || network.essid || '[Hidden]')}">
<td class="col-essid"> <td class="col-essid">
<span class="essid">${escapeHtml(network.display_name || network.essid || '[Hidden]')}</span> <span class="essid">${escapeHtml(network.display_name || network.essid || '[Hidden]')}</span>
${hiddenBadge}${newBadge} ${hiddenBadge}${newBadge}
</td> </td>
<td class="col-bssid"><code>${escapeHtml(network.bssid)}</code></td> <td class="col-bssid"><code>${escapeHtml(network.bssid)}</code></td>
<td class="col-channel">${network.channel || '-'}</td> <td class="col-channel">${network.channel || '-'}</td>
<td class="col-rssi"> <td class="col-rssi">
<span class="rssi-value ${signalClass}">${rssi != null ? rssi : '-'}</span> <span class="rssi-value ${signalClass}">${rssi != null ? rssi : '-'}</span>
</td> </td>
<td class="col-security"> <td class="col-security">
<span class="security-badge ${securityClass}">${escapeHtml(security)}</span> <span class="security-badge ${securityClass}">${escapeHtml(security)}</span>
</td> </td>
<td class="col-clients">${network.client_count || 0}</td> <td class="col-clients">${network.client_count || 0}</td>
<td class="col-agent"> <td class="col-agent">
<span class="agent-badge ${agentClass}">${escapeHtml(agentName)}</span> <span class="agent-badge ${agentClass}">${escapeHtml(agentName)}</span>
@@ -1199,12 +1199,12 @@ const WiFiMode = (function() {
`; `;
} }
function updateNetworkRow(network) { function updateNetworkRow(network) {
scheduleRender({ scheduleRender({
table: true, table: true,
detail: selectedNetwork === network.bssid, detail: selectedNetwork === network.bssid,
}); });
} }
function selectNetwork(bssid) { function selectNetwork(bssid) {
selectedNetwork = bssid; selectedNetwork = bssid;
@@ -1227,9 +1227,9 @@ const WiFiMode = (function() {
// Detail Panel // Detail Panel
// ========================================================================== // ==========================================================================
function updateDetailPanel(bssid, options = {}) { function updateDetailPanel(bssid, options = {}) {
const { refreshClients = true } = options; const { refreshClients = true } = options;
if (!elements.detailDrawer) return; if (!elements.detailDrawer) return;
const network = networks.get(bssid); const network = networks.get(bssid);
if (!network) { if (!network) {
@@ -1274,11 +1274,11 @@ const WiFiMode = (function() {
// Show the drawer // Show the drawer
elements.detailDrawer.classList.add('open'); elements.detailDrawer.classList.add('open');
// Fetch and display clients for this network // Fetch and display clients for this network
if (refreshClients) { if (refreshClients) {
fetchClientsForNetwork(network.bssid); fetchClientsForNetwork(network.bssid);
} }
} }
function closeDetail() { function closeDetail() {
selectedNetwork = null; selectedNetwork = null;
@@ -1294,18 +1294,18 @@ const WiFiMode = (function() {
// Client Display // Client Display
// ========================================================================== // ==========================================================================
async function fetchClientsForNetwork(bssid) { async function fetchClientsForNetwork(bssid) {
if (!elements.detailClientList) return; if (!elements.detailClientList) return;
const listContainer = elements.detailClientList.querySelector('.wifi-client-list'); const listContainer = elements.detailClientList.querySelector('.wifi-client-list');
if (listContainer && typeof renderCollectionState === 'function') { if (listContainer && typeof renderCollectionState === 'function') {
renderCollectionState(listContainer, { type: 'loading', message: 'Loading clients...' }); renderCollectionState(listContainer, { type: 'loading', message: 'Loading clients...' });
elements.detailClientList.style.display = 'block'; elements.detailClientList.style.display = 'block';
} }
try { try {
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
let response; let response;
if (isAgentMode) { if (isAgentMode) {
// Route through agent proxy // Route through agent proxy
@@ -1314,44 +1314,44 @@ const WiFiMode = (function() {
response = await fetch(`${CONFIG.apiBase}/clients?bssid=${encodeURIComponent(bssid)}&associated=true`); response = await fetch(`${CONFIG.apiBase}/clients?bssid=${encodeURIComponent(bssid)}&associated=true`);
} }
if (!response.ok) { if (!response.ok) {
if (listContainer && typeof renderCollectionState === 'function') { if (listContainer && typeof renderCollectionState === 'function') {
renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' }); renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' });
elements.detailClientList.style.display = 'block'; elements.detailClientList.style.display = 'block';
} else { } else {
elements.detailClientList.style.display = 'none'; elements.detailClientList.style.display = 'none';
} }
return; return;
} }
const data = await response.json(); const data = await response.json();
// Handle agent response format (may be nested in 'result') // Handle agent response format (may be nested in 'result')
const result = isAgentMode && data.result ? data.result : data; const result = isAgentMode && data.result ? data.result : data;
const clientList = result.clients || []; const clientList = result.clients || [];
if (clientList.length > 0) { if (clientList.length > 0) {
renderClientList(clientList, bssid); renderClientList(clientList, bssid);
elements.detailClientList.style.display = 'block'; elements.detailClientList.style.display = 'block';
} else { } else {
const countBadge = document.getElementById('wifiClientCountBadge'); const countBadge = document.getElementById('wifiClientCountBadge');
if (countBadge) countBadge.textContent = '0'; if (countBadge) countBadge.textContent = '0';
if (listContainer && typeof renderCollectionState === 'function') { if (listContainer && typeof renderCollectionState === 'function') {
renderCollectionState(listContainer, { type: 'empty', message: 'No associated clients' }); renderCollectionState(listContainer, { type: 'empty', message: 'No associated clients' });
elements.detailClientList.style.display = 'block'; elements.detailClientList.style.display = 'block';
} else { } else {
elements.detailClientList.style.display = 'none'; elements.detailClientList.style.display = 'none';
} }
} }
} catch (error) { } catch (error) {
console.debug('[WiFiMode] Error fetching clients:', error); console.debug('[WiFiMode] Error fetching clients:', error);
if (listContainer && typeof renderCollectionState === 'function') { if (listContainer && typeof renderCollectionState === 'function') {
renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' }); renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' });
elements.detailClientList.style.display = 'block'; elements.detailClientList.style.display = 'block';
} else { } else {
elements.detailClientList.style.display = 'none'; elements.detailClientList.style.display = 'none';
} }
} }
} }
function renderClientList(clientList, bssid) { function renderClientList(clientList, bssid) {
const container = elements.detailClientList?.querySelector('.wifi-client-list'); const container = elements.detailClientList?.querySelector('.wifi-client-list');
@@ -1708,16 +1708,16 @@ const WiFiMode = (function() {
/** /**
* Clear all collected data. * Clear all collected data.
*/ */
function clearData() { function clearData() {
networks.clear(); networks.clear();
clients.clear(); clients.clear();
probeRequests = []; probeRequests = [];
channelStats = []; channelStats = [];
recommendations = []; recommendations = [];
if (selectedNetwork) { if (selectedNetwork) {
closeDetail(); closeDetail();
} }
scheduleRender({ table: true, stats: true, radar: true, chart: true }); scheduleRender({ table: true, stats: true, radar: true, chart: true });
} }
/** /**
@@ -1763,12 +1763,12 @@ const WiFiMode = (function() {
clientsToRemove.push(mac); clientsToRemove.push(mac);
} }
}); });
clientsToRemove.forEach(mac => clients.delete(mac)); clientsToRemove.forEach(mac => clients.delete(mac));
if (selectedNetwork && !networks.has(selectedNetwork)) { if (selectedNetwork && !networks.has(selectedNetwork)) {
closeDetail(); closeDetail();
} }
scheduleRender({ table: true, stats: true, radar: true, chart: true }); scheduleRender({ table: true, stats: true, radar: true, chart: true });
} }
/** /**
* Refresh WiFi interfaces from current agent. * Refresh WiFi interfaces from current agent.
@@ -1811,7 +1811,28 @@ const WiFiMode = (function() {
onNetworkUpdate: (cb) => { onNetworkUpdate = cb; }, onNetworkUpdate: (cb) => { onNetworkUpdate = cb; },
onClientUpdate: (cb) => { onClientUpdate = cb; }, onClientUpdate: (cb) => { onClientUpdate = cb; },
onProbeRequest: (cb) => { onProbeRequest = cb; }, onProbeRequest: (cb) => { onProbeRequest = cb; },
// Lifecycle
destroy,
}; };
/**
* Destroy — close SSE stream and clear polling timers for clean mode switching.
*/
function destroy() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
if (agentPollTimer) {
clearInterval(agentPollTimer);
agentPollTimer = null;
}
}
})(); })();
// Auto-initialize when DOM is ready // Auto-initialize when DOM is ready

View File

@@ -1762,31 +1762,37 @@ ACARS: ${r.statistics.acarsMessages} messages`;
airbandSelect.innerHTML = ''; airbandSelect.innerHTML = '';
if (devices.length === 0) { if (devices.length === 0) {
adsbSelect.innerHTML = '<option value="0">No SDR found</option>'; adsbSelect.innerHTML = '<option value="rtlsdr:0">No SDR found</option>';
airbandSelect.innerHTML = '<option value="0">No SDR found</option>'; airbandSelect.innerHTML = '<option value="rtlsdr:0">No SDR found</option>';
airbandSelect.disabled = true; airbandSelect.disabled = true;
} else { } else {
devices.forEach((dev, i) => { devices.forEach((dev, i) => {
const idx = dev.index !== undefined ? dev.index : i; const idx = dev.index !== undefined ? dev.index : i;
const sdrType = dev.sdr_type || 'rtlsdr';
const compositeVal = `${sdrType}:${idx}`;
const displayName = `SDR ${idx}: ${dev.name}`; const displayName = `SDR ${idx}: ${dev.name}`;
// Add to ADS-B selector // Add to ADS-B selector
const adsbOpt = document.createElement('option'); const adsbOpt = document.createElement('option');
adsbOpt.value = idx; adsbOpt.value = compositeVal;
adsbOpt.dataset.sdrType = sdrType;
adsbOpt.dataset.index = idx;
adsbOpt.textContent = displayName; adsbOpt.textContent = displayName;
adsbSelect.appendChild(adsbOpt); adsbSelect.appendChild(adsbOpt);
// Add to Airband selector // Add to Airband selector
const airbandOpt = document.createElement('option'); const airbandOpt = document.createElement('option');
airbandOpt.value = idx; airbandOpt.value = compositeVal;
airbandOpt.dataset.sdrType = sdrType;
airbandOpt.dataset.index = idx;
airbandOpt.textContent = displayName; airbandOpt.textContent = displayName;
airbandSelect.appendChild(airbandOpt); airbandSelect.appendChild(airbandOpt);
}); });
// Default: ADS-B uses first device, Airband uses second (if available) // Default: ADS-B uses first device, Airband uses second (if available)
adsbSelect.value = devices[0].index !== undefined ? devices[0].index : 0; adsbSelect.value = adsbSelect.options[0]?.value || 'rtlsdr:0';
if (devices.length > 1) { if (devices.length > 1) {
airbandSelect.value = devices[1].index !== undefined ? devices[1].index : 1; airbandSelect.value = airbandSelect.options[1]?.value || airbandSelect.options[0]?.value || 'rtlsdr:0';
} }
// Show warning if only one device // Show warning if only one device
@@ -1797,8 +1803,8 @@ ACARS: ${r.statistics.acarsMessages} messages`;
} }
}) })
.catch(() => { .catch(() => {
document.getElementById('adsbDeviceSelect').innerHTML = '<option value="0">Error</option>'; document.getElementById('adsbDeviceSelect').innerHTML = '<option value="rtlsdr:0">Error</option>';
document.getElementById('airbandDeviceSelect').innerHTML = '<option value="0">Error</option>'; document.getElementById('airbandDeviceSelect').innerHTML = '<option value="rtlsdr:0">Error</option>';
}); });
} }
@@ -2161,11 +2167,14 @@ sudo make install</code>
} }
} }
// Get selected ADS-B device // Get selected ADS-B device (composite value "sdr_type:index")
const adsbDevice = parseInt(document.getElementById('adsbDeviceSelect').value) || 0; const adsbSelectVal = document.getElementById('adsbDeviceSelect').value || 'rtlsdr:0';
const [adsbSdrType, adsbDeviceIdx] = adsbSelectVal.includes(':') ? adsbSelectVal.split(':') : ['rtlsdr', adsbSelectVal];
const adsbDevice = parseInt(adsbDeviceIdx) || 0;
const requestBody = { const requestBody = {
device: adsbDevice, device: adsbDevice,
sdr_type: adsbSdrType,
bias_t: getBiasTEnabled() bias_t: getBiasTEnabled()
}; };
if (remoteConfig) { if (remoteConfig) {
@@ -2316,11 +2325,13 @@ sudo make install</code>
} }
const sessionDevice = session.device_index; const sessionDevice = session.device_index;
const sessionSdrType = session.sdr_type || 'rtlsdr';
if (sessionDevice !== null && sessionDevice !== undefined) { if (sessionDevice !== null && sessionDevice !== undefined) {
adsbActiveDevice = sessionDevice; adsbActiveDevice = sessionDevice;
const adsbSelect = document.getElementById('adsbDeviceSelect'); const adsbSelect = document.getElementById('adsbDeviceSelect');
if (adsbSelect) { if (adsbSelect) {
adsbSelect.value = sessionDevice; // Use composite value to select the correct device+type
adsbSelect.value = `${sessionSdrType}:${sessionDevice}`;
} }
} }
@@ -3834,8 +3845,9 @@ sudo make install</code>
function startAcars() { function startAcars() {
const acarsSelect = document.getElementById('acarsDeviceSelect'); const acarsSelect = document.getElementById('acarsDeviceSelect');
const device = acarsSelect.value; const compositeVal = acarsSelect.value || 'rtlsdr:0';
const sdr_type = acarsSelect.selectedOptions[0]?.dataset.sdrType || 'rtlsdr'; const [sdr_type, deviceIdx] = compositeVal.includes(':') ? compositeVal.split(':') : ['rtlsdr', compositeVal];
const device = deviceIdx;
const frequencies = getAcarsRegionFreqs(); const frequencies = getAcarsRegionFreqs();
// Check if using agent mode // Check if using agent mode
@@ -4179,13 +4191,16 @@ sudo make install</code>
const select = document.getElementById('acarsDeviceSelect'); const select = document.getElementById('acarsDeviceSelect');
select.innerHTML = ''; select.innerHTML = '';
if (devices.length === 0) { if (devices.length === 0) {
select.innerHTML = '<option value="0">No SDR detected</option>'; select.innerHTML = '<option value="rtlsdr:0">No SDR detected</option>';
} else { } else {
devices.forEach((d, i) => { devices.forEach((d, i) => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = d.index || i; const sdrType = d.sdr_type || 'rtlsdr';
opt.dataset.sdrType = d.sdr_type || 'rtlsdr'; const idx = d.index !== undefined ? d.index : i;
opt.textContent = `SDR ${d.index || i}: ${d.name || d.type || 'SDR'}`; opt.value = `${sdrType}:${idx}`;
opt.dataset.sdrType = sdrType;
opt.dataset.index = idx;
opt.textContent = `SDR ${idx}: ${d.name || d.type || 'SDR'}`;
select.appendChild(opt); select.appendChild(opt);
}); });
} }
@@ -4277,8 +4292,9 @@ sudo make install</code>
function startVdl2() { function startVdl2() {
const vdl2Select = document.getElementById('vdl2DeviceSelect'); const vdl2Select = document.getElementById('vdl2DeviceSelect');
const device = vdl2Select.value; const compositeVal = vdl2Select.value || 'rtlsdr:0';
const sdr_type = vdl2Select.selectedOptions[0]?.dataset.sdrType || 'rtlsdr'; const [sdr_type, deviceIdx] = compositeVal.includes(':') ? compositeVal.split(':') : ['rtlsdr', compositeVal];
const device = deviceIdx;
const frequencies = getVdl2RegionFreqs(); const frequencies = getVdl2RegionFreqs();
// Check if using agent mode // Check if using agent mode
@@ -4723,13 +4739,16 @@ sudo make install</code>
const select = document.getElementById('vdl2DeviceSelect'); const select = document.getElementById('vdl2DeviceSelect');
select.innerHTML = ''; select.innerHTML = '';
if (devices.length === 0) { if (devices.length === 0) {
select.innerHTML = '<option value="0">No SDR detected</option>'; select.innerHTML = '<option value="rtlsdr:0">No SDR detected</option>';
} else { } else {
devices.forEach((d, i) => { devices.forEach((d, i) => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = d.index || i; const sdrType = d.sdr_type || 'rtlsdr';
opt.dataset.sdrType = d.sdr_type || 'rtlsdr'; const idx = d.index !== undefined ? d.index : i;
opt.textContent = `SDR ${d.index || i}: ${d.name || d.type || 'SDR'}`; opt.value = `${sdrType}:${idx}`;
opt.dataset.sdrType = sdrType;
opt.dataset.index = idx;
opt.textContent = `SDR ${idx}: ${d.name || d.type || 'SDR'}`;
select.appendChild(opt); select.appendChild(opt);
}); });
} }
@@ -5715,13 +5734,16 @@ sudo make install</code>
select.innerHTML = ''; select.innerHTML = '';
if (devices.length === 0) { if (devices.length === 0) {
select.innerHTML = '<option value="0">No SDR found</option>'; select.innerHTML = '<option value="rtlsdr:0">No SDR found</option>';
} else { } else {
devices.forEach(device => { devices.forEach(device => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = device.index; const sdrType = device.sdr_type || 'rtlsdr';
opt.dataset.sdrType = device.sdr_type || 'rtlsdr'; const idx = device.index !== undefined ? device.index : 0;
opt.textContent = `SDR ${device.index}: ${device.name || device.type || 'SDR'}`; opt.value = `${sdrType}:${idx}`;
opt.dataset.sdrType = sdrType;
opt.dataset.index = idx;
opt.textContent = `SDR ${idx}: ${device.name || device.type || 'SDR'}`;
select.appendChild(opt); select.appendChild(opt);
}); });
} }

View File

@@ -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,9 +3134,17 @@
</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">
<!-- Row 1: COMPUTE -->
<div class="sys-group-header">Compute</div>
<div class="sys-card" id="sysCardCpu"> <div class="sys-card" id="sysCardCpu">
<div class="sys-card-header">CPU</div> <div class="sys-card-header">CPU</div>
<div class="sys-card-body"><span class="sys-metric-na">Connecting&hellip;</span></div> <div class="sys-card-body"><span class="sys-metric-na">Connecting&hellip;</span></div>
@@ -3138,8 +3153,30 @@
<div class="sys-card-header">Memory</div> <div class="sys-card-header">Memory</div>
<div class="sys-card-body"><span class="sys-metric-na">Connecting&hellip;</span></div> <div class="sys-card-body"><span class="sys-metric-na">Connecting&hellip;</span></div>
</div> </div>
<div class="sys-card" id="sysCardTemp">
<div class="sys-card-header">Temperature &amp; Power</div>
<div class="sys-card-body"><span class="sys-metric-na">Connecting&hellip;</span></div>
</div>
<!-- Row 2: NETWORK & LOCATION -->
<div class="sys-group-header">Network &amp; Location</div>
<div class="sys-card" id="sysCardNetwork">
<div class="sys-card-header">Network</div>
<div class="sys-card-body"><span class="sys-metric-na">Connecting&hellip;</span></div>
</div>
<div class="sys-card" id="sysCardLocation">
<div class="sys-card-header">Location &amp; Weather</div>
<div class="sys-card-body"><span class="sys-metric-na">Loading&hellip;</span></div>
</div>
<div class="sys-card" id="sysCardInfo">
<div class="sys-card-header">System Info</div>
<div class="sys-card-body"><span class="sys-metric-na">Connecting&hellip;</span></div>
</div>
<!-- Row 3: EQUIPMENT & OPERATIONS -->
<div class="sys-group-header">Equipment &amp; Operations</div>
<div class="sys-card" id="sysCardDisk"> <div class="sys-card" id="sysCardDisk">
<div class="sys-card-header">Disk</div> <div class="sys-card-header">Disk &amp; Storage</div>
<div class="sys-card-body"><span class="sys-metric-na">Connecting&hellip;</span></div> <div class="sys-card-body"><span class="sys-metric-na">Connecting&hellip;</span></div>
</div> </div>
<div class="sys-card" id="sysCardSdr"> <div class="sys-card" id="sysCardSdr">
@@ -3147,11 +3184,7 @@
<div class="sys-card-body"><span class="sys-metric-na">Scanning&hellip;</span></div> <div class="sys-card-body"><span class="sys-metric-na">Scanning&hellip;</span></div>
</div> </div>
<div class="sys-card" id="sysCardProcesses"> <div class="sys-card" id="sysCardProcesses">
<div class="sys-card-header">Processes</div> <div class="sys-card-header">Active Processes</div>
<div class="sys-card-body"><span class="sys-metric-na">Connecting&hellip;</span></div>
</div>
<div class="sys-card" id="sysCardInfo">
<div class="sys-card-header">System Info</div>
<div class="sys-card-body"><span class="sys-metric-na">Connecting&hellip;</span></div> <div class="sys-card-body"><span class="sys-metric-na">Connecting&hellip;</span></div>
</div> </div>
</div> </div>
@@ -3367,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' },
@@ -4106,12 +4140,27 @@
const stopPhaseMs = Math.round(performance.now() - stopPhaseStartMs); const stopPhaseMs = Math.round(performance.now() - stopPhaseStartMs);
await styleReadyPromise; await styleReadyPromise;
// Clean up SubGHz SSE connection when leaving the mode // Generic module cleanup — destroy previous mode's timers, SSE, etc.
if (typeof SubGhz !== 'undefined' && currentMode === 'subghz' && mode !== 'subghz') { const moduleDestroyMap = {
SubGhz.destroy(); subghz: () => typeof SubGhz !== 'undefined' && SubGhz.destroy(),
} morse: () => typeof MorseMode !== 'undefined' && MorseMode.destroy?.(),
if (typeof MorseMode !== 'undefined' && currentMode === 'morse' && mode !== 'morse' && typeof MorseMode.destroy === 'function') { spaceweather: () => typeof SpaceWeather !== 'undefined' && SpaceWeather.destroy?.(),
MorseMode.destroy(); weathersat: () => typeof WeatherSat !== 'undefined' && WeatherSat.suspend?.(),
wefax: () => typeof WeFax !== 'undefined' && WeFax.destroy?.(),
system: () => typeof SystemHealth !== 'undefined' && SystemHealth.destroy?.(),
waterfall: () => typeof Waterfall !== 'undefined' && Waterfall.destroy?.(),
gps: () => typeof GPS !== 'undefined' && GPS.destroy?.(),
meshtastic: () => typeof Meshtastic !== 'undefined' && Meshtastic.destroy?.(),
bluetooth: () => typeof BluetoothMode !== 'undefined' && BluetoothMode.destroy?.(),
wifi: () => typeof WiFiMode !== 'undefined' && WiFiMode.destroy?.(),
bt_locate: () => typeof BtLocate !== 'undefined' && BtLocate.destroy?.(),
sstv: () => typeof SSTV !== 'undefined' && SSTV.destroy?.(),
sstv_general: () => typeof SSTVGeneral !== 'undefined' && SSTVGeneral.destroy?.(),
websdr: () => typeof WebSDR !== 'undefined' && WebSDR.destroy?.(),
spystations: () => typeof SpyStations !== 'undefined' && SpyStations.destroy?.(),
};
if (previousMode && previousMode !== mode && moduleDestroyMap[previousMode]) {
try { moduleDestroyMap[previousMode](); } catch(e) { console.warn(`[switchMode] destroy ${previousMode} failed:`, e); }
} }
currentMode = mode; currentMode = mode;
@@ -4155,6 +4204,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');
@@ -4204,6 +4254,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';
@@ -4222,6 +4273,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.
@@ -4264,25 +4316,7 @@
refreshTscmDevices(); refreshTscmDevices();
} }
// Initialize/destroy Space Weather mode // Module destroy is now handled by moduleDestroyMap above.
if (mode !== 'spaceweather') {
if (typeof SpaceWeather !== 'undefined' && SpaceWeather.destroy) SpaceWeather.destroy();
}
// Suspend Weather Satellite background timers/streams when leaving the mode
if (mode !== 'weathersat') {
if (typeof WeatherSat !== 'undefined' && WeatherSat.suspend) WeatherSat.suspend();
}
// Suspend WeFax background streams when leaving the mode
if (mode !== 'wefax') {
if (typeof WeFax !== 'undefined' && WeFax.destroy) WeFax.destroy();
}
// Disconnect System Health SSE when leaving the mode
if (mode !== 'system') {
if (typeof SystemHealth !== 'undefined' && SystemHealth.destroy) SystemHealth.destroy();
}
// Show/hide Device Intelligence for modes that use it (not for satellite/aircraft/tscm) // Show/hide Device Intelligence for modes that use it (not for satellite/aircraft/tscm)
const reconBtn = document.getElementById('reconBtn'); const reconBtn = document.getElementById('reconBtn');
@@ -4309,7 +4343,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;
@@ -4414,14 +4448,16 @@
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();
} }
// Destroy Waterfall WebSocket when leaving SDR receiver modes // Waterfall destroy is now handled by moduleDestroyMap above.
if (mode !== 'waterfall' && typeof Waterfall !== 'undefined' && Waterfall.destroy) {
Promise.resolve(Waterfall.destroy()).catch(() => {});
}
const totalMs = Math.round(performance.now() - switchStartMs); const totalMs = Math.round(performance.now() - switchStartMs);
console.info( console.info(
@@ -5626,37 +5662,41 @@
let currentDeviceList = []; let currentDeviceList = [];
// SDR Device Usage Tracking // SDR Device Usage Tracking
// Tracks which mode is using which device index // Tracks which mode is using which device (keyed by "sdr_type:index")
const sdrDeviceUsage = { const sdrDeviceUsage = {
// deviceIndex: 'modeName' (e.g., 0: 'pager', 1: 'scanner') // "sdr_type:index": 'modeName' (e.g., "rtlsdr:0": 'pager', "hackrf:0": 'scanner')
}; };
function getDeviceInUseBy(deviceIndex) { function getDeviceInUseBy(deviceIndex, sdrType) {
return sdrDeviceUsage[deviceIndex] || null; const key = `${sdrType || getSelectedSDRType()}:${deviceIndex}`;
return sdrDeviceUsage[key] || null;
} }
function isDeviceInUse(deviceIndex) { function isDeviceInUse(deviceIndex, sdrType) {
return sdrDeviceUsage[deviceIndex] !== undefined; const key = `${sdrType || getSelectedSDRType()}:${deviceIndex}`;
return sdrDeviceUsage[key] !== undefined;
} }
function reserveDevice(deviceIndex, modeName) { function reserveDevice(deviceIndex, modeName, sdrType) {
sdrDeviceUsage[deviceIndex] = modeName; const key = `${sdrType || getSelectedSDRType()}:${deviceIndex}`;
sdrDeviceUsage[key] = modeName;
updateDeviceSelectStatus(); updateDeviceSelectStatus();
} }
function releaseDevice(modeName) { function releaseDevice(modeName) {
for (const [idx, mode] of Object.entries(sdrDeviceUsage)) { for (const [key, mode] of Object.entries(sdrDeviceUsage)) {
if (mode === modeName) { if (mode === modeName) {
delete sdrDeviceUsage[idx]; delete sdrDeviceUsage[key];
} }
} }
updateDeviceSelectStatus(); updateDeviceSelectStatus();
} }
function getAvailableDevice() { function getAvailableDevice() {
// Find first device not in use // Find first device not in use (within selected SDR type)
const sdrType = getSelectedSDRType();
for (const device of currentDeviceList) { for (const device of currentDeviceList) {
if (!isDeviceInUse(device.index)) { if ((device.sdr_type || 'rtlsdr') === sdrType && !isDeviceInUse(device.index, sdrType)) {
return device.index; return device.index;
} }
} }
@@ -5668,10 +5708,11 @@
const select = document.getElementById('deviceSelect'); const select = document.getElementById('deviceSelect');
if (!select) return; if (!select) return;
const sdrType = getSelectedSDRType();
const options = select.querySelectorAll('option'); const options = select.querySelectorAll('option');
options.forEach(opt => { options.forEach(opt => {
const idx = parseInt(opt.value); const idx = parseInt(opt.value);
const usedBy = getDeviceInUseBy(idx); const usedBy = getDeviceInUseBy(idx, sdrType);
const baseName = opt.textContent.replace(/ \[.*\]$/, ''); // Remove existing status const baseName = opt.textContent.replace(/ \[.*\]$/, ''); // Remove existing status
if (usedBy) { if (usedBy) {
opt.textContent = `${baseName} [${usedBy.toUpperCase()}]`; opt.textContent = `${baseName} [${usedBy.toUpperCase()}]`;

View File

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

View File

@@ -31,6 +31,24 @@
</div> </div>
</div> </div>
<!-- Network & Location -->
<div class="section">
<h3>Network</h3>
<div id="sysQuickNet" style="font-size: 11px; color: var(--text-dim);">--</div>
</div>
<!-- Battery (shown only when available) -->
<div class="section" id="sysQuickBatterySection" style="display: none;">
<h3>Battery</h3>
<div id="sysQuickBattery" style="font-size: 11px; color: var(--text-dim);">--</div>
</div>
<!-- Location -->
<div class="section">
<h3>Location</h3>
<div id="sysQuickLocation" style="font-size: 11px; color: var(--text-dim);">--</div>
</div>
<!-- SDR Devices --> <!-- SDR Devices -->
<div class="section"> <div class="section">
<h3>SDR Devices</h3> <h3>SDR Devices</h3>

View File

@@ -84,6 +84,7 @@
{{ mode_item('ais', 'Vessels', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg>', '/ais/dashboard') }} {{ mode_item('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>

View File

@@ -257,8 +257,8 @@ class TestMorseLifecycleRoutes:
released_devices = [] released_devices = []
monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode: None) monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode, sdr_type='rtlsdr': None)
monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx: released_devices.append(idx)) monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx, sdr_type='rtlsdr': released_devices.append(idx))
class DummyDevice: class DummyDevice:
sdr_type = morse_routes.SDRType.RTL_SDR sdr_type = morse_routes.SDRType.RTL_SDR
@@ -337,8 +337,8 @@ class TestMorseLifecycleRoutes:
released_devices = [] released_devices = []
monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode: None) monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode, sdr_type='rtlsdr': None)
monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx: released_devices.append(idx)) monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx, sdr_type='rtlsdr': released_devices.append(idx))
class DummyDevice: class DummyDevice:
sdr_type = morse_routes.SDRType.RTL_SDR sdr_type = morse_routes.SDRType.RTL_SDR
@@ -421,8 +421,8 @@ class TestMorseLifecycleRoutes:
released_devices = [] released_devices = []
monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode: None) monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode, sdr_type='rtlsdr': None)
monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx: released_devices.append(idx)) monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx, sdr_type='rtlsdr': released_devices.append(idx))
class DummyDevice: class DummyDevice:
def __init__(self, index: int): def __init__(self, index: int):

View File

@@ -30,6 +30,32 @@ def test_metrics_returns_expected_keys(client):
assert 'uptime_human' in data['system'] assert 'uptime_human' in data['system']
def test_metrics_enhanced_keys(client):
"""GET /system/metrics returns enhanced metric keys."""
_login(client)
resp = client.get('/system/metrics')
assert resp.status_code == 200
data = resp.get_json()
# New enhanced keys
assert 'network' in data
assert 'disk_io' in data
assert 'boot_time' in data
assert 'battery' in data
assert 'fans' in data
assert 'power' in data
# CPU should have per_core and freq
if data['cpu'] is not None:
assert 'per_core' in data['cpu']
assert 'freq' in data['cpu']
# Network should have interfaces and connections
if data['network'] is not None:
assert 'interfaces' in data['network']
assert 'connections' in data['network']
assert 'io' in data['network']
def test_metrics_without_psutil(client): def test_metrics_without_psutil(client):
"""Metrics degrade gracefully when psutil is unavailable.""" """Metrics degrade gracefully when psutil is unavailable."""
_login(client) _login(client)
@@ -45,6 +71,11 @@ def test_metrics_without_psutil(client):
assert data['cpu'] is None assert data['cpu'] is None
assert data['memory'] is None assert data['memory'] is None
assert data['disk'] is None assert data['disk'] is None
assert data['network'] is None
assert data['disk_io'] is None
assert data['battery'] is None
assert data['boot_time'] is None
assert data['power'] is None
finally: finally:
mod._HAS_PSUTIL = orig mod._HAS_PSUTIL = orig
@@ -87,3 +118,113 @@ def test_stream_returns_sse_content_type(client):
resp = client.get('/system/stream') resp = client.get('/system/stream')
assert resp.status_code == 200 assert resp.status_code == 200
assert 'text/event-stream' in resp.content_type assert 'text/event-stream' in resp.content_type
def test_location_returns_shape(client):
"""GET /system/location returns lat/lon/source shape."""
_login(client)
resp = client.get('/system/location')
assert resp.status_code == 200
data = resp.get_json()
assert 'lat' in data
assert 'lon' in data
assert 'source' in data
def test_location_from_gps(client):
"""Location endpoint returns GPS data when fix available."""
_login(client)
mock_pos = MagicMock()
mock_pos.fix_quality = 3
mock_pos.latitude = 51.5074
mock_pos.longitude = -0.1278
mock_pos.satellites = 12
mock_pos.epx = 2.5
mock_pos.epy = 3.1
mock_pos.altitude = 45.0
with patch('routes.system.get_current_position', return_value=mock_pos, create=True):
# Patch the import inside the function
import routes.system as mod
original = mod._get_observer_location
def _patched():
with patch('utils.gps.get_current_position', return_value=mock_pos):
return original()
mod._get_observer_location = _patched
try:
resp = client.get('/system/location')
finally:
mod._get_observer_location = original
assert resp.status_code == 200
data = resp.get_json()
assert data['source'] == 'gps'
assert data['lat'] == 51.5074
assert data['lon'] == -0.1278
assert data['gps']['fix_quality'] == 3
assert data['gps']['satellites'] == 12
assert data['gps']['accuracy'] == 3.1
assert data['gps']['altitude'] == 45.0
def test_location_falls_back_to_defaults(client):
"""Location endpoint returns constants defaults when GPS and config unavailable."""
_login(client)
resp = client.get('/system/location')
assert resp.status_code == 200
data = resp.get_json()
assert 'source' in data
# Should get location from config or default constants
assert data['lat'] is not None
assert data['lon'] is not None
assert data['source'] in ('config', 'default')
def test_weather_requires_location(client):
"""Weather endpoint returns error when no location available."""
_login(client)
# Without lat/lon params and no GPS state or config
resp = client.get('/system/weather')
assert resp.status_code == 200
data = resp.get_json()
# Either returns weather or error (depending on config)
assert 'error' in data or 'temp_c' in data
def test_weather_with_mocked_response(client):
"""Weather endpoint returns parsed weather data with mocked HTTP."""
_login(client)
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = {
'current_condition': [{
'temp_C': '22',
'temp_F': '72',
'weatherDesc': [{'value': 'Clear'}],
'humidity': '45',
'windspeedMiles': '8',
'winddir16Point': 'NW',
'FeelsLikeC': '20',
'visibility': '10',
'pressure': '1013',
}]
}
mock_resp.raise_for_status = MagicMock()
import routes.system as mod
# Clear cache
mod._weather_cache.clear()
mod._weather_cache_time = 0.0
with patch('routes.system._requests') as mock_requests:
mock_requests.get.return_value = mock_resp
resp = client.get('/system/weather?lat=40.7&lon=-74.0')
assert resp.status_code == 200
data = resp.get_json()
assert data['temp_c'] == '22'
assert data['condition'] == 'Clear'
assert data['humidity'] == '45'
assert data['wind_mph'] == '8'

View File

@@ -54,72 +54,72 @@ class TestWeFaxStations:
from utils.wefax_stations import get_station from utils.wefax_stations import get_station
assert get_station('noj') is not None assert get_station('noj') is not None
def test_get_station_not_found(self): def test_get_station_not_found(self):
"""get_station() should return None for unknown callsign.""" """get_station() should return None for unknown callsign."""
from utils.wefax_stations import get_station from utils.wefax_stations import get_station
assert get_station('XXXXX') is None assert get_station('XXXXX') is None
def test_resolve_tuning_frequency_auto_uses_carrier_for_known_station(self): def test_resolve_tuning_frequency_auto_uses_carrier_for_known_station(self):
"""Known station frequencies default to carrier-list behavior in auto mode.""" """Known station frequencies default to carrier-list behavior in auto mode."""
from utils.wefax_stations import resolve_tuning_frequency_khz from utils.wefax_stations import resolve_tuning_frequency_khz
tuned, reference, offset_applied = resolve_tuning_frequency_khz( tuned, reference, offset_applied = resolve_tuning_frequency_khz(
listed_frequency_khz=4298.0, listed_frequency_khz=4298.0,
station_callsign='NOJ', station_callsign='NOJ',
frequency_reference='auto', frequency_reference='auto',
) )
assert math.isclose(tuned, 4296.1, abs_tol=1e-6) assert math.isclose(tuned, 4296.1, abs_tol=1e-6)
assert reference == 'carrier' assert reference == 'carrier'
assert offset_applied is True assert offset_applied is True
def test_resolve_tuning_frequency_auto_preserves_unknown_station_input(self): def test_resolve_tuning_frequency_auto_preserves_unknown_station_input(self):
"""Ad-hoc frequencies (no station metadata) should be treated as dial.""" """Ad-hoc frequencies (no station metadata) should be treated as dial."""
from utils.wefax_stations import resolve_tuning_frequency_khz from utils.wefax_stations import resolve_tuning_frequency_khz
tuned, reference, offset_applied = resolve_tuning_frequency_khz( tuned, reference, offset_applied = resolve_tuning_frequency_khz(
listed_frequency_khz=4298.0, listed_frequency_khz=4298.0,
station_callsign='', station_callsign='',
frequency_reference='auto', frequency_reference='auto',
) )
assert math.isclose(tuned, 4298.0, abs_tol=1e-6) assert math.isclose(tuned, 4298.0, abs_tol=1e-6)
assert reference == 'dial' assert reference == 'dial'
assert offset_applied is False assert offset_applied is False
def test_resolve_tuning_frequency_dial_override(self): def test_resolve_tuning_frequency_dial_override(self):
"""Explicit dial reference must bypass USB alignment.""" """Explicit dial reference must bypass USB alignment."""
from utils.wefax_stations import resolve_tuning_frequency_khz from utils.wefax_stations import resolve_tuning_frequency_khz
tuned, reference, offset_applied = resolve_tuning_frequency_khz( tuned, reference, offset_applied = resolve_tuning_frequency_khz(
listed_frequency_khz=4298.0, listed_frequency_khz=4298.0,
station_callsign='NOJ', station_callsign='NOJ',
frequency_reference='dial', frequency_reference='dial',
) )
assert math.isclose(tuned, 4298.0, abs_tol=1e-6) assert math.isclose(tuned, 4298.0, abs_tol=1e-6)
assert reference == 'dial' assert reference == 'dial'
assert offset_applied is False assert offset_applied is False
def test_resolve_tuning_frequency_rejects_invalid_reference(self): def test_resolve_tuning_frequency_rejects_invalid_reference(self):
"""Invalid frequency reference values should raise a validation error.""" """Invalid frequency reference values should raise a validation error."""
from utils.wefax_stations import resolve_tuning_frequency_khz from utils.wefax_stations import resolve_tuning_frequency_khz
try: try:
resolve_tuning_frequency_khz( resolve_tuning_frequency_khz(
listed_frequency_khz=4298.0, listed_frequency_khz=4298.0,
station_callsign='NOJ', station_callsign='NOJ',
frequency_reference='invalid', frequency_reference='invalid',
) )
assert False, "Expected ValueError for invalid frequency_reference" assert False, "Expected ValueError for invalid frequency_reference"
except ValueError as exc: except ValueError as exc:
assert 'frequency_reference' in str(exc) assert 'frequency_reference' in str(exc)
def test_station_frequencies_have_khz(self): def test_station_frequencies_have_khz(self):
"""Each frequency entry must have 'khz' and 'description'.""" """Each frequency entry must have 'khz' and 'description'."""
from utils.wefax_stations import load_stations from utils.wefax_stations import load_stations
for station in load_stations(): for station in load_stations():
for freq in station['frequencies']: for freq in station['frequencies']:
assert 'khz' in freq, f"{station['callsign']} missing khz" assert 'khz' in freq, f"{station['callsign']} missing khz"
assert 'description' in freq, f"{station['callsign']} missing description" assert 'description' in freq, f"{station['callsign']} missing description"
assert isinstance(freq['khz'], (int, float)) assert isinstance(freq['khz'], (int, float))
@@ -281,7 +281,7 @@ class TestWeFaxDecoder:
# Route tests # Route tests
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestWeFaxRoutes: class TestWeFaxRoutes:
"""WeFax route endpoint tests.""" """WeFax route endpoint tests."""
def test_status(self, client): def test_status(self, client):
@@ -390,11 +390,11 @@ class TestWeFaxRoutes:
data = response.get_json() data = response.get_json()
assert 'LPM' in data['message'] assert 'LPM' in data['message']
def test_start_success(self, client): def test_start_success(self, client):
"""POST /wefax/start with valid params should succeed.""" """POST /wefax/start with valid params should succeed."""
_login_session(client) _login_session(client)
mock_decoder = MagicMock() mock_decoder = MagicMock()
mock_decoder.is_running = False mock_decoder.is_running = False
mock_decoder.start.return_value = True mock_decoder.start.return_value = True
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder), \ with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder), \
@@ -411,46 +411,46 @@ class TestWeFaxRoutes:
content_type='application/json', content_type='application/json',
) )
assert response.status_code == 200 assert response.status_code == 200
data = response.get_json() data = response.get_json()
assert data['status'] == 'started' assert data['status'] == 'started'
assert data['frequency_khz'] == 4298 assert data['frequency_khz'] == 4298
assert data['usb_offset_applied'] is True assert data['usb_offset_applied'] is True
assert math.isclose(data['tuned_frequency_khz'], 4296.1, abs_tol=1e-6) assert math.isclose(data['tuned_frequency_khz'], 4296.1, abs_tol=1e-6)
assert data['frequency_reference'] == 'carrier' assert data['frequency_reference'] == 'carrier'
assert data['station'] == 'NOJ' assert data['station'] == 'NOJ'
mock_decoder.start.assert_called_once() mock_decoder.start.assert_called_once()
start_kwargs = mock_decoder.start.call_args.kwargs start_kwargs = mock_decoder.start.call_args.kwargs
assert math.isclose(start_kwargs['frequency_khz'], 4296.1, abs_tol=1e-6) assert math.isclose(start_kwargs['frequency_khz'], 4296.1, abs_tol=1e-6)
def test_start_respects_dial_reference_override(self, client): def test_start_respects_dial_reference_override(self, client):
"""POST /wefax/start with dial reference should not apply USB offset.""" """POST /wefax/start with dial reference should not apply USB offset."""
_login_session(client) _login_session(client)
mock_decoder = MagicMock() mock_decoder = MagicMock()
mock_decoder.is_running = False mock_decoder.is_running = False
mock_decoder.start.return_value = True mock_decoder.start.return_value = True
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder), \ with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder), \
patch('routes.wefax.app_module.claim_sdr_device', return_value=None): patch('routes.wefax.app_module.claim_sdr_device', return_value=None):
response = client.post( response = client.post(
'/wefax/start', '/wefax/start',
data=json.dumps({ data=json.dumps({
'frequency_khz': 4298, 'frequency_khz': 4298,
'station': 'NOJ', 'station': 'NOJ',
'device': 0, 'device': 0,
'frequency_reference': 'dial', 'frequency_reference': 'dial',
}), }),
content_type='application/json', content_type='application/json',
) )
assert response.status_code == 200 assert response.status_code == 200
data = response.get_json() data = response.get_json()
assert data['status'] == 'started' assert data['status'] == 'started'
assert data['usb_offset_applied'] is False assert data['usb_offset_applied'] is False
assert math.isclose(data['tuned_frequency_khz'], 4298.0, abs_tol=1e-6) assert math.isclose(data['tuned_frequency_khz'], 4298.0, abs_tol=1e-6)
assert data['frequency_reference'] == 'dial' assert data['frequency_reference'] == 'dial'
start_kwargs = mock_decoder.start.call_args.kwargs start_kwargs = mock_decoder.start.call_args.kwargs
assert math.isclose(start_kwargs['frequency_khz'], 4298.0, abs_tol=1e-6) assert math.isclose(start_kwargs['frequency_khz'], 4298.0, abs_tol=1e-6)
def test_start_device_busy(self, client): def test_start_device_busy(self, client):
"""POST /wefax/start should return 409 when device is busy.""" """POST /wefax/start should return 409 when device is busy."""
@@ -509,83 +509,83 @@ class TestWeFaxRoutes:
assert response.status_code == 400 assert response.status_code == 400
def test_delete_image_wrong_extension(self, client): def test_delete_image_wrong_extension(self, client):
"""DELETE /wefax/images/<filename> should reject non-PNG.""" """DELETE /wefax/images/<filename> should reject non-PNG."""
_login_session(client) _login_session(client)
mock_decoder = MagicMock() mock_decoder = MagicMock()
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder): with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder):
response = client.delete('/wefax/images/test.jpg') response = client.delete('/wefax/images/test.jpg')
assert response.status_code == 400 assert response.status_code == 400
def test_schedule_enable_applies_usb_alignment(self, client): def test_schedule_enable_applies_usb_alignment(self, client):
"""Scheduler should receive tuned USB dial frequency in auto mode.""" """Scheduler should receive tuned USB dial frequency in auto mode."""
_login_session(client) _login_session(client)
mock_scheduler = MagicMock() mock_scheduler = MagicMock()
mock_scheduler.enable.return_value = { mock_scheduler.enable.return_value = {
'enabled': True, 'enabled': True,
'scheduled_count': 2, 'scheduled_count': 2,
'total_broadcasts': 2, 'total_broadcasts': 2,
} }
with patch('utils.wefax_scheduler.get_wefax_scheduler', return_value=mock_scheduler): with patch('utils.wefax_scheduler.get_wefax_scheduler', return_value=mock_scheduler):
response = client.post( response = client.post(
'/wefax/schedule/enable', '/wefax/schedule/enable',
data=json.dumps({ data=json.dumps({
'station': 'NOJ', 'station': 'NOJ',
'frequency_khz': 4298, 'frequency_khz': 4298,
'device': 0, 'device': 0,
}), }),
content_type='application/json', content_type='application/json',
) )
assert response.status_code == 200 assert response.status_code == 200
data = response.get_json() data = response.get_json()
assert data['status'] == 'ok' assert data['status'] == 'ok'
assert data['usb_offset_applied'] is True assert data['usb_offset_applied'] is True
assert math.isclose(data['tuned_frequency_khz'], 4296.1, abs_tol=1e-6) assert math.isclose(data['tuned_frequency_khz'], 4296.1, abs_tol=1e-6)
enable_kwargs = mock_scheduler.enable.call_args.kwargs enable_kwargs = mock_scheduler.enable.call_args.kwargs
assert math.isclose(enable_kwargs['frequency_khz'], 4296.1, abs_tol=1e-6) assert math.isclose(enable_kwargs['frequency_khz'], 4296.1, abs_tol=1e-6)
class TestWeFaxProgressCallback: class TestWeFaxProgressCallback:
"""Regression tests for WeFax route-level progress callback behavior.""" """Regression tests for WeFax route-level progress callback behavior."""
def test_terminal_progress_releases_active_device(self): def test_terminal_progress_releases_active_device(self):
"""Terminal decoder events must release any manually claimed SDR.""" """Terminal decoder events must release any manually claimed SDR."""
import routes.wefax as wefax_routes import routes.wefax as wefax_routes
original_device = wefax_routes.wefax_active_device original_device = wefax_routes.wefax_active_device
try: try:
wefax_routes.wefax_active_device = 3 wefax_routes.wefax_active_device = 3
with patch('routes.wefax.app_module.release_sdr_device') as mock_release: with patch('routes.wefax.app_module.release_sdr_device') as mock_release:
wefax_routes._progress_callback({ wefax_routes._progress_callback({
'type': 'wefax_progress', 'type': 'wefax_progress',
'status': 'error', 'status': 'error',
'message': 'decode failed', 'message': 'decode failed',
}) })
mock_release.assert_called_once_with(3) mock_release.assert_called_once_with(3, 'rtlsdr')
assert wefax_routes.wefax_active_device is None assert wefax_routes.wefax_active_device is None
finally: finally:
wefax_routes.wefax_active_device = original_device wefax_routes.wefax_active_device = original_device
def test_non_terminal_progress_does_not_release_active_device(self): def test_non_terminal_progress_does_not_release_active_device(self):
"""Non-terminal progress updates must not release SDR ownership.""" """Non-terminal progress updates must not release SDR ownership."""
import routes.wefax as wefax_routes import routes.wefax as wefax_routes
original_device = wefax_routes.wefax_active_device original_device = wefax_routes.wefax_active_device
try: try:
wefax_routes.wefax_active_device = 4 wefax_routes.wefax_active_device = 4
with patch('routes.wefax.app_module.release_sdr_device') as mock_release: with patch('routes.wefax.app_module.release_sdr_device') as mock_release:
wefax_routes._progress_callback({ wefax_routes._progress_callback({
'type': 'wefax_progress', 'type': 'wefax_progress',
'status': 'receiving', 'status': 'receiving',
'line_count': 120, 'line_count': 120,
}) })
mock_release.assert_not_called() mock_release.assert_not_called()
assert wefax_routes.wefax_active_device == 4 assert wefax_routes.wefax_active_device == 4
finally: finally:
wefax_routes.wefax_active_device = original_device wefax_routes.wefax_active_device = original_device

View File

@@ -300,6 +300,20 @@ SUBGHZ_PRESETS = {
} }
# =============================================================================
# RADIOSONDE (Weather Balloon Tracking)
# =============================================================================
# UDP port for radiosonde_auto_rx telemetry broadcast
RADIOSONDE_UDP_PORT = 55673
# Radiosonde process termination timeout
RADIOSONDE_TERMINATE_TIMEOUT = 5
# Maximum age for balloon data before cleanup (30 min — balloons move slowly)
MAX_RADIOSONDE_AGE_SECONDS = 1800
# ============================================================================= # =============================================================================
# DEAUTH ATTACK DETECTION # DEAUTH ATTACK DETECTION
# ============================================================================= # =============================================================================

View File

@@ -1,49 +1,57 @@
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')
# Additional paths to search for tools (e.g., /usr/sbin on Debian) # Additional paths to search for tools (e.g., /usr/sbin on Debian)
EXTRA_TOOL_PATHS = ['/usr/sbin', '/sbin'] EXTRA_TOOL_PATHS = ['/usr/sbin', '/sbin']
# Tools installed to non-standard locations (not on PATH)
KNOWN_TOOL_PATHS: dict[str, list[str]] = {
'auto_rx.py': [
'/opt/radiosonde_auto_rx/auto_rx/auto_rx.py',
'/opt/auto_rx/auto_rx.py',
],
}
def check_tool(name: str) -> bool: def check_tool(name: str) -> bool:
"""Check if a tool is installed.""" """Check if a tool is installed."""
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:
@@ -51,6 +59,11 @@ def get_tool_path(name: str) -> str | None:
if os.path.isfile(full_path) and os.access(full_path, os.X_OK): if os.path.isfile(full_path) and os.access(full_path, os.X_OK):
return full_path return full_path
# Check known non-standard install locations
for known_path in KNOWN_TOOL_PATHS.get(name, []):
if os.path.isfile(known_path):
return known_path
return None return None
@@ -447,6 +460,20 @@ TOOL_DEPENDENCIES = {
} }
} }
}, },
'radiosonde': {
'name': 'Radiosonde Tracking',
'tools': {
'auto_rx.py': {
'required': True,
'description': 'Radiosonde weather balloon decoder',
'install': {
'apt': 'Run ./setup.sh (clones from GitHub)',
'brew': 'Run ./setup.sh (clones from GitHub)',
'manual': 'https://github.com/projecthorus/radiosonde_auto_rx'
}
}
}
},
'tscm': { 'tscm': {
'name': 'TSCM Counter-Surveillance', 'name': 'TSCM Counter-Surveillance',
'tools': { 'tools': {

View File

@@ -6,31 +6,31 @@ Detects RTL-SDR devices via rtl_test and other SDR hardware via SoapySDR.
from __future__ import annotations from __future__ import annotations
import logging import logging
import re import re
import shutil import shutil
import subprocess import subprocess
import time import time
from typing import Optional from typing import Optional
from .base import SDRCapabilities, SDRDevice, SDRType from .base import SDRCapabilities, SDRDevice, SDRType
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Cache HackRF detection results so polling endpoints don't repeatedly run # Cache HackRF detection results so polling endpoints don't repeatedly run
# hackrf_info while the device is actively streaming in SubGHz mode. # hackrf_info while the device is actively streaming in SubGHz mode.
_hackrf_cache: list[SDRDevice] = [] _hackrf_cache: list[SDRDevice] = []
_hackrf_cache_ts: float = 0.0 _hackrf_cache_ts: float = 0.0
_HACKRF_CACHE_TTL_SECONDS = 3.0 _HACKRF_CACHE_TTL_SECONDS = 3.0
def _hackrf_probe_blocked() -> bool: def _hackrf_probe_blocked() -> bool:
"""Return True when probing HackRF would interfere with an active stream.""" """Return True when probing HackRF would interfere with an active stream."""
try: try:
from utils.subghz import get_subghz_manager from utils.subghz import get_subghz_manager
return get_subghz_manager().active_mode in {'rx', 'decode', 'tx', 'sweep'} return get_subghz_manager().active_mode in {'rx', 'decode', 'tx', 'sweep'}
except Exception: except Exception:
return False return False
def _check_tool(name: str) -> bool: def _check_tool(name: str) -> bool:
@@ -112,21 +112,21 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
lib_paths = ['/usr/local/lib', '/opt/homebrew/lib'] lib_paths = ['/usr/local/lib', '/opt/homebrew/lib']
current_ld = env.get('DYLD_LIBRARY_PATH', '') current_ld = env.get('DYLD_LIBRARY_PATH', '')
env['DYLD_LIBRARY_PATH'] = ':'.join(lib_paths + [current_ld] if current_ld else lib_paths) env['DYLD_LIBRARY_PATH'] = ':'.join(lib_paths + [current_ld] if current_ld else lib_paths)
result = subprocess.run( result = subprocess.run(
['rtl_test', '-t'], ['rtl_test', '-t'],
capture_output=True, capture_output=True,
text=True, text=True,
encoding='utf-8', encoding='utf-8',
errors='replace', errors='replace',
timeout=5, timeout=5,
env=env env=env
) )
output = result.stderr + result.stdout output = result.stderr + result.stdout
# Parse device info from rtl_test output # Parse device info from rtl_test output
# Format: "0: Realtek, RTL2838UHIDIR, SN: 00000001" # Format: "0: Realtek, RTL2838UHIDIR, SN: 00000001"
# Require a non-empty serial to avoid matching malformed lines like "SN:". # Require a non-empty serial to avoid matching malformed lines like "SN:".
device_pattern = r'(\d+):\s+(.+?),\s*SN:\s*(\S+)\s*$' device_pattern = r'(\d+):\s+(.+?),\s*SN:\s*(\S+)\s*$'
from .rtlsdr import RTLSDRCommandBuilder from .rtlsdr import RTLSDRCommandBuilder
@@ -134,14 +134,14 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
line = line.strip() line = line.strip()
match = re.match(device_pattern, line) match = re.match(device_pattern, line)
if match: if match:
devices.append(SDRDevice( devices.append(SDRDevice(
sdr_type=SDRType.RTL_SDR, sdr_type=SDRType.RTL_SDR,
index=int(match.group(1)), index=int(match.group(1)),
name=match.group(2).strip().rstrip(','), name=match.group(2).strip().rstrip(','),
serial=match.group(3), serial=match.group(3),
driver='rtlsdr', driver='rtlsdr',
capabilities=RTLSDRCommandBuilder.CAPABILITIES capabilities=RTLSDRCommandBuilder.CAPABILITIES
)) ))
# Fallback: if we found devices but couldn't parse details # Fallback: if we found devices but couldn't parse details
if not devices: if not devices:
@@ -314,29 +314,29 @@ def _add_soapy_device(
)) ))
def detect_hackrf_devices() -> list[SDRDevice]: def detect_hackrf_devices() -> list[SDRDevice]:
""" """
Detect HackRF devices using native hackrf_info tool. Detect HackRF devices using native hackrf_info tool.
Fallback for when SoapySDR is not available. Fallback for when SoapySDR is not available.
""" """
global _hackrf_cache, _hackrf_cache_ts global _hackrf_cache, _hackrf_cache_ts
now = time.time() now = time.time()
# While HackRF is actively streaming in SubGHz mode, skip probe calls. # While HackRF is actively streaming in SubGHz mode, skip probe calls.
# Re-running hackrf_info during active RX/TX can disrupt the USB stream. # Re-running hackrf_info during active RX/TX can disrupt the USB stream.
if _hackrf_probe_blocked(): if _hackrf_probe_blocked():
return list(_hackrf_cache) return list(_hackrf_cache)
if _hackrf_cache and (now - _hackrf_cache_ts) < _HACKRF_CACHE_TTL_SECONDS: if _hackrf_cache and (now - _hackrf_cache_ts) < _HACKRF_CACHE_TTL_SECONDS:
return list(_hackrf_cache) return list(_hackrf_cache)
devices: list[SDRDevice] = [] devices: list[SDRDevice] = []
if not _check_tool('hackrf_info'): if not _check_tool('hackrf_info'):
_hackrf_cache = devices _hackrf_cache = devices
_hackrf_cache_ts = now _hackrf_cache_ts = now
return devices return devices
try: try:
result = subprocess.run( result = subprocess.run(
@@ -374,12 +374,12 @@ def detect_hackrf_devices() -> list[SDRDevice]:
capabilities=HackRFCommandBuilder.CAPABILITIES capabilities=HackRFCommandBuilder.CAPABILITIES
)) ))
except Exception as e: except Exception as e:
logger.debug(f"HackRF detection error: {e}") logger.debug(f"HackRF detection error: {e}")
_hackrf_cache = list(devices) _hackrf_cache = list(devices)
_hackrf_cache_ts = now _hackrf_cache_ts = now
return devices return devices
def probe_rtlsdr_device(device_index: int) -> str | None: def probe_rtlsdr_device(device_index: int) -> str | None:
@@ -413,31 +413,73 @@ def probe_rtlsdr_device(device_index: int) -> str | None:
lib_paths + [current_ld] if current_ld else lib_paths lib_paths + [current_ld] if current_ld else lib_paths
) )
result = subprocess.run( # Use Popen with early termination instead of run() with full timeout.
# rtl_test prints device info to stderr quickly, then keeps running
# its test loop. We kill it as soon as we see success or failure.
proc = subprocess.Popen(
['rtl_test', '-d', str(device_index), '-t'], ['rtl_test', '-d', str(device_index), '-t'],
capture_output=True, stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True, text=True,
timeout=3,
env=env, env=env,
) )
output = result.stderr + result.stdout
if 'usb_claim_interface' in output or 'Failed to open' in output: import select
error_found = False
device_found = False
deadline = time.monotonic() + 3.0
try:
while time.monotonic() < deadline:
remaining = deadline - time.monotonic()
if remaining <= 0:
break
# Wait for stderr output with timeout
ready, _, _ = select.select(
[proc.stderr], [], [], min(remaining, 0.1)
)
if ready:
line = proc.stderr.readline()
if not line:
break # EOF — process closed stderr
# Check for no-device messages first (before success check,
# since "No supported devices found" also contains "Found" + "device")
if 'no supported devices' in line.lower() or 'no matching devices' in line.lower():
error_found = True
break
if 'usb_claim_interface' in line or 'Failed to open' in line:
error_found = True
break
if 'Found' in line and 'device' in line.lower():
# Device opened successfully — no need to wait longer
device_found = True
break
if proc.poll() is not None:
break # Process exited
if not device_found and not error_found and proc.poll() is not None and proc.returncode != 0:
# rtl_test exited with error and we never saw a success message
error_found = True
finally:
try:
proc.kill()
except OSError:
pass
proc.wait()
if device_found:
# Allow the kernel to fully release the USB interface
# before the caller opens the device with dump1090/rtl_fm/etc.
time.sleep(0.5)
if error_found:
logger.warning( logger.warning(
f"RTL-SDR device {device_index} USB probe failed: " f"RTL-SDR device {device_index} USB probe failed: "
f"device busy or unavailable" f"device busy or unavailable"
) )
return ( return (
f'SDR device {device_index} is busy at the USB level' f'SDR device {device_index} is not available'
f'another process outside INTERCEPT may be using it. ' f'check that the RTL-SDR is connected and not in use by another process.'
f'Check for stale rtl_fm/rtl_433/dump1090 processes, '
f'or try a different device.'
) )
except subprocess.TimeoutExpired:
# rtl_test opened the device successfully and is running the
# test — that means the device *is* available.
pass
except Exception as e: except Exception as e:
logger.debug(f"RTL-SDR probe error for device {device_index}: {e}") logger.debug(f"RTL-SDR probe error for device {device_index}: {e}")