Merge upstream/main and resolve weather-satellite.js conflict

Resolved conflict in static/js/modes/weather-satellite.js:
- Kept allPasses state variable and applyPassFilter() for satellite pass filtering
- Kept satellite select dropdown listener for filter feature
- Adopted upstream's optimistic stop() UI pattern for better responsiveness
- Kept optional chaining (pass?.trajectory) since drawPolarPlot can receive null

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
mitchross
2026-02-26 00:37:02 -05:00
71 changed files with 13181 additions and 3658 deletions
+147 -46
View File
@@ -429,6 +429,7 @@
let agentPollTimer = null; // Polling fallback for agent mode
let isTracking = false;
let currentFilter = 'all';
// ICAO -> { emergency: bool, watchlist: bool, military: bool }
let alertedAircraft = {};
let alertsEnabled = true;
let detectionSoundEnabled = localStorage.getItem('adsb_detectionSound') !== 'false'; // Default on
@@ -678,24 +679,64 @@
}
}
function speakAircraftAlert(kind, icao, ac, detail) {
if (typeof VoiceAlerts === 'undefined' || typeof VoiceAlerts.speak !== 'function') return;
const cfg = (typeof VoiceAlerts.getConfig === 'function')
? VoiceAlerts.getConfig()
: { streams: {} };
const streams = cfg && cfg.streams ? cfg.streams : {};
const callsign = (ac && ac.callsign ? String(ac.callsign).trim() : '') || icao;
if (kind === 'emergency') {
if (streams.squawks === false) return;
const squawk = detail && detail.squawk ? ` squawk ${detail.squawk}.` : '.';
const meaning = detail && detail.name ? ` ${detail.name}.` : '';
VoiceAlerts.speak(`Aircraft emergency: ${callsign}.${squawk}${meaning}`, VoiceAlerts.PRIORITY.HIGH);
return;
}
if (kind === 'military') {
if (streams.adsb_military === false) return;
const country = detail && detail.country ? ` ${detail.country}.` : '';
VoiceAlerts.speak(`Military aircraft detected: ${callsign}.${country}`, VoiceAlerts.PRIORITY.HIGH);
}
}
function checkAndAlertAircraft(icao, ac) {
if (alertedAircraft[icao]) return;
if (!alertedAircraft[icao]) {
alertedAircraft[icao] = { emergency: false, watchlist: false, military: false };
}
const alertState = alertedAircraft[icao];
const militaryInfo = isMilitaryAircraft(icao, ac.callsign);
const squawkInfo = checkSquawkCode(ac);
const onWatchlist = isOnWatchlist(ac);
if (squawkInfo && squawkInfo.type === 'emergency') {
alertedAircraft[icao] = 'emergency';
playAlertSound('emergency');
showAlertBanner(`EMERGENCY: ${squawkInfo.name} - ${ac.callsign || icao}`, '#ff0000');
} else if (onWatchlist) {
alertedAircraft[icao] = 'watchlist';
if (!alertState.emergency) {
alertState.emergency = true;
playAlertSound('emergency');
showAlertBanner(`EMERGENCY: ${squawkInfo.name} - ${ac.callsign || icao}`, '#ff0000');
speakAircraftAlert('emergency', icao, ac, {
squawk: ac.squawk,
name: squawkInfo.name,
});
}
return;
}
if (onWatchlist && !alertState.watchlist) {
alertState.watchlist = true;
playAlertSound('military'); // Use military sound for watchlist
showAlertBanner(`WATCHLIST: ${ac.callsign || ac.registration || icao} detected!`, '#00d4ff');
} else if (militaryInfo.military) {
alertedAircraft[icao] = 'military';
} else if (militaryInfo.military && !alertState.military) {
alertState.military = true;
playAlertSound('military');
showAlertBanner(`MILITARY: ${ac.callsign || icao}${militaryInfo.country ? ' (' + militaryInfo.country + ')' : ''}`, '#556b2f');
speakAircraftAlert('military', icao, ac, {
country: militaryInfo.country || null,
});
}
}
@@ -2183,21 +2224,44 @@ sudo make install</code>
}
} else {
try {
// Route stop through agent proxy if using remote agent
const url = useAgent
? `/controller/agents/${adsbCurrentAgent}/adsb/stop`
// Route stop through the source that actually started tracking.
const stopSource = adsbTrackingSource || (useAgent ? adsbCurrentAgent : 'local');
const stopViaAgent = stopSource !== null && stopSource !== undefined && stopSource !== 'local';
const url = stopViaAgent
? `/controller/agents/${stopSource}/adsb/stop`
: '/adsb/stop';
await fetch(url, {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
body: JSON.stringify({ source: 'adsb_dashboard' })
});
const text = await response.text();
let data = {};
if (text) {
try {
data = JSON.parse(text);
} catch (e) {
throw new Error(`Invalid response: ${text}`);
}
}
const result = stopViaAgent && data.result ? data.result : data;
const stopped = response.ok && (
result.status === 'stopped' ||
result.status === 'success' ||
data.status === 'success'
);
if (!stopped) {
throw new Error(result.message || data.message || `HTTP ${response.status}`);
}
// Update agent running modes tracking
if (useAgent && typeof agentRunningModes !== 'undefined') {
if (stopViaAgent && typeof agentRunningModes !== 'undefined') {
agentRunningModes = agentRunningModes.filter(m => m !== 'adsb');
}
} catch (err) {}
} catch (err) {
alert('Failed to stop ADS-B: ' + err.message);
return;
}
stopEventStream();
isTracking = false;
@@ -2350,16 +2414,17 @@ sudo make install</code>
function startEventStream() {
if (eventSource) eventSource.close();
const useAgent = typeof adsbCurrentAgent !== 'undefined' && adsbCurrentAgent !== 'local';
const activeSource = (isTracking && adsbTrackingSource) ? adsbTrackingSource : adsbCurrentAgent;
const useAgent = typeof activeSource !== 'undefined' && activeSource !== null && activeSource !== 'local';
const streamUrl = useAgent ? '/controller/stream/all' : '/adsb/stream';
console.log(`[ADS-B] startEventStream called - adsbCurrentAgent=${adsbCurrentAgent}, useAgent=${useAgent}, streamUrl=${streamUrl}`);
console.log(`[ADS-B] startEventStream called - activeSource=${activeSource}, useAgent=${useAgent}, streamUrl=${streamUrl}`);
eventSource = new EventSource(streamUrl);
// Get agent name for filtering multi-agent stream
let targetAgentName = null;
if (useAgent && typeof agents !== 'undefined') {
const agent = agents.find(a => a.id == adsbCurrentAgent);
const agent = agents.find(a => a.id == activeSource);
targetAgentName = agent ? agent.name : null;
}
@@ -4253,30 +4318,56 @@ sudo make install</code>
.catch(err => alert('VDL2 Error: ' + err));
}
function stopVdl2() {
const isAgentMode = vdl2CurrentAgent !== null;
async function stopVdl2() {
const sourceAgentId = vdl2CurrentAgent;
const isAgentMode = sourceAgentId !== null && sourceAgentId !== undefined;
const endpoint = isAgentMode
? `/controller/agents/${vdl2CurrentAgent}/vdl2/stop`
? `/controller/agents/${sourceAgentId}/vdl2/stop`
: '/vdl2/stop';
fetch(endpoint, { method: 'POST' })
.then(r => r.json())
.then(() => {
isVdl2Running = false;
vdl2CurrentAgent = null;
document.getElementById('vdl2ToggleBtn').innerHTML = '&#9654; START VDL2';
document.getElementById('vdl2ToggleBtn').classList.remove('active');
document.getElementById('vdl2PanelIndicator').classList.remove('active');
if (vdl2EventSource) {
vdl2EventSource.close();
vdl2EventSource = null;
}
// Clear polling timer
if (vdl2PollTimer) {
clearInterval(vdl2PollTimer);
vdl2PollTimer = null;
}
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source: 'adsb_dashboard' })
});
const text = await response.text();
let data = {};
if (text) {
try {
data = JSON.parse(text);
} catch (e) {
throw new Error(`Invalid response: ${text}`);
}
}
const result = isAgentMode && data.result ? data.result : data;
const stopped = response.ok && (
result.status === 'stopped' ||
result.status === 'success' ||
data.status === 'success'
);
if (!stopped) {
throw new Error(result.message || data.message || `HTTP ${response.status}`);
}
isVdl2Running = false;
vdl2CurrentAgent = null;
document.getElementById('vdl2ToggleBtn').innerHTML = '&#9654; START VDL2';
document.getElementById('vdl2ToggleBtn').classList.remove('active');
document.getElementById('vdl2PanelIndicator').classList.remove('active');
if (vdl2EventSource) {
vdl2EventSource.close();
vdl2EventSource = null;
}
// Clear polling timer
if (vdl2PollTimer) {
clearInterval(vdl2PollTimer);
vdl2PollTimer = null;
}
} catch (err) {
alert('Failed to stop VDL2: ' + err.message);
}
}
// Sync VDL2 UI state (called by syncModeUI in agents.js)
@@ -4294,6 +4385,7 @@ sudo make install</code>
startVdl2Stream(agentId !== null);
}
} else {
vdl2CurrentAgent = null;
btn.innerHTML = '&#9654; START VDL2';
btn.classList.remove('active');
if (indicator) indicator.classList.remove('active');
@@ -5340,7 +5432,13 @@ sudo make install</code>
<!-- Help Modal -->
{% 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/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
<script>
window.addEventListener('DOMContentLoaded', () => {
if (typeof VoiceAlerts !== 'undefined') VoiceAlerts.init();
});
</script>
<!-- Agent Manager -->
<script src="{{ url_for('static', filename='js/core/agents.js') }}"></script>
@@ -5472,24 +5570,27 @@ sudo make install</code>
if (running) {
isTracking = true;
const normalizedSource = source === null || source === undefined
? (adsbTrackingSource || 'local')
: source;
// If source is an agent ID (not 'local' and not null), update adsbCurrentAgent
// This ensures startEventStream uses the correct routing
if (source && source !== 'local') {
adsbCurrentAgent = source;
if (normalizedSource !== 'local') {
adsbCurrentAgent = normalizedSource;
// Also update the dropdown to match
const agentSelect = document.getElementById('agentSelect');
if (agentSelect) {
agentSelect.value = source;
agentSelect.value = normalizedSource;
}
// Update global agent state too
if (typeof currentAgent !== 'undefined') {
currentAgent = source;
currentAgent = normalizedSource;
}
console.log(`[ADS-B] Updated adsbCurrentAgent to ${source}`);
console.log(`[ADS-B] Updated adsbCurrentAgent to ${normalizedSource}`);
}
adsbTrackingSource = source || adsbCurrentAgent; // Track which source is active
adsbTrackingSource = normalizedSource; // Track which source is active
// Update button
if (btn) {
@@ -5509,9 +5610,9 @@ sudo make install</code>
if (agentSelect) agentSelect.disabled = true;
// Start data stream for the active source
const isAgentSource = adsbCurrentAgent !== 'local';
const isAgentSource = normalizedSource !== 'local';
if (isAgentSource) {
console.log(`[ADS-B] Starting data stream from agent ${adsbCurrentAgent}`);
console.log(`[ADS-B] Starting data stream from agent ${normalizedSource}`);
startEventStream();
drawRangeRings();
startSessionTimer();
+507 -94
View File
@@ -79,36 +79,90 @@
gps: "{{ url_for('static', filename='css/modes/gps.css') }}",
subghz: "{{ url_for('static', filename='css/modes/subghz.css') }}?v={{ version }}&r=subghz_layout9",
bt_locate: "{{ url_for('static', filename='css/modes/bt_locate.css') }}?v={{ version }}&r=btlocate4",
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') }}",
morse: "{{ url_for('static', filename='css/modes/morse.css') }}"
};
window.INTERCEPT_MODE_STYLE_LOADED = {};
window.INTERCEPT_MODE_STYLE_PROMISES = {};
window.ensureModeStyles = function(mode) {
const href = window.INTERCEPT_MODE_STYLE_MAP ? window.INTERCEPT_MODE_STYLE_MAP[mode] : null;
if (!href) return;
if (!href) return Promise.resolve();
if (window.INTERCEPT_MODE_STYLE_LOADED[href] === 'loaded') {
return Promise.resolve();
}
if (window.INTERCEPT_MODE_STYLE_PROMISES[href]) {
return window.INTERCEPT_MODE_STYLE_PROMISES[href];
}
const absHref = new URL(href, window.location.href).href;
const existing = Array.from(document.querySelectorAll('link[data-mode-style]'))
.find((link) => link.href === absHref);
if (existing) {
if (existing && existing.sheet) {
window.INTERCEPT_MODE_STYLE_LOADED[href] = 'loaded';
return;
return Promise.resolve();
}
if (window.INTERCEPT_MODE_STYLE_LOADED[href] === 'loading') return;
window.INTERCEPT_MODE_STYLE_LOADED[href] = 'loading';
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
link.dataset.modeStyle = mode;
link.onload = () => {
window.INTERCEPT_MODE_STYLE_LOADED[href] = 'loaded';
};
link.onerror = () => {
delete window.INTERCEPT_MODE_STYLE_LOADED[href];
try {
link.remove();
} catch (_) {}
};
document.head.appendChild(link);
const link = existing || document.createElement('link');
if (!existing) {
link.rel = 'stylesheet';
link.href = href;
link.dataset.modeStyle = mode;
}
const promise = new Promise((resolve, reject) => {
const onLoad = () => {
window.INTERCEPT_MODE_STYLE_LOADED[href] = 'loaded';
delete window.INTERCEPT_MODE_STYLE_PROMISES[href];
resolve();
};
const onError = () => {
delete window.INTERCEPT_MODE_STYLE_LOADED[href];
delete window.INTERCEPT_MODE_STYLE_PROMISES[href];
try {
link.remove();
} catch (_) {}
reject(new Error(`failed to load mode stylesheet: ${mode}`));
};
link.addEventListener('load', onLoad, { once: true });
link.addEventListener('error', onError, { once: true });
if (existing) {
// Existing links may have finished loading before listeners attached.
if (existing.sheet) onLoad();
} else {
document.head.appendChild(link);
}
});
window.INTERCEPT_MODE_STYLE_PROMISES[href] = promise;
return promise;
};
// Start loading a deep-linked mode stylesheet as early as possible.
(function preloadQueryModeStyles() {
const queryMode = new URLSearchParams(window.location.search).get('mode');
const mode = queryMode === 'listening' ? 'waterfall' : queryMode;
if (!mode) return;
window.ensureModeStyles(mode).catch(() => {});
})();
// Warm remaining lazy mode styles in the background to avoid first-switch FOUC.
(function warmModeStylesInBackground() {
const modeMap = window.INTERCEPT_MODE_STYLE_MAP || {};
const queryMode = new URLSearchParams(window.location.search).get('mode');
const selectedMode = queryMode === 'listening' ? 'waterfall' : queryMode;
const modes = Object.keys(modeMap).filter((mode) => mode !== selectedMode);
if (!modes.length) return;
const warm = function () {
modes.forEach(function (mode, index) {
setTimeout(function () {
window.ensureModeStyles(mode).catch(() => {});
}, index * 40);
});
};
if (typeof window.requestIdleCallback === 'function') {
window.requestIdleCallback(warm, { timeout: 2000 });
} else {
setTimeout(warm, 600);
}
})();
</script>
</head>
@@ -141,6 +195,12 @@
</svg>
</div>
<div class="welcome-container">
<button type="button" class="welcome-settings-btn" onclick="showSettings()" title="Settings" aria-label="Open settings">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
<!-- Header Section -->
<div class="welcome-header">
<div class="welcome-logo">
@@ -218,6 +278,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"><path d="M2 12h4l3-8 3 16 3-8h4"/><path d="M2 18h20" opacity="0.5"/><path d="M2 21h20" opacity="0.3"/></svg></span>
<span class="mode-name">Waterfall</span>
</button>
<button class="mode-card mode-card-sm" onclick="selectMode('morse')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="2" y1="12" x2="5" y2="12"/><line x1="7" y1="12" x2="13" y2="12"/><line x1="15" y1="12" x2="18" y2="12"/><line x1="20" y1="12" x2="22" y2="12"/></svg></span>
<span class="mode-name">Morse</span>
</button>
</div>
</div>
@@ -264,6 +328,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"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg></span>
<span class="mode-name">HF SSTV</span>
</button>
<button class="mode-card mode-card-sm" onclick="selectMode('wefax')">
<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="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg></span>
<span class="mode-name">WeFax</span>
</button>
<button class="mode-card mode-card-sm" onclick="selectMode('spaceweather')">
<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="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></span>
<span class="mode-name">Space Wx</span>
@@ -616,6 +684,10 @@
{% include 'partials/modes/gps.html' %}
{% include 'partials/modes/wefax.html' %}
{% include 'partials/modes/morse.html' %}
{% include 'partials/modes/space-weather.html' %}
{% include 'partials/modes/tscm.html' %}
@@ -1182,23 +1254,25 @@
<!-- GPS Receiver Dashboard -->
<div id="gpsVisuals" class="gps-visuals-container" style="display: none;">
<div class="gps-visuals-top">
<!-- Sky View Polar Plot -->
<!-- Sky View Globe -->
<div class="gps-skyview-panel">
<h4>Satellite Sky View</h4>
<h4>Satellite Globe View</h4>
<div class="gps-skyview-canvas-wrap" id="gpsSkyViewWrap">
<canvas id="gpsSkyCanvas" width="400" height="400" aria-label="GPS satellite sky globe"></canvas>
<div id="gpsSkyGlobe" class="gps-sky-globe" aria-label="GPS satellite globe"></div>
<canvas id="gpsSkyCanvas" width="400" height="400" aria-label="GPS satellite sky fallback globe"></canvas>
<div class="gps-sky-overlay" id="gpsSkyOverlay" aria-hidden="true"></div>
</div>
<div class="gps-sky-hint">Drag to orbit | Scroll to zoom</div>
<div class="gps-sky-hint">Drag to orbit globe | Scroll to zoom | Hover satellites for details</div>
<div class="gps-legend">
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#ffffff;"></span> Observer</div>
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#00d4ff;"></span> GPS</div>
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#00ff88;"></span> GLONASS</div>
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#ff8800;"></span> Galileo</div>
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#ff4466;"></span> BeiDou</div>
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#ffdd00;"></span> SBAS</div>
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#cc66ff;"></span> QZSS</div>
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#00d4ff;"></span> Used (filled)</div>
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:transparent; border:1.5px solid #00d4ff;"></span> Unused (hollow)</div>
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#00d4ff;"></span> Used (bright)</div>
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:rgba(0,212,255,0.45);"></span> Unused (dim)</div>
</div>
</div>
<!-- Position Info -->
@@ -2527,6 +2601,137 @@
</div>
</div>
<!-- WeFax Decoder Dashboard -->
<div id="wefaxVisuals" class="wefax-visuals-container" style="display: none;">
<!-- Stats Strip -->
<div class="wefax-stats-strip">
<div class="wefax-strip-group">
<div class="wefax-strip-status">
<span class="wefax-strip-dot idle" id="wefaxStripDot"></span>
<span class="wefax-strip-status-text" id="wefaxStripStatus">Idle</span>
</div>
<button class="wefax-strip-btn start" id="wefaxStartBtn" onclick="WeFax.start()">Start</button>
<button class="wefax-strip-btn stop" id="wefaxStopBtn" onclick="WeFax.stop()" style="display: none;">Stop</button>
<label class="wefax-schedule-toggle" title="Auto-capture broadcasts">
<input type="checkbox" id="wefaxStripAutoSchedule"
onchange="WeFax.toggleScheduler(this)">
<span>Auto</span>
</label>
</div>
<div class="wefax-strip-divider"></div>
<div class="wefax-strip-group">
<div class="wefax-strip-stat">
<span class="wefax-strip-value accent-amber" id="wefaxStripFreq">---</span>
<span class="wefax-strip-label">KHZ</span>
</div>
<div class="wefax-strip-stat">
<span class="wefax-strip-value" id="wefaxStripLines">0</span>
<span class="wefax-strip-label">LINES</span>
</div>
<div class="wefax-strip-stat">
<span class="wefax-strip-value" id="wefaxStripImageCount">0</span>
<span class="wefax-strip-label">IMAGES</span>
</div>
</div>
</div>
<!-- Countdown + Timeline -->
<div class="wefax-countdown-bar" id="wefaxCountdownBar" style="display: none;">
<div class="wefax-countdown-next">
<div class="wefax-countdown-boxes" id="wefaxCountdownBoxes">
<div class="wefax-countdown-box"><span class="wefax-cd-value" id="wefaxCdHours">--</span><span class="wefax-cd-unit">HRS</span></div>
<div class="wefax-countdown-box"><span class="wefax-cd-value" id="wefaxCdMins">--</span><span class="wefax-cd-unit">MIN</span></div>
<div class="wefax-countdown-box"><span class="wefax-cd-value" id="wefaxCdSecs">--</span><span class="wefax-cd-unit">SEC</span></div>
</div>
<div class="wefax-countdown-info" id="wefaxCountdownInfo">
<span class="wefax-countdown-content" id="wefaxCountdownContent">--</span>
<span class="wefax-countdown-detail" id="wefaxCountdownDetail">Select a station</span>
</div>
</div>
<div class="wefax-timeline" id="wefaxTimeline">
<div class="wefax-timeline-track" id="wefaxTimelineTrack"></div>
<div class="wefax-timeline-cursor" id="wefaxTimelineCursor"></div>
<div class="wefax-timeline-labels">
<span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span>
</div>
</div>
</div>
<!-- Audio Waveform Scope -->
<div id="wefaxScopePanel" style="display: none;">
<div style="background: #0a0a0a; border: 1px solid #2e2a1a; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
<span>Audio Waveform</span>
<div style="display: flex; gap: 14px;">
<span>RMS: <span id="wefaxScopeRmsLabel" style="color: #ffaa00; font-variant-numeric: tabular-nums;">0</span></span>
<span>PEAK: <span id="wefaxScopePeakLabel" style="color: #f44; font-variant-numeric: tabular-nums;">0</span></span>
<span id="wefaxScopeStatusLabel" style="color: #444;">IDLE</span>
</div>
</div>
<canvas id="wefaxScopeCanvas" style="width: 100%; height: 80px; display: block; border-radius: 3px; background: #050510;"></canvas>
</div>
</div>
<!-- Schedule Timeline -->
<div class="wefax-schedule-panel">
<div class="wefax-schedule-header">
<span class="wefax-schedule-title">Broadcast Schedule</span>
<span id="wefaxStatusText" style="font-family: var(--font-mono); font-size: 10px; color: var(--text-dim);"></span>
</div>
<div id="wefaxScheduleTimeline">
<div class="wefax-schedule-empty">Select a station to see broadcast schedule</div>
</div>
</div>
<!-- Main Content: Live Preview + Gallery -->
<div class="wefax-main-row">
<div class="wefax-live-section">
<div class="wefax-live-header">
<div class="wefax-live-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="vertical-align: -2px; margin-right: 4px; color: #ffaa00;">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
Live Decode
</div>
</div>
<div class="wefax-live-content" id="wefaxLiveContent">
<div class="wefax-idle-state" id="wefaxIdleState">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
</svg>
<h4>WeFax Decoder</h4>
<p>Select a station and click Start to decode weather fax transmissions</p>
</div>
<img id="wefaxLivePreview" class="wefax-live-preview" style="display: none;" alt="WeFax decode in progress">
</div>
</div>
<div class="wefax-gallery-section">
<div class="wefax-gallery-header">
<div class="wefax-gallery-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="vertical-align: -2px; margin-right: 4px; color: #ffaa00;">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
Decoded Images
</div>
<div class="wefax-gallery-controls">
<span class="wefax-gallery-count" id="wefaxImageCount">0</span>
<button class="wefax-gallery-clear-btn" onclick="WeFax.deleteAllImages()" title="Delete all images">Clear All</button>
</div>
</div>
<div class="wefax-gallery-grid" id="wefaxGallery">
<div class="wefax-gallery-empty">No images decoded yet</div>
</div>
</div>
</div>
</div>
<!-- Space Weather Dashboard -->
<div id="spaceWeatherVisuals" class="sw-visuals-container" style="display: none;">
<!-- Header metrics strip -->
@@ -2809,6 +3014,7 @@
</div>
</div>
<!-- Device Intelligence Dashboard (above waterfall for prominence) -->
<div class="recon-panel collapsed" id="reconPanel">
<div class="recon-header" onclick="toggleReconCollapse()" style="cursor: pointer;">
@@ -2864,6 +3070,42 @@
<div id="sensorTimelineContainer" style="display: none; margin-bottom: 12px;"></div>
<!-- Morse Signal Scope -->
<div id="morseScopePanel" style="display: none; margin-bottom: 12px;">
<div style="background: #0a0a0a; border: 1px solid #1a2e1a; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
<span>CW Tone Detection</span>
<div style="display: flex; gap: 14px;">
<span>TONE: <span id="morseScopeToneLabel" style="color: #0f0; font-variant-numeric: tabular-nums;">--</span></span>
<span>THRESH: <span id="morseScopeThreshLabel" style="color: #fa0; font-variant-numeric: tabular-nums;">--</span></span>
<span id="morseScopeStatusLabel" style="color: #444;">IDLE</span>
</div>
</div>
<canvas id="morseScopeCanvas" style="width: 100%; height: 80px; display: block; border-radius: 3px; background: #050510;"></canvas>
</div>
</div>
<!-- Morse Decoded Output -->
<div id="morseOutputPanel" style="display: none; margin-bottom: 12px;">
<div style="background: #0a0a0a; border: 1px solid #1a2e1a; border-radius: 6px; padding: 8px 10px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
<span>Decoded Text</span>
<div style="display: flex; gap: 6px;">
<button class="btn btn-sm btn-ghost" onclick="MorseMode.exportTxt()">TXT</button>
<button class="btn btn-sm btn-ghost" onclick="MorseMode.exportCsv()">CSV</button>
<button class="btn btn-sm btn-ghost" id="morseCopyBtn" onclick="MorseMode.copyToClipboard()">Copy</button>
<button class="btn btn-sm btn-ghost" onclick="MorseMode.clearText()">Clear</button>
</div>
</div>
<div id="morseDecodedText" class="morse-decoded-panel"></div>
<div class="morse-status-bar">
<span class="status-item" id="morseStatusBarWpm">15 WPM</span>
<span class="status-item" id="morseStatusBarTone">700 Hz</span>
<span class="status-item" id="morseStatusBarChars">0 chars decoded</span>
</div>
</div>
</div>
<div class="output-content signal-feed" id="output">
<div class="placeholder signal-empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
@@ -2932,6 +3174,8 @@
<script src="{{ url_for('static', filename='js/modes/websdr.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/subghz.js') }}?v={{ version }}&r=subghz_layout9"></script>
<script src="{{ url_for('static', filename='js/modes/bt_locate.js') }}?v={{ version }}&r=btlocate4"></script>
<script src="{{ url_for('static', filename='js/modes/wefax.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/morse.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/space-weather.js') }}"></script>
<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>
@@ -3075,6 +3319,7 @@
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' },
sstv_general: { label: 'HF SSTV', indicator: 'HF SSTV', outputTitle: 'HF SSTV Decoder', group: 'space' },
wefax: { label: 'WeFax', indicator: 'WEFAX', outputTitle: 'Weather Fax Decoder', group: 'space' },
spaceweather: { label: 'Space Weather', indicator: 'SPACE WX', outputTitle: 'Space Weather Monitor', group: 'space' },
wifi: { label: 'WiFi', indicator: 'WIFI', outputTitle: 'WiFi Scanner', group: 'wireless' },
bluetooth: { label: 'Bluetooth', indicator: 'BLUETOOTH', outputTitle: 'Bluetooth Scanner', group: 'wireless' },
@@ -3084,6 +3329,7 @@
spystations: { label: 'Spy Stations', indicator: 'SPY STATIONS', outputTitle: 'Spy Stations', group: 'intel' },
websdr: { label: 'WebSDR', indicator: 'WEBSDR', outputTitle: 'HF/Shortwave WebSDR', group: 'intel' },
waterfall: { label: 'Waterfall', indicator: 'WATERFALL', outputTitle: 'Spectrum Waterfall', group: 'signals' },
morse: { label: 'Morse', indicator: 'MORSE', outputTitle: 'CW/Morse Decoder', group: 'signals' },
};
const validModes = new Set(Object.keys(modeCatalog));
window.interceptModeCatalog = Object.assign({}, modeCatalog);
@@ -3740,6 +3986,11 @@
const previousMode = currentMode;
if (mode === 'listening') mode = 'waterfall';
if (!validModes.has(mode)) mode = 'pager';
const styleReadyPromise = (typeof window.ensureModeStyles === 'function')
? Promise.resolve(window.ensureModeStyles(mode)).catch((err) => {
console.warn(`[ModeSwitch] style load failed for ${mode}: ${err?.message || err}`);
})
: Promise.resolve();
// Only stop local scans if in local mode (not agent mode)
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
const stopPhaseStartMs = performance.now();
@@ -3783,6 +4034,7 @@
stopTaskCount = stopTasks.length;
}
const stopPhaseMs = Math.round(performance.now() - stopPhaseStartMs);
await styleReadyPromise;
// Clean up SubGHz SSE connection when leaving the mode
if (typeof SubGhz !== 'undefined' && currentMode === 'subghz' && mode !== 'subghz') {
@@ -3808,10 +4060,6 @@
closeAllDropdowns();
updateDropdownActiveState();
if (typeof window.ensureModeStyles === 'function') {
window.ensureModeStyles(mode);
}
// Remove active from all nav buttons, then add to the correct one
document.querySelectorAll('.mode-nav-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.mode === mode);
@@ -3826,6 +4074,7 @@
document.getElementById('sstvMode')?.classList.toggle('active', mode === 'sstv');
document.getElementById('weatherSatMode')?.classList.toggle('active', mode === 'weathersat');
document.getElementById('sstvGeneralMode')?.classList.toggle('active', mode === 'sstv_general');
document.getElementById('wefaxMode')?.classList.toggle('active', mode === 'wefax');
document.getElementById('gpsMode')?.classList.toggle('active', mode === 'gps');
document.getElementById('wifiMode')?.classList.toggle('active', mode === 'wifi');
document.getElementById('bluetoothMode')?.classList.toggle('active', mode === 'bluetooth');
@@ -3839,6 +4088,7 @@
document.getElementById('subghzMode')?.classList.toggle('active', mode === 'subghz');
document.getElementById('spaceWeatherMode')?.classList.toggle('active', mode === 'spaceweather');
document.getElementById('waterfallMode')?.classList.toggle('active', mode === 'waterfall');
document.getElementById('morseMode')?.classList.toggle('active', mode === 'morse');
const pagerStats = document.getElementById('pagerStats');
@@ -3877,6 +4127,7 @@
const websdrVisuals = document.getElementById('websdrVisuals');
const subghzVisuals = document.getElementById('subghzVisuals');
const btLocateVisuals = document.getElementById('btLocateVisuals');
const wefaxVisuals = document.getElementById('wefaxVisuals');
const spaceWeatherVisuals = document.getElementById('spaceWeatherVisuals');
const waterfallVisuals = document.getElementById('waterfallVisuals');
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
@@ -3893,6 +4144,7 @@
if (websdrVisuals) websdrVisuals.style.display = mode === 'websdr' ? 'flex' : 'none';
if (subghzVisuals) subghzVisuals.style.display = mode === 'subghz' ? 'flex' : 'none';
if (btLocateVisuals) btLocateVisuals.style.display = mode === 'bt_locate' ? 'flex' : 'none';
if (wefaxVisuals) wefaxVisuals.style.display = mode === 'wefax' ? 'flex' : 'none';
if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? 'flex' : 'none';
if (waterfallVisuals) waterfallVisuals.style.display = mode === 'waterfall' ? 'flex' : 'none';
@@ -3916,6 +4168,10 @@
const sensorTimelineContainer = document.getElementById('sensorTimelineContainer');
if (pagerTimelineContainer) pagerTimelineContainer.style.display = mode === 'pager' ? 'block' : 'none';
if (sensorTimelineContainer) sensorTimelineContainer.style.display = mode === 'sensor' ? 'block' : 'none';
const morseScopePanel = document.getElementById('morseScopePanel');
const morseOutputPanel = document.getElementById('morseOutputPanel');
if (morseScopePanel && mode !== 'morse') morseScopePanel.style.display = 'none';
if (morseOutputPanel && mode !== 'morse') morseOutputPanel.style.display = 'none';
// Update output panel title based on mode
const outputTitle = document.getElementById('outputTitle');
@@ -3940,11 +4196,16 @@
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();
}
// Show/hide Device Intelligence for modes that use it (not for satellite/aircraft/tscm)
const reconBtn = document.getElementById('reconBtn');
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
const reconPanel = document.getElementById('reconPanel');
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'gps' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall') {
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'gps' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall') {
if (reconPanel) reconPanel.style.display = 'none';
if (reconBtn) reconBtn.style.display = 'none';
if (intelBtn) intelBtn.style.display = 'none';
@@ -3964,7 +4225,27 @@
// Show RTL-SDR device section for modes that use it
const rtlDeviceSection = document.getElementById('rtlDeviceSection');
if (rtlDeviceSection) rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'aprs' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general') ? 'block' : 'none';
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';
// Save original sidebar position of SDR device section (once)
if (!rtlDeviceSection._origParent) {
rtlDeviceSection._origParent = rtlDeviceSection.parentNode;
rtlDeviceSection._origNext = rtlDeviceSection.nextElementSibling;
}
// For morse mode, move SDR device section inside the morse panel after the title
const morsePanel = document.getElementById('morseMode');
if (mode === 'morse' && morsePanel) {
const firstSection = morsePanel.querySelector('.section');
if (firstSection) firstSection.after(rtlDeviceSection);
} else if (rtlDeviceSection._origParent && rtlDeviceSection.parentNode !== rtlDeviceSection._origParent) {
// Restore to original sidebar position when leaving morse mode
if (rtlDeviceSection._origNext) {
rtlDeviceSection._origNext.before(rtlDeviceSection);
} else {
rtlDeviceSection._origParent.appendChild(rtlDeviceSection);
}
}
}
// Toggle mode-specific tool status displays
const toolStatusPager = document.getElementById('toolStatusPager');
@@ -3975,7 +4256,7 @@
// Hide output console for modes with their own visualizations
const outputEl = document.getElementById('output');
const statusBar = document.querySelector('.status-bar');
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'bt_locate' || mode === 'waterfall') ? 'none' : 'block';
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'bt_locate' || mode === 'waterfall') ? 'none' : 'block';
if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall') ? 'none' : 'flex';
// Restore sidebar when leaving Meshtastic mode (user may have collapsed it)
@@ -4042,10 +4323,14 @@
setTimeout(() => {
if (typeof BtLocate !== 'undefined' && BtLocate.invalidateMap) BtLocate.invalidateMap();
}, 320);
} else if (mode === 'wefax') {
WeFax.init();
} else if (mode === 'spaceweather') {
SpaceWeather.init();
} else if (mode === 'waterfall') {
if (typeof Waterfall !== 'undefined') Waterfall.init();
} else if (mode === 'morse') {
MorseMode.init();
}
// Destroy Waterfall WebSocket when leaving SDR receiver modes
@@ -5449,10 +5734,19 @@
renderSdrStatus(devices);
})
.catch(err => {
console.error('Failed to fetch SDR status:', err);
const transient = (typeof window.isTransientOrOffline === 'function' && window.isTransientOrOffline(err)) ||
(typeof navigator !== 'undefined' && navigator.onLine === false) ||
/failed to fetch|network io suspended|networkerror|timeout/i.test(String((err && err.message) || err || ''));
if (!transient) {
console.error('Failed to fetch SDR status:', err);
}
const container = document.getElementById('sdrStatusList');
if (container) {
container.innerHTML = '<div style="padding: 8px; color: #ff6666; font-size: 11px; text-align: center;">Error loading status</div>';
if (transient) {
container.innerHTML = '<div style="padding: 8px; color: #888; font-size: 11px; text-align: center;">Status temporarily unavailable</div>';
} else {
container.innerHTML = '<div style="padding: 8px; color: #ff6666; font-size: 11px; text-align: center;">Error loading status</div>';
}
}
});
}
@@ -9083,19 +9377,26 @@
return R * c;
}
function aprsHasValidCoordinates(lat, lon) {
return lat != null && lon != null &&
Number.isFinite(Number(lat)) && Number.isFinite(Number(lon));
}
// Update APRS user location from GPS
function updateAprsUserLocation(position) {
if (!position || !position.latitude || !position.longitude) return;
const lat = Number(position && position.latitude);
const lon = Number(position && position.longitude);
if (!aprsHasValidCoordinates(lat, lon)) return;
aprsUserLocation.lat = position.latitude;
aprsUserLocation.lon = position.longitude;
aprsUserLocation.lat = lat;
aprsUserLocation.lon = lon;
// Update user marker on map
if (aprsMap) {
if (aprsUserMarker) {
aprsUserMarker.setLatLng([position.latitude, position.longitude]);
aprsUserMarker.setLatLng([lat, lon]);
} else {
aprsUserMarker = L.marker([position.latitude, position.longitude], {
aprsUserMarker = L.marker([lat, lon], {
icon: L.divIcon({
className: 'aprs-user-marker',
html: '<div style="width: 14px; height: 14px; background: #ff0; border: 2px solid #000; border-radius: 50%; box-shadow: 0 0 10px #ff0;"></div>',
@@ -9108,7 +9409,7 @@
// Center map on first GPS fix
if (!aprsMap._gpsInitialized) {
aprsMap.setView([position.latitude, position.longitude], 8);
aprsMap.setView([lat, lon], 8);
aprsMap._gpsInitialized = true;
}
}
@@ -9123,7 +9424,7 @@
// Update distances for all stations in the list
function updateAprsStationDistances() {
if (!aprsUserLocation.lat || !aprsUserLocation.lon) return;
if (!aprsHasValidCoordinates(aprsUserLocation.lat, aprsUserLocation.lon)) return;
// Update station list items
const listEl = document.getElementById('aprsStationList');
@@ -9180,9 +9481,14 @@
if (!mapContainer) return;
// Use GPS location if available, otherwise default to center of US
const initialLat = aprsUserLocation.lat || gpsLastPosition?.latitude || 39.8283;
const initialLon = aprsUserLocation.lon || gpsLastPosition?.longitude || -98.5795;
const initialZoom = (aprsUserLocation.lat || gpsLastPosition?.latitude) ? 8 : 4;
const gpsLat = Number(gpsLastPosition && gpsLastPosition.latitude);
const gpsLon = Number(gpsLastPosition && gpsLastPosition.longitude);
const hasUserLocation = aprsHasValidCoordinates(aprsUserLocation.lat, aprsUserLocation.lon);
const hasGpsLocation = aprsHasValidCoordinates(gpsLat, gpsLon);
const initialLat = hasUserLocation ? aprsUserLocation.lat : (hasGpsLocation ? gpsLat : 39.8283);
const initialLon = hasUserLocation ? aprsUserLocation.lon : (hasGpsLocation ? gpsLon : -98.5795);
const initialZoom = (hasUserLocation || hasGpsLocation) ? 8 : 4;
aprsMap = L.map('aprsMap').setView([initialLat, initialLon], initialZoom);
window.aprsMap = aprsMap;
@@ -9203,8 +9509,8 @@
}
// Add user marker if GPS position is already available
if (gpsConnected && gpsLastPosition && gpsLastPosition.latitude && gpsLastPosition.longitude) {
updateAprsUserLocation(gpsLastPosition);
if (gpsConnected && hasGpsLocation) {
updateAprsUserLocation({ latitude: gpsLat, longitude: gpsLon });
aprsMap._gpsInitialized = true;
}
@@ -9251,6 +9557,104 @@
// APRS mode polling timer for agent mode
let aprsPollTimer = null;
let aprsCurrentAgent = null;
const aprsAgentStationSignatures = new Map();
function resetAprsAgentStationTracking() {
aprsAgentStationSignatures.clear();
}
function extractAprsStationsFromPayload(payload) {
if (!payload) return [];
if (Array.isArray(payload)) return payload;
if (Array.isArray(payload.stations)) return payload.stations;
if (Array.isArray(payload.data)) return payload.data;
if (payload.data && Array.isArray(payload.data.stations)) return payload.data.stations;
if (payload.data && Array.isArray(payload.data.data)) return payload.data.data;
if (payload.result && Array.isArray(payload.result.stations)) return payload.result.stations;
if (payload.result && Array.isArray(payload.result.data)) return payload.result.data;
if (payload.data && payload.data.result && Array.isArray(payload.data.result.stations)) {
return payload.data.result.stations;
}
return [];
}
function getAprsStationSignature(station) {
if (!station || typeof station !== 'object') return '';
const receivedAt = station.received_at || station.last_seen || station.timestamp || '';
const lat = station.lat ?? station.latitude ?? '';
const lon = station.lon ?? station.longitude ?? '';
const payloadHint = station.raw || station.comment || station.path || '';
return `${receivedAt}|${lat},${lon}|${payloadHint}`;
}
function processAprsAgentStations(stations, agentName) {
if (!Array.isArray(stations) || stations.length === 0) return;
stations.forEach((station) => {
const callsign = String(station && station.callsign ? station.callsign : '').trim();
if (!callsign) return;
const lat = station.lat ?? station.latitude ?? null;
const lon = station.lon ?? station.longitude ?? null;
const signature = getAprsStationSignature(station);
if (aprsAgentStationSignatures.get(callsign) === signature) return;
aprsAgentStationSignatures.set(callsign, signature);
aprsPacketCount++;
document.getElementById('aprsPacketCount').textContent = aprsPacketCount;
document.getElementById('aprsStripPackets').textContent = aprsPacketCount;
const dot = document.getElementById('aprsStripDot');
if (dot && !dot.classList.contains('tracking')) {
updateAprsStatus('tracking');
}
processAprsPacket({
type: 'aprs',
...station,
lat,
lon,
callsign,
agent_name: station.agent_name || agentName || 'Remote Agent'
});
});
}
async function loadAprsStationSnapshot(isAgentMode = false) {
try {
const endpoint = (isAgentMode && aprsCurrentAgent)
? `/controller/agents/${aprsCurrentAgent}/aprs/data`
: '/aprs/stations';
const response = await fetch(endpoint);
if (!response.ok) return;
const payload = await response.json();
const stations = extractAprsStationsFromPayload(payload);
if (!Array.isArray(stations) || stations.length === 0) return;
if (isAgentMode) {
processAprsAgentStations(stations, payload.agent_name);
return;
}
stations.forEach((station) => {
const callsign = String(station && station.callsign ? station.callsign : '').trim();
if (!callsign) return;
const packet = {
type: 'aprs',
...station,
callsign,
lat: station.lat ?? station.latitude ?? null,
lon: station.lon ?? station.longitude ?? null,
packet_type: station.packet_type || 'position',
};
if (aprsHasValidCoordinates(packet.lat, packet.lon) && aprsMap) {
updateAprsMarker(packet);
}
updateAprsStationList(packet);
});
} catch (err) {
console.debug('APRS snapshot load failed:', err);
}
}
function startAprs() {
// Get values from function bar controls
@@ -9300,6 +9704,16 @@
isAprsRunning = true;
aprsPacketCount = 0;
aprsStationCount = 0;
resetAprsAgentStationTracking();
if (aprsMap) {
Object.values(aprsMarkers).forEach((marker) => {
try {
aprsMap.removeLayer(marker);
} catch (_) {}
});
}
aprsMarkers = {};
// Initialize APRS filter bar and clear history
const filterContainer = document.getElementById('aprsFilterBarContainer');
@@ -9312,6 +9726,12 @@
// Clear existing station cards
stationList.innerHTML = '<div class="signal-cards-placeholder" style="padding: 20px; text-align: center; color: var(--text-muted);">Waiting for stations...</div>';
const packetLog = document.getElementById('aprsPacketLog');
if (packetLog) {
packetLog.innerHTML = '<div style="color: var(--text-muted);">Waiting for packets...</div>';
}
document.getElementById('aprsPacketCount').textContent = '0';
document.getElementById('aprsStationCount').textContent = '0';
// Update function bar buttons
document.getElementById('aprsStripStartBtn').style.display = 'none';
@@ -9332,6 +9752,9 @@
if (customFreqInput) customFreqInput.disabled = true;
startAprsMeterCheck();
startAprsStream(isAgentMode);
// Backfill current stations in case position packets arrived before
// map initialization or SSE attachment.
loadAprsStationSnapshot(isAgentMode);
} else {
alert('APRS Error: ' + (scanResult.message || scanResult.error || 'Failed to start'));
updateAprsStatus('error');
@@ -9343,7 +9766,7 @@
});
}
function stopAprs() {
async function stopAprs() {
const isAgentMode = aprsCurrentAgent !== null;
const endpoint = isAgentMode
? `/controller/agents/${aprsCurrentAgent}/aprs/stop`
@@ -9352,9 +9775,9 @@
isAprsRunning = false;
aprsCurrentAgent = null;
document.getElementById('aprsStripStartBtn').style.display = 'inline-block';
resetAprsAgentStationTracking();
document.getElementById('aprsStripStopBtn').style.display = 'none';
document.getElementById('aprsMapStatus').textContent = 'STANDBY';
document.getElementById('aprsMapStatus').textContent = 'STOPPING';
document.getElementById('aprsMapStatus').style.color = '';
updateAprsStatus('standby');
document.getElementById('aprsStripFreq').textContent = '--';
@@ -9377,7 +9800,9 @@
aprsPollTimer = null;
}
return postStopRequest(endpoint, timeoutMs);
await postStopRequest(endpoint, timeoutMs);
document.getElementById('aprsStripStartBtn').style.display = 'inline-block';
document.getElementById('aprsMapStatus').textContent = 'STANDBY';
}
function startAprsStream(isAgentMode = false) {
@@ -9385,7 +9810,7 @@
// Use different stream endpoint for agent mode
const streamUrl = isAgentMode ? '/controller/stream/all' : '/aprs/stream';
aprsEventSource = new EventSource(streamUrl);
aprsEventSource = new EventSource(streamUrl + (streamUrl.includes('?') ? '&' : '?') + 't=' + Date.now());
aprsEventSource.onmessage = function (e) {
const data = JSON.parse(e.data);
@@ -9407,6 +9832,9 @@
processAprsPacket(payload);
} else if (payload.type === 'meter') {
updateAprsMeter(payload.level);
} else {
const stations = extractAprsStationsFromPayload(payload);
processAprsAgentStations(stations, data.agent_name);
}
}
} else {
@@ -9440,12 +9868,9 @@
}
}
// Track last station count for polling
let lastAprsStationCount = 0;
function startAprsPolling() {
if (aprsPollTimer) return;
lastAprsStationCount = 0;
resetAprsAgentStationTracking();
const pollInterval = 2000;
aprsPollTimer = setInterval(async () => {
@@ -9459,31 +9884,12 @@
const response = await fetch(`/controller/agents/${aprsCurrentAgent}/aprs/data`);
if (!response.ok) return;
const data = await response.json();
const result = data.result || data;
const stations = result.data || [];
// Process new stations
if (stations.length > lastAprsStationCount) {
const newStations = stations.slice(lastAprsStationCount);
newStations.forEach(station => {
aprsPacketCount++;
document.getElementById('aprsPacketCount').textContent = aprsPacketCount;
document.getElementById('aprsStripPackets').textContent = aprsPacketCount;
const dot = document.getElementById('aprsStripDot');
if (dot && !dot.classList.contains('tracking')) {
updateAprsStatus('tracking');
}
// Convert to expected packet format
const packet = {
type: 'aprs',
...station,
agent_name: result.agent_name || 'Remote Agent'
};
processAprsPacket(packet);
});
lastAprsStationCount = stations.length;
}
const payload = await response.json();
const stations = extractAprsStationsFromPayload(payload);
const agentName = payload.agent_name ||
(payload.data && payload.data.agent_name) ||
'Remote Agent';
processAprsAgentStations(stations, agentName);
} catch (err) {
console.error('APRS polling error:', err);
}
@@ -9597,7 +10003,7 @@
}
// Update map if position data
if (packet.lat && packet.lon && aprsMap) {
if (aprsHasValidCoordinates(packet.lat, packet.lon) && aprsMap) {
updateAprsMarker(packet);
}
@@ -9642,22 +10048,27 @@
function updateAprsMarker(packet) {
const callsign = packet.callsign;
const lat = Number(packet.lat);
const lon = Number(packet.lon);
if (!aprsHasValidCoordinates(lat, lon)) {
return;
}
// Calculate distance if user location available
let distStr = '';
if (aprsUserLocation.lat && aprsUserLocation.lon) {
const dist = aprsCalculateDistanceMi(aprsUserLocation.lat, aprsUserLocation.lon, packet.lat, packet.lon);
if (aprsHasValidCoordinates(aprsUserLocation.lat, aprsUserLocation.lon)) {
const dist = aprsCalculateDistanceMi(aprsUserLocation.lat, aprsUserLocation.lon, lat, lon);
distStr = `Distance: ${dist.toFixed(1)} mi<br>`;
}
if (aprsMarkers[callsign]) {
// Update existing marker position and popup
aprsMarkers[callsign].setLatLng([packet.lat, packet.lon]);
aprsMarkers[callsign].setLatLng([lat, lon]);
aprsMarkers[callsign].setIcon(buildAprsMarkerIcon(packet));
aprsMarkers[callsign].setPopupContent(`
<div style="font-family: monospace;">
<strong>${callsign}</strong><br>
Position: ${packet.lat.toFixed(4)}, ${packet.lon.toFixed(4)}<br>
Position: ${lat.toFixed(4)}, ${lon.toFixed(4)}<br>
${distStr}
${packet.altitude ? `Altitude: ${packet.altitude} ft<br>` : ''}
${packet.speed ? `Speed: ${packet.speed} kts<br>` : ''}
@@ -9671,12 +10082,12 @@
document.getElementById('aprsStationCount').textContent = aprsStationCount;
document.getElementById('aprsStripStations').textContent = aprsStationCount;
const marker = L.marker([packet.lat, packet.lon], { icon: buildAprsMarkerIcon(packet) }).addTo(aprsMap);
const marker = L.marker([lat, lon], { icon: buildAprsMarkerIcon(packet) }).addTo(aprsMap);
marker.bindPopup(`
<div style="font-family: monospace;">
<strong>${callsign}</strong><br>
Position: ${packet.lat.toFixed(4)}, ${packet.lon.toFixed(4)}<br>
Position: ${lat.toFixed(4)}, ${lon.toFixed(4)}<br>
${distStr}
${packet.altitude ? `Altitude: ${packet.altitude} ft<br>` : ''}
${packet.speed ? `Speed: ${packet.speed} kts<br>` : ''}
@@ -9700,9 +10111,11 @@
// Calculate distance if user location available
let distance = null;
const hasPos = packet.lat && packet.lon;
if (hasPos && aprsUserLocation.lat && aprsUserLocation.lon) {
distance = aprsCalculateDistanceMi(aprsUserLocation.lat, aprsUserLocation.lon, packet.lat, packet.lon);
const hasPos = aprsHasValidCoordinates(packet.lat, packet.lon);
const lat = hasPos ? Number(packet.lat) : null;
const lon = hasPos ? Number(packet.lon) : null;
if (hasPos && aprsHasValidCoordinates(aprsUserLocation.lat, aprsUserLocation.lon)) {
distance = aprsCalculateDistanceMi(aprsUserLocation.lat, aprsUserLocation.lon, lat, lon);
}
// Check if station already exists
@@ -9713,8 +10126,8 @@
const msg = {
callsign: callsign,
packet_type: packet.packet_type || 'unknown',
latitude: packet.lat,
longitude: packet.lon,
latitude: lat,
longitude: lon,
altitude: packet.altitude,
speed: packet.speed,
course: packet.course,
@@ -9732,8 +10145,8 @@
// Store position for distance updates
if (hasPos) {
newCard.dataset.lat = packet.lat;
newCard.dataset.lon = packet.lon;
newCard.dataset.lat = lat;
newCard.dataset.lon = lon;
}
// Add click handler to focus map
+98
View File
@@ -0,0 +1,98 @@
<!-- MORSE CODE MODE -->
<div id="morseMode" class="mode-content">
<div class="section">
<h3>CW/Morse Decoder</h3>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
Decode CW (continuous wave) Morse code from amateur radio HF bands using USB demodulation
and Goertzel tone detection.
</p>
</div>
<div class="section">
<h3>Frequency</h3>
<div class="form-group">
<label>Frequency (MHz)</label>
<input type="number" id="morseFrequency" value="14.060" step="0.001" min="1" max="30">
</div>
<div class="form-group">
<label>Band Presets</label>
<div class="morse-presets" style="display: flex; flex-wrap: wrap; gap: 4px;">
<button class="preset-btn" onclick="MorseMode.setFreq(3.560)">80m</button>
<button class="preset-btn" onclick="MorseMode.setFreq(7.030)">40m</button>
<button class="preset-btn" onclick="MorseMode.setFreq(10.116)">30m</button>
<button class="preset-btn" onclick="MorseMode.setFreq(14.060)">20m</button>
<button class="preset-btn" onclick="MorseMode.setFreq(18.080)">17m</button>
<button class="preset-btn" onclick="MorseMode.setFreq(21.060)">15m</button>
<button class="preset-btn" onclick="MorseMode.setFreq(24.910)">12m</button>
<button class="preset-btn" onclick="MorseMode.setFreq(28.060)">10m</button>
</div>
</div>
</div>
<div class="section">
<h3>Settings</h3>
<div class="form-group">
<label>Gain (dB)</label>
<input type="number" id="morseGain" value="40" step="1" min="0" max="50">
</div>
<div class="form-group">
<label>PPM Correction</label>
<input type="number" id="morsePPM" value="0" step="1" min="-100" max="100">
</div>
</div>
<div class="section">
<h3>CW Settings</h3>
<div class="form-group">
<label>Tone Frequency: <span id="morseToneFreqLabel">700</span> Hz</label>
<input type="range" id="morseToneFreq" value="700" min="300" max="1200" step="10"
oninput="document.getElementById('morseToneFreqLabel').textContent = this.value">
</div>
<div class="form-group">
<label>Speed: <span id="morseWpmLabel">15</span> WPM</label>
<input type="range" id="morseWpm" value="15" min="5" max="50" step="1"
oninput="document.getElementById('morseWpmLabel').textContent = this.value">
</div>
</div>
<!-- Morse Reference -->
<div class="section">
<h3 style="cursor: pointer;" onclick="this.parentElement.querySelector('.morse-ref-grid').classList.toggle('collapsed')">
Morse Reference <span style="font-size: 10px; color: var(--text-dim);">(click to toggle)</span>
</h3>
<div class="morse-ref-grid collapsed" style="font-family: var(--font-mono); font-size: 10px; line-height: 1.8; columns: 2; column-gap: 12px; color: var(--text-dim);">
<div>A .-</div><div>B -...</div><div>C -.-.</div><div>D -..</div>
<div>E .</div><div>F ..-.</div><div>G --.</div><div>H ....</div>
<div>I ..</div><div>J .---</div><div>K -.-</div><div>L .-..</div>
<div>M --</div><div>N -.</div><div>O ---</div><div>P .--.</div>
<div>Q --.-</div><div>R .-.</div><div>S ...</div><div>T -</div>
<div>U ..-</div><div>V ...-</div><div>W .--</div><div>X -..-</div>
<div>Y -.--</div><div>Z --..</div>
<div style="margin-top: 4px; border-top: 1px solid var(--border-color); padding-top: 4px;">0 -----</div>
<div style="margin-top: 4px; border-top: 1px solid var(--border-color); padding-top: 4px;">1 .----</div>
<div>2 ..---</div><div>3 ...--</div><div>4 ....-</div>
<div>5 .....</div><div>6 -....</div><div>7 --...</div>
<div>8 ---..</div><div>9 ----.</div>
</div>
</div>
<!-- Status -->
<div class="section">
<div class="morse-status" style="display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-dim);">
<span id="morseStatusIndicator" class="status-dot" style="width: 8px; height: 8px; border-radius: 50%; background: var(--text-dim);"></span>
<span id="morseStatusText">Standby</span>
<span style="margin-left: auto;" id="morseCharCount">0 chars</span>
</div>
</div>
<!-- HF Antenna Note -->
<div class="section">
<p class="info-text" style="font-size: 11px; color: #ffaa00; line-height: 1.5;">
CW operates on HF bands (1-30 MHz). Requires an HF-capable SDR with direct sampling
or an upconverter, plus an appropriate HF antenna (dipole, end-fed, or random wire).
</p>
</div>
<button class="run-btn" id="morseStartBtn" onclick="MorseMode.start()">Start Decoder</button>
<button class="stop-btn" id="morseStopBtn" onclick="MorseMode.stop()" style="display: none;">Stop Decoder</button>
</div>
+26 -14
View File
@@ -169,20 +169,32 @@
.catch(err => alert('Error: ' + err.message));
}
function stopVdl2Mode() {
fetch('/vdl2/stop', { method: 'POST' })
.then(r => r.json())
.then(() => {
document.getElementById('startVdl2Btn').style.display = 'block';
document.getElementById('stopVdl2Btn').style.display = 'none';
document.getElementById('vdl2StatusText').textContent = 'Standby';
document.getElementById('vdl2StatusText').style.color = 'var(--accent-yellow)';
if (vdl2MainEventSource) {
vdl2MainEventSource.close();
vdl2MainEventSource = null;
}
});
}
function stopVdl2Mode() {
fetch('/vdl2/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source: 'vdl2_mode' })
})
.then(async (r) => {
const text = await r.text();
const data = text ? JSON.parse(text) : {};
if (!r.ok || (data.status !== 'stopped' && data.status !== 'success')) {
throw new Error(data.message || `HTTP ${r.status}`);
}
return data;
})
.then(() => {
document.getElementById('startVdl2Btn').style.display = 'block';
document.getElementById('stopVdl2Btn').style.display = 'none';
document.getElementById('vdl2StatusText').textContent = 'Standby';
document.getElementById('vdl2StatusText').style.color = 'var(--accent-yellow)';
if (vdl2MainEventSource) {
vdl2MainEventSource.close();
vdl2MainEventSource = null;
}
})
.catch(err => alert('Failed to stop VDL2: ' + err.message));
}
function startVdl2MainSSE() {
if (vdl2MainEventSource) vdl2MainEventSource.close();
+129
View File
@@ -0,0 +1,129 @@
<!-- WEFAX MODE -->
<div id="wefaxMode" class="mode-content">
<div class="section">
<h3>WeFax Decoder</h3>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
Decode HF weather fax (radiofax) from maritime and aviation weather services.
Stations broadcast weather charts on fixed schedules via HF radio.
</p>
</div>
<div class="section">
<h3>Station</h3>
<div class="form-group">
<label>Station</label>
<select id="wefaxStation" onchange="WeFax.onStationChange()">
<option value="">Select a station...</option>
</select>
</div>
<div class="form-group">
<label>Frequency (kHz)</label>
<select id="wefaxFrequency">
<option value="">Select station first</option>
</select>
</div>
</div>
<div class="section">
<h3>Settings</h3>
<div class="form-group">
<label>IOC (Index of Cooperation)</label>
<select id="wefaxIOC">
<option value="576" selected>576 (Standard)</option>
<option value="288">288 (Half)</option>
</select>
</div>
<div class="form-group">
<label>LPM (Lines Per Minute)</label>
<select id="wefaxLPM">
<option value="120" selected>120 (Standard)</option>
<option value="60">60 (Slow)</option>
</select>
</div>
<div class="form-group">
<label>Gain (dB)</label>
<input type="number" id="wefaxGain" value="40" step="1" min="0" max="50">
</div>
<div class="form-group" style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" id="wefaxDirectSampling" checked>
<label for="wefaxDirectSampling" style="margin: 0; cursor: pointer;">Direct Sampling (Q-branch, required for HF)</label>
</div>
<div class="form-group" style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" id="wefaxAutoUsbAlign" checked>
<label for="wefaxAutoUsbAlign" style="margin: 0; cursor: pointer;">Auto USB align listed carrier frequencies (-1.9 kHz)</label>
</div>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-top: -4px;">
Disable this if your source already provides USB dial frequencies.
</p>
</div>
<div class="section">
<h3>Auto Capture</h3>
<div class="form-group" style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" id="wefaxSidebarAutoSchedule"
onchange="WeFax.toggleScheduler(this)">
<label for="wefaxSidebarAutoSchedule" style="margin: 0; cursor: pointer;">Auto-capture scheduled broadcasts</label>
</div>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-top: 4px;">
Automatically decode at scheduled broadcast times.
</p>
</div>
<!-- Antenna Guide -->
<div class="section">
<h3>HF Antenna Guide</h3>
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
<p style="margin-bottom: 8px; color: #ffaa00; font-weight: 600;">
HF band (2&ndash;30 MHz) &mdash; requires HF antenna + direct sampling SDR
</p>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<strong style="color: #ffaa00; font-size: 12px;">Requirements</strong>
<ul style="margin: 6px 0 0 14px; padding: 0;">
<li><strong style="color: var(--text-primary);">SDR:</strong> RTL-SDR (direct sampling), HackRF, LimeSDR, Airspy, or SDRPlay</li>
<li><strong style="color: var(--text-primary);">Antenna:</strong> Long wire (10m+), random wire, or dipole for target band</li>
<li><strong style="color: var(--text-primary);">Mode:</strong> USB (Upper Sideband) demodulation</li>
<li><strong style="color: var(--text-primary);">Signals:</strong> Moderate &mdash; HF propagation varies by time of day</li>
</ul>
</div>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
<strong style="color: #ffaa00; 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);">Protocol</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">AM Facsimile (ITU-T T.4)</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Carrier</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">1900 Hz</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Deviation</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">&plusmn;400 Hz</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Black / White</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">1500 / 2300 Hz</td>
</tr>
<tr>
<td style="padding: 3px 4px; color: var(--text-dim);">Start / Stop tone</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">300 / 450 Hz</td>
</tr>
</table>
</div>
</div>
</div>
<div class="section">
<h3>Resources</h3>
<div style="display: flex; flex-direction: column; gap: 6px;">
<a href="https://www.weather.gov/marine/radiofax_charts" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
NWS Radiofax Charts
</a>
<a href="https://www.nws.noaa.gov/os/marine/rfax.pdf" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
NOAA Radiofax Schedule (PDF)
</a>
</div>
</div>
</div>
+4
View File
@@ -67,6 +67,7 @@
{{ mode_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
{{ mode_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
{{ mode_item('waterfall', 'Waterfall', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-8 3 16 3-8h4"/><path d="M2 18h20" opacity="0.4"/><path d="M2 21h20" opacity="0.2"/></svg>') }}
{{ mode_item('morse', 'Morse', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="2" y1="12" x2="5" y2="12"/><line x1="7" y1="12" x2="13" y2="12"/><line x1="15" y1="12" x2="18" y2="12"/><line x1="20" y1="12" x2="22" y2="12"/></svg>') }}
</div>
</div>
@@ -102,6 +103,7 @@
{% endif %}
{{ mode_item('sstv', 'ISS SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M3 9h2"/><path d="M19 9h2"/><path d="M3 15h2"/><path d="M19 15h2"/></svg>') }}
{{ mode_item('weathersat', 'Weather Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
{{ mode_item('wefax', 'WeFax', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>') }}
{{ mode_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
{{ mode_item('spaceweather', 'Space Weather', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>') }}
</div>
@@ -200,6 +202,7 @@
{{ mobile_item('sensor', '433MHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
{{ mobile_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
{{ mobile_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
{{ mobile_item('morse', 'Morse', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="2" y1="12" x2="5" y2="12"/><line x1="7" y1="12" x2="13" y2="12"/><line x1="15" y1="12" x2="18" y2="12"/><line x1="20" y1="12" x2="22" y2="12"/></svg>') }}
{# Tracking #}
{{ mobile_item('adsb', 'Aircraft', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>', '/adsb/dashboard') }}
{{ mobile_item('ais', 'Vessels', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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') }}
@@ -213,6 +216,7 @@
{% endif %}
{{ mobile_item('sstv', 'SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
{{ mobile_item('weathersat', 'WxSat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
{{ mobile_item('wefax', 'WeFax', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>') }}
{{ mobile_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
{{ mobile_item('spaceweather', 'SpaceWx', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/></svg>') }}
{# Wireless #}
+11
View File
@@ -323,6 +323,17 @@
</label>
</div>
<div class="settings-row">
<div class="settings-label">
<span class="settings-label-text">Military Aircraft</span>
<span class="settings-label-desc">Speak when military aircraft are detected</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="voiceCfgAdsbMilitary" checked onchange="saveVoiceAlertConfig()">
<span class="toggle-slider"></span>
</label>
</div>
<div class="settings-row">
<div class="settings-label">
<span class="settings-label-text">Emergency Squawks</span>
+6 -1
View File
@@ -971,7 +971,12 @@
}
}
} catch (err) {
console.error('Position update error:', err);
const transient = (typeof window.isTransientOrOffline === 'function' && window.isTransientOrOffline(err)) ||
(typeof navigator !== 'undefined' && navigator.onLine === false) ||
/failed to fetch|network io suspended|networkerror|timeout/i.test(String((err && err.message) || err || ''));
if (!transient) {
console.error('Position update error:', err);
}
}
}