Fix dashboard startup regressions and mode utilities

This commit is contained in:
James Smith
2026-03-19 10:37:21 +00:00
parent 5f34d20287
commit 18b442eb21
15 changed files with 283 additions and 96 deletions

View File

@@ -3502,7 +3502,7 @@ class ModeManager:
stations_url = 'https://celestrak.org/NORAD/elements/gp.php?GROUP=weather&FORMAT=tle'
satellites = load.tle_file(stations_url)
ts = load.timescale()
ts = load.timescale(builtin=True)
observer = Topos(latitude_degrees=lat, longitude_degrees=lon)
logger.info(f"Satellite predictor: {len(satellites)} satellites loaded")

View File

@@ -725,11 +725,12 @@ def agent_management_page():
return render_template('agents.html', version=VERSION)
@controller_bp.route('/monitor')
def network_monitor_page():
"""Render the network monitor page for multi-agent aggregated view."""
from flask import render_template
return render_template('network_monitor.html')
@controller_bp.route('/monitor')
def network_monitor_page():
"""Render the network monitor page for multi-agent aggregated view."""
from flask import render_template
from config import VERSION
return render_template('network_monitor.html', version=VERSION)
# =============================================================================

View File

@@ -66,8 +66,8 @@ AUTO_RX_PATHS = [
]
def find_auto_rx() -> str | None:
"""Find radiosonde_auto_rx script/binary."""
def find_auto_rx() -> str | None:
"""Find radiosonde_auto_rx script/binary."""
# Check PATH first
path = shutil.which('radiosonde_auto_rx')
if path:
@@ -77,10 +77,66 @@ def find_auto_rx() -> str | None:
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
for p in AUTO_RX_PATHS:
if os.path.isfile(p):
return p
return None
def _iter_auto_rx_python_candidates(auto_rx_path: str):
"""Yield plausible Python interpreters for radiosonde_auto_rx."""
auto_rx_abs = os.path.abspath(auto_rx_path)
auto_rx_dir = os.path.dirname(auto_rx_abs)
install_root = os.path.dirname(auto_rx_dir)
candidates = [
sys.executable,
os.path.join(install_root, 'venv', 'bin', 'python'),
os.path.join(install_root, '.venv', 'bin', 'python'),
os.path.join(auto_rx_dir, 'venv', 'bin', 'python'),
os.path.join(auto_rx_dir, '.venv', 'bin', 'python'),
shutil.which('python3'),
]
seen: set[str] = set()
for candidate in candidates:
if not candidate:
continue
candidate_abs = os.path.abspath(candidate)
if candidate_abs in seen:
continue
seen.add(candidate_abs)
if os.path.isfile(candidate_abs) and os.access(candidate_abs, os.X_OK):
yield candidate_abs
def _resolve_auto_rx_python(auto_rx_path: str) -> tuple[str | None, str, list[str]]:
"""Pick a Python interpreter that can import autorx.scan successfully."""
auto_rx_dir = os.path.dirname(os.path.abspath(auto_rx_path))
checked: list[str] = []
last_error = 'No usable Python interpreter found'
for python_bin in _iter_auto_rx_python_candidates(auto_rx_path):
checked.append(python_bin)
try:
dep_check = subprocess.run(
[python_bin, '-c', 'import autorx.scan'],
cwd=auto_rx_dir,
capture_output=True,
timeout=10,
)
except Exception as exc:
last_error = str(exc)
continue
if dep_check.returncode == 0:
return python_bin, '', checked
stderr_output = dep_check.stderr.decode('utf-8', errors='ignore').strip()
stdout_output = dep_check.stdout.decode('utf-8', errors='ignore').strip()
last_error = stderr_output or stdout_output or f'Interpreter exited with code {dep_check.returncode}'
return None, last_error, checked
def generate_station_cfg(
@@ -544,33 +600,31 @@ def start_radiosonde():
logger.error(f"Failed to generate radiosonde config: {e}")
return api_error(str(e), 500)
# Build command - auto_rx -c expects the path to station.cfg
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))
# Quick dependency check before launching the full process
if auto_rx_path.endswith('.py'):
dep_check = subprocess.run(
[sys.executable, '-c', 'import autorx.scan'],
cwd=auto_rx_dir,
capture_output=True,
timeout=10,
)
if dep_check.returncode != 0:
dep_error = dep_check.stderr.decode('utf-8', errors='ignore').strip()
logger.error(f"radiosonde_auto_rx dependency check failed:\n{dep_error}")
app_module.release_sdr_device(device_int, sdr_type_str)
return api_error(
'radiosonde_auto_rx dependencies not satisfied. '
f'Re-run setup.sh to install. Error: {dep_error[:500]}',
500,
)
# Build command - auto_rx -c expects the path to station.cfg
cfg_abs = os.path.abspath(cfg_path)
if auto_rx_path.endswith('.py'):
selected_python, dep_error, checked_interpreters = _resolve_auto_rx_python(auto_rx_path)
if not selected_python:
logger.error(
"radiosonde_auto_rx dependency check failed across interpreters %s: %s",
checked_interpreters,
dep_error,
)
app_module.release_sdr_device(device_int, sdr_type_str)
checked_msg = ', '.join(checked_interpreters) if checked_interpreters else 'none'
return api_error(
'radiosonde_auto_rx dependencies not satisfied. '
'Install or repair its Python environment (missing packages such as semver). '
f'Checked interpreters: {checked_msg}. '
f'Last error: {dep_error[:500]}',
500,
)
cmd = [selected_python, 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)}")

