fix(modes): deep-linked mode scripts fail when body not yet parsed

ensureModeScript() used document.body.appendChild() to load lazy mode
scripts, but the preload for ?mode= query params runs in <head> before
<body> exists, causing all deep-linked modes to silently fail.

Also fix cross-mode handoffs (BT→BT Locate, WiFi→WiFi Locate,
Spy Stations→Waterfall) that assumed target module was already loaded.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-03-12 20:49:08 +00:00
parent e687862043
commit 90281b1535
87 changed files with 9128 additions and 8368 deletions
+18 -5
View File
@@ -40,6 +40,8 @@ def get_connection() -> sqlite3.Connection:
_local.connection.row_factory = sqlite3.Row
# Enable foreign keys
_local.connection.execute('PRAGMA foreign_keys = ON')
# Use WAL mode for better concurrent read/write performance
_local.connection.execute('PRAGMA journal_mode = WAL')
except sqlite3.OperationalError as e:
logger.error(
f"Cannot open database at {db_path}: {e}. "
@@ -254,12 +256,23 @@ def init_db() -> None:
cursor = conn.execute('SELECT COUNT(*) FROM users')
if cursor.fetchone()[0] == 0:
from config import ADMIN_USERNAME, ADMIN_PASSWORD
import secrets as _secrets
admin_password = ADMIN_PASSWORD
if not admin_password:
admin_password = _secrets.token_urlsafe(16)
logger.warning(f"Generated admin password: {admin_password}")
logger.warning("Set INTERCEPT_ADMIN_PASSWORD env var to use a fixed password.")
try:
pw_path = Path('instance/.initial_password')
pw_path.parent.mkdir(parents=True, exist_ok=True)
pw_path.write_text(f"{ADMIN_USERNAME}:{admin_password}\n")
except OSError as e:
logger.warning(f"Could not write initial password file: {e}")
logger.info(f"Creating default admin user: {ADMIN_USERNAME}")
# Password hashing
hashed_pw = generate_password_hash(ADMIN_PASSWORD)
hashed_pw = generate_password_hash(admin_password)
conn.execute('''
INSERT INTO users (username, password_hash, role)
VALUES (?, ?, ?)
+9 -1
View File
@@ -45,7 +45,7 @@ def close_process_pipes(process: subprocess.Popen) -> None:
def cleanup_all_processes() -> None:
"""Clean up all registered processes on exit."""
"""Clean up all registered processes and flush DataStores on exit."""
logger.info("Cleaning up all spawned processes...")
with _process_lock:
for process in _spawned_processes:
@@ -60,6 +60,14 @@ def cleanup_all_processes() -> None:
close_process_pipes(process)
_spawned_processes.clear()
# Stop DataStore cleanup timers and run final cleanup
try:
from utils.cleanup import cleanup_manager
cleanup_manager.cleanup_now()
cleanup_manager.stop()
except Exception as e:
logger.warning(f"Error during DataStore cleanup: {e}")
def safe_terminate(process: subprocess.Popen | None, timeout: float = 2.0) -> bool:
"""
+37
View File
@@ -0,0 +1,37 @@
"""Standardized API response helpers.
Use these in new or modified routes for consistent JSON responses.
Existing routes are NOT being refactored to avoid unnecessary churn.
"""
from flask import jsonify
def api_success(data=None, message=None, status_code=200):
"""Return a success JSON response.
Args:
data: Optional dict of additional fields merged into the response.
message: Optional human-readable message.
status_code: HTTP status code (default 200).
"""
payload = {'status': 'success'}
if message:
payload['message'] = message
if data:
payload.update(data)
return jsonify(payload), status_code
def api_error(message, status_code=400, error_type=None):
"""Return an error JSON response.
Args:
message: Human-readable error message.
status_code: HTTP status code (default 400).
error_type: Optional machine-readable error category (e.g. 'DEVICE_BUSY').
"""
payload = {'status': 'error', 'message': message}
if error_type:
payload['error_type'] = error_type
return jsonify(payload), status_code
+10
View File
@@ -37,6 +37,7 @@ class SDRCapabilities:
supports_bias_t: bool = False # Bias-T support
supports_ppm: bool = True # PPM correction support
tx_capable: bool = False # Can transmit
supports_iq_capture: bool = False # Raw I/Q sample capture
@dataclass
@@ -217,6 +218,15 @@ class CommandBuilder(ABC):
Raises:
NotImplementedError: If the SDR type does not support I/Q capture.
"""
if not device.capabilities.supports_iq_capture:
supported = ', '.join(
t.value for t in SDRType
if t == SDRType.RTL_SDR # known IQ-capable types
)
raise ValueError(
f"{device.sdr_type.value} does not support raw I/Q capture. "
f"Supported devices: {supported}"
)
raise NotImplementedError(
f"{self.__class__.__name__} does not support raw I/Q capture"
)
+97 -83
View File
@@ -6,15 +6,15 @@ Detects RTL-SDR devices via rtl_test and other SDR hardware via SoapySDR.
from __future__ import annotations
import logging
import re
import subprocess
import time
from typing import Optional
from utils.dependencies import get_tool_path
from .base import SDRCapabilities, SDRDevice, SDRType
import logging
import re
import subprocess
import time
from typing import Optional
from utils.dependencies import get_tool_path
from .base import SDRCapabilities, SDRDevice, SDRType
logger = logging.getLogger(__name__)
@@ -44,7 +44,7 @@ def _hackrf_probe_blocked() -> bool:
return False
def _get_capabilities_for_type(sdr_type: SDRType) -> SDRCapabilities:
def _get_capabilities_for_type(sdr_type: SDRType) -> SDRCapabilities:
"""Get default capabilities for an SDR type."""
# Import here to avoid circular imports
from .rtlsdr import RTLSDRCommandBuilder
@@ -96,7 +96,7 @@ def _driver_to_sdr_type(driver: str) -> Optional[SDRType]:
return mapping.get(driver.lower())
def detect_rtlsdr_devices() -> list[SDRDevice]:
def detect_rtlsdr_devices() -> list[SDRDevice]:
"""
Detect RTL-SDR devices using rtl_test.
@@ -105,10 +105,10 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
"""
devices: list[SDRDevice] = []
rtl_test_path = get_tool_path('rtl_test')
if not rtl_test_path:
logger.debug("rtl_test not found, skipping RTL-SDR detection")
return devices
rtl_test_path = get_tool_path('rtl_test')
if not rtl_test_path:
logger.debug("rtl_test not found, skipping RTL-SDR detection")
return devices
try:
import os
@@ -119,15 +119,19 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
lib_paths = ['/usr/local/lib', '/opt/homebrew/lib']
current_ld = env.get('DYLD_LIBRARY_PATH', '')
env['DYLD_LIBRARY_PATH'] = ':'.join(lib_paths + [current_ld] if current_ld else lib_paths)
result = subprocess.run(
[rtl_test_path, '-t'],
capture_output=True,
text=True,
encoding='utf-8',
errors='replace',
timeout=5,
env=env
)
try:
result = subprocess.run(
[rtl_test_path, '-t'],
capture_output=True,
text=True,
encoding='utf-8',
errors='replace',
timeout=5,
env=env
)
except subprocess.TimeoutExpired:
logger.warning("rtl_test timed out after 5s")
return []
output = result.stderr + result.stdout
# Parse device info from rtl_test output
@@ -173,14 +177,14 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
return devices
def _find_soapy_util() -> str | None:
"""Find SoapySDR utility command (name varies by distribution)."""
# Try different command names used across distributions
for cmd in ['SoapySDRUtil', 'soapy_sdr_util', 'soapysdr-util']:
tool_path = get_tool_path(cmd)
if tool_path:
return tool_path
return None
def _find_soapy_util() -> str | None:
"""Find SoapySDR utility command (name varies by distribution)."""
# Try different command names used across distributions
for cmd in ['SoapySDRUtil', 'soapy_sdr_util', 'soapysdr-util']:
tool_path = get_tool_path(cmd)
if tool_path:
return tool_path
return None
def _get_soapy_env() -> dict:
@@ -322,7 +326,7 @@ def _add_soapy_device(
))
def detect_hackrf_devices() -> list[SDRDevice]:
def detect_hackrf_devices() -> list[SDRDevice]:
"""
Detect HackRF devices using native hackrf_info tool.
@@ -341,46 +345,46 @@ def detect_hackrf_devices() -> list[SDRDevice]:
devices: list[SDRDevice] = []
hackrf_info_path = get_tool_path('hackrf_info')
if not hackrf_info_path:
_hackrf_cache = devices
_hackrf_cache_ts = now
return devices
hackrf_info_path = get_tool_path('hackrf_info')
if not hackrf_info_path:
_hackrf_cache = devices
_hackrf_cache_ts = now
return devices
try:
result = subprocess.run(
[hackrf_info_path],
capture_output=True,
text=True,
timeout=5
)
result = subprocess.run(
[hackrf_info_path],
capture_output=True,
text=True,
timeout=5
)
# Combine stdout + stderr: newer firmware may print to stderr,
# and hackrf_info may exit non-zero when device is briefly busy
# but still output valid info.
output = f"{result.stdout or ''}\n{result.stderr or ''}"
# Parse hackrf_info output
# Extract board name from "Board ID Number: X (Name)" and serial
from .hackrf import HackRFCommandBuilder
serial_pattern = re.compile(
r'^\s*Serial\s+number:\s*(.+)$',
re.IGNORECASE | re.MULTILINE,
)
board_pattern = re.compile(
r'Board\s+ID\s+Number:\s*\d+\s*\(([^)]+)\)',
re.IGNORECASE,
)
serials_found = []
for raw in serial_pattern.findall(output):
# Normalise legacy formats like "0x1234 5678" to plain hex.
serial = re.sub(r'0x', '', raw, flags=re.IGNORECASE)
serial = re.sub(r'[^0-9A-Fa-f]', '', serial)
if serial:
serials_found.append(serial)
boards_found = board_pattern.findall(output)
output = f"{result.stdout or ''}\n{result.stderr or ''}"
# Parse hackrf_info output
# Extract board name from "Board ID Number: X (Name)" and serial
from .hackrf import HackRFCommandBuilder
serial_pattern = re.compile(
r'^\s*Serial\s+number:\s*(.+)$',
re.IGNORECASE | re.MULTILINE,
)
board_pattern = re.compile(
r'Board\s+ID\s+Number:\s*\d+\s*\(([^)]+)\)',
re.IGNORECASE,
)
serials_found = []
for raw in serial_pattern.findall(output):
# Normalise legacy formats like "0x1234 5678" to plain hex.
serial = re.sub(r'0x', '', raw, flags=re.IGNORECASE)
serial = re.sub(r'[^0-9A-Fa-f]', '', serial)
if serial:
serials_found.append(serial)
boards_found = board_pattern.findall(output)
for i, serial in enumerate(serials_found):
board_name = boards_found[i] if i < len(boards_found) else 'HackRF'
@@ -394,11 +398,11 @@ def detect_hackrf_devices() -> list[SDRDevice]:
))
# Fallback: check if any HackRF found without serial
if not devices and re.search(r'Found\s+HackRF', output, re.IGNORECASE):
board_match = board_pattern.search(output)
board_name = board_match.group(1) if board_match else 'HackRF'
devices.append(SDRDevice(
sdr_type=SDRType.HACKRF,
if not devices and re.search(r'Found\s+HackRF', output, re.IGNORECASE):
board_match = board_pattern.search(output)
board_name = board_match.group(1) if board_match else 'HackRF'
devices.append(SDRDevice(
sdr_type=SDRType.HACKRF,
index=0,
name=board_name,
serial='Unknown',
@@ -414,7 +418,7 @@ def detect_hackrf_devices() -> list[SDRDevice]:
return devices
def probe_rtlsdr_device(device_index: int) -> str | None:
def probe_rtlsdr_device(device_index: int) -> str | None:
"""Probe whether an RTL-SDR device is available at the USB level.
Runs a quick ``rtl_test`` invocation targeting a single device to
@@ -428,11 +432,11 @@ def probe_rtlsdr_device(device_index: int) -> str | None:
An error message string if the device cannot be opened,
or ``None`` if the device is available.
"""
rtl_test_path = get_tool_path('rtl_test')
if not rtl_test_path:
# Can't probe without rtl_test — let the caller proceed and
# surface errors from the actual decoder process instead.
return None
rtl_test_path = get_tool_path('rtl_test')
if not rtl_test_path:
# Can't probe without rtl_test — let the caller proceed and
# surface errors from the actual decoder process instead.
return None
try:
import os
@@ -449,11 +453,11 @@ def probe_rtlsdr_device(device_index: int) -> str | None:
# 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_path, '-d', str(device_index), '-t'],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
proc = subprocess.Popen(
[rtl_test_path, '-d', str(device_index), '-t'],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
env=env,
)
@@ -572,6 +576,16 @@ def detect_all_devices(force: bool = False) -> list[SDRDevice]:
return devices
def get_cached_devices() -> list[SDRDevice] | None:
"""Return the cached device list without probing hardware.
Returns None if no cached data is available (never probed).
"""
if _all_devices_cache_ts == 0.0:
return None
return list(_all_devices_cache)
def invalidate_device_cache() -> None:
"""Clear the all-devices cache so the next call re-probes hardware."""
global _all_devices_cache, _all_devices_cache_ts
+2 -1
View File
@@ -87,7 +87,8 @@ class RTLSDRCommandBuilder(CommandBuilder):
sample_rates=[250000, 1024000, 1800000, 2048000, 2400000],
supports_bias_t=True,
supports_ppm=True,
tx_capable=False
tx_capable=False,
supports_iq_capture=True
)
def _get_device_arg(self, device: SDRDevice) -> str:
+14 -7
View File
@@ -6,6 +6,7 @@ Shared prediction logic used by both the API endpoint and the auto-scheduler.
from __future__ import annotations
import datetime
import time
from typing import Any
from utils.logging import get_logger
@@ -63,14 +64,20 @@ def predict_passes(
from data.satellites import TLE_SATELLITES
# Use live TLE cache from satellite module if available (refreshed from CelesTrak)
# Use live TLE cache from satellite module if available (refreshed from CelesTrak).
# Cache the reference locally so repeated calls don't re-import each time.
tle_source = TLE_SATELLITES
try:
from routes.satellite import _tle_cache
if _tle_cache:
tle_source = _tle_cache
except ImportError:
pass
if not hasattr(predict_passes, '_tle_ref') or \
(time.time() - getattr(predict_passes, '_tle_ref_ts', 0)) > 3600:
try:
from routes.satellite import _tle_cache
if _tle_cache:
predict_passes._tle_ref = _tle_cache
predict_passes._tle_ref_ts = time.time()
except ImportError:
pass
if hasattr(predict_passes, '_tle_ref') and predict_passes._tle_ref:
tle_source = predict_passes._tle_ref
ts = _get_timescale()
observer = wgs84.latlon(lat, lon)