View File

@@ -30,12 +30,13 @@ satellite_bp = Blueprint('satellite', __name__, url_prefix='/satellite')
_cached_timescale = None
def _get_timescale():
global _cached_timescale
if _cached_timescale is None:
from skyfield.api import load
_cached_timescale = load.timescale()
return _cached_timescale
def _get_timescale():
global _cached_timescale
if _cached_timescale is None:
from skyfield.api import load
# Use bundled timescale data so the first request does not block on network I/O.
_cached_timescale = load.timescale(builtin=True)
return _cached_timescale
# Maximum response size for external requests (1MB)
MAX_RESPONSE_SIZE = 1024 * 1024

View File

@@ -472,14 +472,14 @@ def stream_progress():
return response
def _get_timescale():
"""Return a cached skyfield timescale (expensive to create)."""
global _timescale
with _timescale_lock:
if _timescale is None:
from skyfield.api import load
_timescale = load.timescale()
return _timescale
def _get_timescale():
"""Return a cached skyfield timescale (expensive to create)."""
global _timescale
with _timescale_lock:
if _timescale is None:
from skyfield.api import load
_timescale = load.timescale(builtin=True)
return _timescale
@sstv_bp.route('/iss-schedule')

View File

@@ -17,9 +17,10 @@ const CheatSheets = (function () {
sstv: { title: 'ISS SSTV', icon: '🖼️', hardware: 'RTL-SDR + 145MHz antenna', description: 'Receives ISS SSTV images via slowrx.', whatToExpect: 'Color images during ISS SSTV events (PD180 mode).', tips: ['ISS SSTV: 145.800 MHz', 'Check ARISS for active event dates', 'ISS must be overhead — check pass times'] },
weathersat: { title: 'Weather Satellites', icon: '🌤️', hardware: 'RTL-SDR + 137MHz turnstile/QFH antenna', description: 'Decodes NOAA APT and Meteor LRPT weather imagery via SatDump.', whatToExpect: 'Infrared/visible cloud imagery.', tips: ['NOAA 15/18/19: 137.1137.9 MHz APT', 'Meteor M2-3: 137.9 MHz LRPT', 'Use circular polarized antenna (QFH or turnstile)'] },
sstv_general:{ title: 'HF SSTV', icon: '📷', hardware: 'RTL-SDR + HF upconverter', description: 'Receives HF SSTV transmissions.', whatToExpect: 'Amateur radio images on 14.230 MHz (USB mode).', tips: ['14.230 MHz USB is primary HF SSTV frequency', 'Scottie 1 and Martin 1 most common', 'Best during daylight hours'] },
gps: { title: 'GPS Receiver', icon: '🗺️', hardware: 'USB GPS receiver (NMEA)', description: 'Streams GPS position and feeds location to other modes.', whatToExpect: 'Lat/lon, altitude, speed, heading, satellite count.', tips: ['BT Locate uses GPS for trail logging', 'Set observer location for satellite prediction', 'Verify a 3D fix before relying on altitude'] },
spaceweather:{ title: 'Space Weather', icon: '☀️', hardware: 'None (NOAA/SpaceWeatherLive data)', description: 'Monitors solar activity and geomagnetic storm indices.', whatToExpect: 'Kp index, solar flux, X-ray flare alerts, CME tracking.', tips: ['High Kp (≥5) = geomagnetic storm', 'X-class flares cause HF radio blackouts', 'Check before HF or satellite operations'] },
tscm: { title: 'TSCM Counter-Surveillance', icon: '🔍', hardware: 'WiFi + Bluetooth adapters', description: 'Technical Surveillance Countermeasures — detects hidden devices.', whatToExpect: 'RF baseline comparison, rogue device alerts, tracker detection.', tips: ['Take baseline in a known-clean environment', 'New strong signals = potential bug', 'Correlate WiFi + Bluetooth observations'] },
gps: { title: 'GPS Receiver', icon: '🗺️', hardware: 'USB GPS receiver (NMEA)', description: 'Streams GPS position and feeds location to other modes.', whatToExpect: 'Lat/lon, altitude, speed, heading, satellite count.', tips: ['BT Locate uses GPS for trail logging', 'Set observer location for satellite prediction', 'Verify a 3D fix before relying on altitude'] },
spaceweather:{ title: 'Space Weather', icon: '☀️', hardware: 'None (NOAA/SpaceWeatherLive data)', description: 'Monitors solar activity and geomagnetic storm indices.', whatToExpect: 'Kp index, solar flux, X-ray flare alerts, CME tracking.', tips: ['High Kp (≥5) = geomagnetic storm', 'X-class flares cause HF radio blackouts', 'Check before HF or satellite operations'] },
controller_monitor: { title: 'Controller Monitor', icon: '🖧', hardware: 'Optional remote agents', description: 'Aggregated controller view across connected agents and local sources.', whatToExpect: 'Combined device activity, logs, and agent health in one place.', tips: ['Use it to compare what each agent is seeing', 'Check agent status before remote starts', 'Open Manage to add or troubleshoot agents'] },
tscm: { title: 'TSCM Counter-Surveillance', icon: '🔍', hardware: 'WiFi + Bluetooth adapters', description: 'Technical Surveillance Countermeasures — detects hidden devices.', whatToExpect: 'RF baseline comparison, rogue device alerts, tracker detection.', tips: ['Take baseline in a known-clean environment', 'New strong signals = potential bug', 'Correlate WiFi + Bluetooth observations'] },
spystations: { title: 'Spy Stations', icon: '🕵️', hardware: 'RTL-SDR + HF antenna', description: 'Database of known number stations, military, and diplomatic HF signals.', whatToExpect: 'Scheduled broadcasts, frequency database, tune-to links.', tips: ['Numbers stations often broadcast on the hour', 'Use Spectrum Waterfall to tune directly', 'STANAG and HF mil signals are common'] },
websdr: { title: 'WebSDR', icon: '🌐', hardware: 'None (uses remote SDR servers)', description: 'Access remote WebSDR receivers worldwide for HF shortwave listening.', whatToExpect: 'Live audio from global HF receivers, waterfall display.', tips: ['websdr.org lists available servers', 'Good for HF when local antenna is lacking', 'Use in-app player for seamless experience'] },
subghz: { title: 'SubGHz Transceiver', icon: '📡', hardware: 'HackRF One', description: 'Transmit and receive sub-GHz RF signals for IoT and industrial protocols.', whatToExpect: 'Raw signal capture, replay, and protocol analysis.', tips: ['Only use on licensed frequencies', 'Capture mode records raw IQ for replay', 'Common: garage doors, keyfobs, 315/433/868/915 MHz'] },

View File

@@ -16,8 +16,13 @@ const SpaceWeather = (function () {
let _xrayChart = null;
// Current image selections
let _solarImageKey = 'sdo_193';
let _drapFreq = 'drap_global';
let _solarImageKey = 'sdo_193';
let _drapFreq = 'drap_global';
const SOLAR_IMAGE_FALLBACKS = {
sdo_193: 'https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_0193.jpg',
sdo_304: 'https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_0304.jpg',
sdo_magnetogram: 'https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_HMIBC.jpg',
};
/** Stable cache-bust key that rotates every 5 minutes (matches backend max-age). */
function _cacheBust() {
@@ -48,33 +53,35 @@ const SpaceWeather = (function () {
_fetchData();
}
function selectSolarImage(key) {
_solarImageKey = key;
_updateSolarImageTabs();
const frame = document.getElementById('swSolarImageFrame');
if (frame) {
frame.innerHTML = '<div class="sw-loading">Loading</div>';
const img = new Image();
img.onload = function () { frame.innerHTML = ''; frame.appendChild(img); };
img.onerror = function () { frame.innerHTML = '<div class="sw-empty">Failed to load image</div>'; };
img.src = '/space-weather/image/' + key + '?' + _cacheBust();
img.alt = key;
}
}
function selectSolarImage(key) {
_solarImageKey = key;
_updateSolarImageTabs();
const frame = document.getElementById('swSolarImageFrame');
if (frame) {
frame.innerHTML = '<div class="sw-loading">Loading</div>';
_loadImageWithFallback(
frame,
['/space-weather/image/' + key + '?' + _cacheBust(), _directImageUrlForKey(key)],
key,
'<div class="sw-empty">NASA SDO image is temporarily unavailable</div>'
);
}
}
function selectDrapFreq(key) {
_drapFreq = key;
_updateDrapTabs();
const frame = document.getElementById('swDrapImageFrame');
if (frame) {
frame.innerHTML = '<div class="sw-loading">Loading</div>';
const img = new Image();
img.onload = function () { frame.innerHTML = ''; frame.appendChild(img); };
img.onerror = function () { frame.innerHTML = '<div class="sw-empty">Failed to load image</div>'; };
img.src = '/space-weather/image/' + key + '?' + _cacheBust();
img.alt = key;
}
}
function selectDrapFreq(key) {
_drapFreq = key;
_updateDrapTabs();
const frame = document.getElementById('swDrapImageFrame');
if (frame) {
frame.innerHTML = '<div class="sw-loading">Loading</div>';
_loadImageWithFallback(
frame,
['/space-weather/image/' + key + '?' + _cacheBust()],
key,
'<div class="sw-empty">Failed to load image</div>'
);
}
}
function toggleAutoRefresh() {
const cb = document.getElementById('swAutoRefresh');
@@ -94,9 +101,41 @@ const SpaceWeather = (function () {
}
}
function _stopAutoRefresh() {
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
}
function _stopAutoRefresh() {
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
}
function _directImageUrlForKey(key) {
const base = SOLAR_IMAGE_FALLBACKS[key];
if (!base) return null;
return base + '?' + _cacheBust();
}
function _loadImageWithFallback(frame, urls, alt, failureHtml) {
const candidates = (urls || []).filter(Boolean);
if (!frame || candidates.length === 0) {
if (frame) frame.innerHTML = failureHtml;
return;
}
let index = 0;
const img = new Image();
img.alt = alt;
img.referrerPolicy = 'no-referrer';
img.onload = function () {
frame.innerHTML = '';
frame.appendChild(img);
};
img.onerror = function () {
index += 1;
if (index < candidates.length) {
img.src = candidates[index];
return;
}
frame.innerHTML = failureHtml;
};
img.src = candidates[index];
}
function _fetchData() {
fetch('/space-weather/data')

View File

@@ -25,7 +25,7 @@
<script defer src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
</head>
<body>
<body data-mode="adsb">
<div class="radar-bg"></div>
<div class="scanline"></div>
@@ -422,6 +422,7 @@
let eventSource = null;
let agentPollTimer = null; // Polling fallback for agent mode
let isTracking = false;
let isTrackingStarting = false;
let currentFilter = 'all';
// ICAO -> { emergency: bool, watchlist: bool, military: bool }
let alertedAircraft = {};
@@ -2367,6 +2368,10 @@ sudo make install</code>
const btn = document.getElementById('startBtn');
const useAgent = typeof adsbCurrentAgent !== 'undefined' && adsbCurrentAgent !== 'local';
if (isTrackingStarting) {
return;
}
if (!isTracking) {
// Check for remote dump1090 config (only for local mode)
const remoteConfig = !useAgent ? getRemoteDump1090Config() : null;
@@ -2444,6 +2449,10 @@ sudo make install</code>
requestBody.remote_sbs_host = remoteConfig.host;
requestBody.remote_sbs_port = remoteConfig.port;
}
isTrackingStarting = true;
btn.disabled = true;
btn.textContent = 'STARTING...';
updateTrackingStatusDisplay();
try {
// Route through agent proxy if using remote agent
const url = useAgent
@@ -2470,10 +2479,12 @@ sudo make install</code>
drawRangeRings();
startSessionTimer();
isTracking = true;
isTrackingStarting = false;
adsbActiveDevice = adsbDevice; // Track which device is being used
adsbTrackingSource = useAgent ? adsbCurrentAgent : 'local'; // Track which source started tracking
btn.textContent = 'STOP';
btn.classList.add('active');
btn.disabled = false;
document.getElementById('trackingDot').classList.remove('inactive');
updateTrackingStatusDisplay();
// Disable ADS-B device selector while tracking
@@ -2493,6 +2504,14 @@ sudo make install</code>
}
} catch (err) {
alert('Error: ' + err.message);
} finally {
if (!isTracking) {
isTrackingStarting = false;
btn.disabled = false;
btn.textContent = 'START';
btn.classList.remove('active');
updateTrackingStatusDisplay();
}
}
} else {
try {
@@ -5697,10 +5716,14 @@ sudo make install</code>
{% include 'partials/help-modal.html' %}
<script src="{{ url_for('static', filename='js/core/voice-alerts.js') }}?v={{ version }}&r=adsbvoice1"></script>
<script src="{{ url_for('static', filename='js/core/keyboard-shortcuts.js') }}"></script>
<script src="{{ url_for('static', filename='js/core/cheat-sheets.js') }}"></script>
{% include 'partials/nav-utility-modals.html' %}
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
<script>
window.addEventListener('DOMContentLoaded', () => {
if (typeof VoiceAlerts !== 'undefined') VoiceAlerts.init();
if (typeof KeyboardShortcuts !== 'undefined') KeyboardShortcuts.init();
});
</script>
@@ -5738,7 +5761,10 @@ sudo make install</code>
const statusEl = document.getElementById('trackingStatus');
if (!statusEl) return;
if (!isTracking) {
if (isTrackingStarting && !isTracking) {
statusEl.textContent = 'INITIALIZING';
statusEl.title = 'Starting ADS-B receiver';
} else if (!isTracking) {
statusEl.textContent = 'STANDBY';
statusEl.title = 'Select source and click START';
} else {

View File

@@ -24,7 +24,7 @@
<script defer src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
</head>
<body>
<body data-mode="ais">
<!-- Radar background effects -->
<div class="radar-bg"></div>
<div class="scanline"></div>
@@ -1632,7 +1632,20 @@
<!-- Help Modal -->
{% include 'partials/help-modal.html' %}
<script src="{{ url_for('static', filename='js/core/voice-alerts.js') }}?v={{ version }}&r=voicefix2"></script>
<script src="{{ url_for('static', filename='js/core/keyboard-shortcuts.js') }}"></script>
<script src="{{ url_for('static', filename='js/core/cheat-sheets.js') }}"></script>
{% include 'partials/nav-utility-modals.html' %}
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
<script>
window.addEventListener('DOMContentLoaded', () => {
if (typeof VoiceAlerts !== 'undefined') {
VoiceAlerts.init({ startStreams: false });
VoiceAlerts.scheduleStreamStart(20000);
}
if (typeof KeyboardShortcuts !== 'undefined') KeyboardShortcuts.init();
});
</script>
<!-- Agent Manager -->
<script src="{{ url_for('static', filename='js/core/agents.js') }}"></script>

View File

@@ -518,7 +518,7 @@
}
</style>
</head>
<body>
<body data-mode="controller_monitor">
<header class="header">
<div class="logo">
NETWORK MONITOR
@@ -1117,7 +1117,20 @@
<!-- Help Modal -->
{% include 'partials/help-modal.html' %}
<script src="{{ url_for('static', filename='js/core/voice-alerts.js') }}?v={{ version }}&r=voicefix2"></script>
<script src="{{ url_for('static', filename='js/core/keyboard-shortcuts.js') }}"></script>
<script src="{{ url_for('static', filename='js/core/cheat-sheets.js') }}"></script>
{% include 'partials/nav-utility-modals.html' %}
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
<script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script>
<script>
window.addEventListener('DOMContentLoaded', () => {
if (typeof VoiceAlerts !== 'undefined') {
VoiceAlerts.init({ startStreams: false });
VoiceAlerts.scheduleStreamStart(20000);
}
if (typeof KeyboardShortcuts !== 'undefined') KeyboardShortcuts.init();
});
</script>
</body>
</html>

View File

@@ -0,0 +1,26 @@
<!-- Cheat Sheet Modal -->
<div id="cheatSheetModal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.7); z-index:10000; align-items:center; justify-content:center; padding:20px;" onclick="if(event.target===this)CheatSheets.hide()">
<div style="background:var(--bg-card, #1a1f2e); border:1px solid rgba(255,255,255,0.15); border-radius:12px; max-width:480px; width:100%; max-height:80vh; overflow-y:auto; padding:20px; position:relative;">
<button onclick="CheatSheets.hide()" style="position:absolute; top:12px; right:12px; background:none; border:none; color:var(--text-dim); cursor:pointer; font-size:18px; line-height:1;"></button>
<div id="cheatSheetContent"></div>
</div>
</div>
<!-- Keyboard Shortcuts Modal -->
<div id="kbShortcutsModal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.7); z-index:10000; align-items:center; justify-content:center; padding:20px;" onclick="if(event.target===this)KeyboardShortcuts.hideHelp()">
<div style="background:var(--bg-card, #1a1f2e); border:1px solid rgba(255,255,255,0.15); border-radius:12px; max-width:520px; width:100%; max-height:80vh; overflow-y:auto; padding:20px; position:relative;">
<button onclick="KeyboardShortcuts.hideHelp()" style="position:absolute; top:12px; right:12px; background:none; border:none; color:var(--text-dim); cursor:pointer; font-size:18px; line-height:1;"></button>
<h2 style="margin:0 0 16px; font-size:16px; color:var(--accent-cyan, #4aa3ff); font-family:var(--font-mono);">Keyboard Shortcuts</h2>
<table style="width:100%; border-collapse:collapse; font-family:var(--font-mono); font-size:12px;">
<tbody>
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+W</td><td style="padding:6px 8px; color:var(--text-secondary);">Switch to Waterfall</td></tr>
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+M</td><td style="padding:6px 8px; color:var(--text-secondary);">Toggle voice mute</td></tr>
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+S</td><td style="padding:6px 8px; color:var(--text-secondary);">Toggle sidebar</td></tr>
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+K / ?</td><td style="padding:6px 8px; color:var(--text-secondary);">Show keyboard shortcuts</td></tr>
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+C</td><td style="padding:6px 8px; color:var(--text-secondary);">Show cheat sheet for current mode</td></tr>
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+1..9</td><td style="padding:6px 8px; color:var(--text-secondary);">Switch to Nth mode in current group</td></tr>
<tr><td style="padding:6px 8px; color:var(--accent-cyan);">Escape</td><td style="padding:6px 8px; color:var(--text-secondary);">Close modal</td></tr>
</tbody>
</table>
</div>
</div>

View File

@@ -21,7 +21,7 @@
</script>
<script src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
</head>
<body>
<body data-mode="satellite">
<div class="grid-bg"></div>
<div class="scanline"></div>
@@ -1830,9 +1830,22 @@
<!-- Help Modal -->
{% include 'partials/help-modal.html' %}
<script src="{{ url_for('static', filename='js/core/voice-alerts.js') }}?v={{ version }}&r=voicefix2"></script>
<script src="{{ url_for('static', filename='js/core/keyboard-shortcuts.js') }}"></script>
<script src="{{ url_for('static', filename='js/core/cheat-sheets.js') }}"></script>
{% include 'partials/nav-utility-modals.html' %}
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
<script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/ground_station_waterfall.js') }}"></script>
<script>
window.addEventListener('DOMContentLoaded', () => {
if (typeof VoiceAlerts !== 'undefined') {
VoiceAlerts.init({ startStreams: false });
VoiceAlerts.scheduleStreamStart(20000);
}
if (typeof KeyboardShortcuts !== 'undefined') KeyboardShortcuts.init();
});
</script>
<script>
// -------------------------------------------------------------------------

View File

@@ -90,7 +90,7 @@ class DopplerTracker:
return False
try:
ts = load.timescale()
ts = load.timescale(builtin=True)
satellite = EarthSatellite(tle[1], tle[2], tle[0], ts)
observer = wgs84.latlon(latitude, longitude)
except Exception as e:

View File

@@ -286,10 +286,10 @@ class GroundStationScheduler:
from utils.satellite_predict import predict_passes as _predict_passes
try:
ts = load.timescale()
ts = load.timescale(builtin=True)
except Exception:
from skyfield.api import load as _load
ts = _load.timescale()
ts = _load.timescale(builtin=True)
observer = wgs84.latlon(self._lat, self._lon)
now = datetime.now(timezone.utc)

View File

@@ -70,7 +70,7 @@ def predict_passes(
# patch sys.modules to simulate skyfield being unavailable).
import skyfield # noqa: F401
ts = load.timescale()
ts = load.timescale(builtin=True)
observer = wgs84.latlon(lat, lon)
t0 = ts.now()
t1 = ts.utc(t0.utc_datetime() + datetime.timedelta(hours=hours))