// --- Persistent settings (localStorage) --- const STORAGE_PREFIX = "trx_"; function saveSetting(key, value) { try { localStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(value)); } catch(e) {} } function loadSetting(key, fallback) { try { const v = localStorage.getItem(STORAGE_PREFIX + key); return v !== null ? JSON.parse(v) : fallback; } catch(e) { return fallback; } } // --- Authentication --- let authRole = null; // null (not authenticated), "rx" (read-only), or "control" (full access) async function checkAuthStatus() { try { const resp = await fetch("/auth/session"); if (!resp.ok) return { authenticated: false }; const data = await resp.json(); return data; } catch (e) { console.error("Auth check failed:", e); return { authenticated: false }; } } async function authLogin(passphrase) { try { const resp = await fetch("/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ passphrase }), }); if (!resp.ok) { const text = await resp.text(); throw new Error(text || "Login failed"); } const data = await resp.json(); return data; } catch (e) { throw e; } } async function authLogout() { try { const resp = await fetch("/auth/logout", { method: "POST" }); if (!resp.ok) throw new Error("Logout failed"); authRole = null; // Disconnect and show auth gate without page reload disconnect(); document.getElementById("content").style.display = "none"; document.getElementById("loading").style.display = "none"; document.getElementById("auth-passphrase").value = ""; updateAuthUI(); // Check if guest mode is available after logout const authStatus = await checkAuthStatus(); const allowGuest = authStatus.role === "rx"; showAuthGate(allowGuest); } catch (e) { console.error("Logout failed:", e); showAuthError("Logout failed"); } } function showAuthGate(allowGuest = false) { document.getElementById("loading").style.display = "none"; document.getElementById("content").style.display = "none"; document.getElementById("auth-gate").style.display = "block"; document.getElementById("tab-bar").style.display = "none"; // Show guest button if guest mode is available const guestBtn = document.getElementById("auth-guest-btn"); if (guestBtn) { guestBtn.style.display = allowGuest ? "block" : "none"; } } function hideAuthGate() { document.getElementById("auth-gate").style.display = "none"; document.getElementById("loading").style.display = "block"; document.getElementById("tab-bar").style.display = ""; } function showAuthError(msg) { const el = document.getElementById("auth-error"); el.textContent = msg; el.style.display = "block"; setTimeout(() => { el.style.display = "none"; }, 5000); } function updateAuthUI() { const badge = document.getElementById("auth-badge"); const badgeRole = document.getElementById("auth-role-badge"); const headerAuthBtn = document.getElementById("header-auth-btn"); if (authRole) { badge.style.display = "block"; badgeRole.textContent = authRole === "control" ? "Control (full access)" : "RX (read-only)"; if (headerAuthBtn) { headerAuthBtn.textContent = "Logout"; headerAuthBtn.style.display = "block"; } } else { badge.style.display = "none"; if (headerAuthBtn) { headerAuthBtn.textContent = "Login"; headerAuthBtn.style.display = "block"; } } } function applyAuthRestrictions() { if (!authRole) return; // Disable TX/PTT/frequency/mode/VFO controls for rx role if (authRole === "rx") { const pttBtn = document.getElementById("ptt-btn"); const powerBtn = document.getElementById("power-btn"); const lockBtn = document.getElementById("lock-btn"); const freqInput = document.getElementById("freq"); const modeSelect = document.getElementById("mode"); const txLimitInput = document.getElementById("tx-limit"); const txLimitBtn = document.getElementById("tx-limit-btn"); const txAudioBtn = document.getElementById("tx-audio-btn"); const txLimitRow = document.getElementById("tx-limit-row"); const vfoPicker = document.getElementById("vfo-picker"); const jogUp = document.getElementById("jog-up"); const jogDown = document.getElementById("jog-down"); const jogButtons = document.querySelectorAll(".jog-step button"); const vfoButtons = document.querySelectorAll("#vfo-picker button"); // Disable TX buttons if (pttBtn) pttBtn.disabled = true; if (powerBtn) powerBtn.disabled = true; if (lockBtn) lockBtn.disabled = true; if (txAudioBtn) txAudioBtn.disabled = true; if (txLimitBtn) txLimitBtn.disabled = true; // Disable frequency/mode inputs if (freqInput) freqInput.disabled = true; if (modeSelect) modeSelect.disabled = true; if (txLimitInput) txLimitInput.disabled = true; // Disable VFO selector vfoButtons.forEach(btn => btn.disabled = true); // Disable jog controls const jogWheel = document.getElementById("jog-wheel"); if (jogUp) jogUp.disabled = true; if (jogDown) jogDown.disabled = true; if (jogWheel) jogWheel.style.opacity = "0.5"; jogButtons.forEach(btn => btn.disabled = true); // Disable plugin enable/disable buttons const pluginToggleBtns = [ "ft8-decode-toggle-btn", "wspr-decode-toggle-btn", "cw-auto" ]; pluginToggleBtns.forEach(id => { const btn = document.getElementById(id); if (btn && btn.tagName === "BUTTON") { btn.disabled = true; } else if (btn && btn.type === "checkbox") { btn.disabled = true; } }); // Hide TX-specific UI but keep controls visible (disabled) if (txLimitRow) txLimitRow.style.opacity = "0.5"; } } const freqEl = document.getElementById("freq"); const wavelengthEl = document.getElementById("wavelength"); const modeEl = document.getElementById("mode"); const bandLabel = document.getElementById("band-label"); const powerBtn = document.getElementById("power-btn"); const powerHint = document.getElementById("power-hint"); const vfoPicker = document.getElementById("vfo-picker"); const signalBar = document.getElementById("signal-bar"); const signalValue = document.getElementById("signal-value"); const pttBtn = document.getElementById("ptt-btn"); const txLimitInput = document.getElementById("tx-limit"); const txLimitBtn = document.getElementById("tx-limit-btn"); const txLimitRow = document.getElementById("tx-limit-row"); const lockBtn = document.getElementById("lock-btn"); const txMeters = document.getElementById("tx-meters"); const pwrBar = document.getElementById("pwr-bar"); const pwrValue = document.getElementById("pwr-value"); const swrBar = document.getElementById("swr-bar"); const swrValue = document.getElementById("swr-value"); const loadingEl = document.getElementById("loading"); const contentEl = document.getElementById("content"); const serverSubtitle = document.getElementById("server-subtitle"); const loadingTitle = document.getElementById("loading-title"); const loadingSub = document.getElementById("loading-sub"); const headerSigCanvas = document.getElementById("header-sig-canvas"); const themeToggleBtn = document.getElementById("theme-toggle"); let lastControl; let lastTxEn = null; let lastRendered = null; let rigName = "Rig"; let hintTimer = null; let sigMeasuring = false; let sigLastSUnits = null; let sigMeasureTimer = null; let sigMeasureLastTickMs = 0; let sigMeasureAccumMs = 0; let sigMeasureWeighted = 0; let sigMeasurePeak = null; let lastFreqHz = null; let jogStep = loadSetting("jogStep", 1000); let minFreqStepHz = 1; const VFO_COLORS = ["var(--accent-green)", "var(--accent-yellow)"]; function vfoColor(idx) { if (idx < VFO_COLORS.length) return VFO_COLORS[idx]; // Deterministic pseudo-random hue for extra VFOs const hue = ((idx * 137) % 360); return `hsl(${hue}, 70%, 55%)`; } let jogAngle = 0; let lastClientCount = null; let lastLocked = false; const originalTitle = document.title; const savedTheme = loadSetting("theme", null); function currentTheme() { return document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark"; } function setTheme(theme) { const next = theme === "light" ? "light" : "dark"; document.documentElement.setAttribute("data-theme", next); saveSetting("theme", next); if (themeToggleBtn) { themeToggleBtn.textContent = next === "dark" ? "☀️ Light" : "🌙 Dark"; themeToggleBtn.title = next === "dark" ? "Switch to light mode" : "Switch to dark mode"; } } if (savedTheme === "light" || savedTheme === "dark") { setTheme(savedTheme); } else { const prefersLight = window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches; setTheme(prefersLight ? "light" : "dark"); } if (themeToggleBtn) { themeToggleBtn.addEventListener("click", () => { setTheme(currentTheme() === "dark" ? "light" : "dark"); updateMapBaseLayerForTheme(currentTheme()); }); } function readyText() { return lastClientCount !== null ? `Ready \u00b7 ${lastClientCount} user${lastClientCount !== 1 ? "s" : ""}` : "Ready"; } function showHint(msg, duration) { powerHint.textContent = msg; if (hintTimer) clearTimeout(hintTimer); if (duration) hintTimer = setTimeout(() => { powerHint.textContent = readyText(); }, duration); } let supportedModes = []; let supportedBands = []; let freqDirty = false; let initialized = false; let lastEventAt = Date.now(); let es; let esHeartbeat; let reconnectTimer = null; let headerSigSamples = []; let headerSigTimer = null; const HEADER_SIG_WINDOW_MS = 10_000; function resizeHeaderSignalCanvas() { if (!headerSigCanvas) return; const cssW = Math.floor(headerSigCanvas.clientWidth); const cssH = Math.floor(headerSigCanvas.clientHeight); if (cssW <= 0 || cssH <= 0) return; const dpr = window.devicePixelRatio || 1; const nextW = Math.floor(cssW * dpr); const nextH = Math.floor(cssH * dpr); if (headerSigCanvas.width !== nextW || headerSigCanvas.height !== nextH) { headerSigCanvas.width = nextW; headerSigCanvas.height = nextH; } drawHeaderSignalGraph(); } function pushHeaderSignalSample(sUnits) { if (!headerSigCanvas) return; const now = Date.now(); const sample = Number.isFinite(sUnits) ? Math.max(0, Math.min(20, sUnits)) : 0; headerSigSamples.push({ t: now, v: sample }); while (headerSigSamples.length && now - headerSigSamples[0].t > HEADER_SIG_WINDOW_MS) { headerSigSamples.shift(); } drawHeaderSignalGraph(); } function startHeaderSignalSampling() { if (!headerSigCanvas || headerSigTimer) return; headerSigTimer = setInterval(() => { pushHeaderSignalSample(Number.isFinite(sigLastSUnits) ? sigLastSUnits : 0); }, 120); } function drawHeaderSignalGraph() { if (!headerSigCanvas) return; const ctx = headerSigCanvas.getContext("2d"); if (!ctx) return; const isLight = currentTheme() === "light"; const dpr = window.devicePixelRatio || 1; const w = headerSigCanvas.width / dpr; const h = headerSigCanvas.height / dpr; if (w <= 0 || h <= 0) return; ctx.save(); ctx.scale(dpr, dpr); ctx.clearRect(0, 0, w, h); // Soft horizontal guides for readability. ctx.strokeStyle = isLight ? "rgba(71, 85, 105, 0.26)" : "rgba(148, 163, 184, 0.16)"; ctx.lineWidth = 1; for (let i = 1; i <= 3; i++) { const y = Math.round((h * i) / 4) + 0.5; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); } // Minimal S-unit scale markers. const yFor = (v) => h - (Math.max(0, Math.min(20, v)) / 20) * (h - 2) - 1; ctx.fillStyle = isLight ? "rgba(30, 41, 59, 0.62)" : "rgba(154, 164, 181, 0.55)"; ctx.font = "10px sans-serif"; ctx.textAlign = "right"; ctx.textBaseline = "middle"; [["S9+", 18], ["S9", 9], ["S0", 0]].forEach(([label, val]) => { const y = yFor(val); ctx.fillText(label, w - 4, y); ctx.strokeStyle = isLight ? "rgba(51, 65, 85, 0.22)" : "rgba(154, 164, 181, 0.22)"; ctx.beginPath(); ctx.moveTo(2, y + 0.5); ctx.lineTo(w - 36, y + 0.5); ctx.stroke(); }); if (headerSigSamples.length > 1) { const maxVal = 20; // includes S9+ scale overshoot. const toY = (v) => h - (Math.max(0, Math.min(maxVal, v)) / maxVal) * (h - 2) - 1; const now = Date.now(); const windowStart = now - HEADER_SIG_WINDOW_MS; const toX = (t) => ((t - windowStart) / HEADER_SIG_WINDOW_MS) * w; const strengthGrad = ctx.createLinearGradient(0, h, 0, 0); const fillGrad = ctx.createLinearGradient(0, h, 0, 0); if (isLight) { // Higher-contrast palette for bright backgrounds. strengthGrad.addColorStop(0.0, "rgba(0, 86, 255, 0.95)"); // weak: deep blue strengthGrad.addColorStop(0.5, "rgba(0, 179, 255, 0.95)"); strengthGrad.addColorStop(0.8, "rgba(255, 133, 0, 0.97)"); strengthGrad.addColorStop(1.0, "rgba(224, 36, 36, 0.98)"); // strong: red fillGrad.addColorStop(0.0, "rgba(0, 86, 255, 0.18)"); fillGrad.addColorStop(0.5, "rgba(0, 179, 255, 0.20)"); fillGrad.addColorStop(0.8, "rgba(255, 133, 0, 0.22)"); fillGrad.addColorStop(1.0, "rgba(224, 36, 36, 0.24)"); } else { strengthGrad.addColorStop(0.0, "rgba(64, 120, 255, 0.88)"); // weak: blue strengthGrad.addColorStop(0.5, "rgba(106, 186, 255, 0.9)"); strengthGrad.addColorStop(0.8, "rgba(255, 166, 77, 0.9)"); strengthGrad.addColorStop(1.0, "rgba(255, 78, 78, 0.92)"); // strong: red fillGrad.addColorStop(0.0, "rgba(64, 120, 255, 0.12)"); fillGrad.addColorStop(0.5, "rgba(106, 186, 255, 0.16)"); fillGrad.addColorStop(0.8, "rgba(255, 166, 77, 0.18)"); fillGrad.addColorStop(1.0, "rgba(255, 78, 78, 0.2)"); } ctx.beginPath(); headerSigSamples.forEach((sample, i) => { const x = toX(sample.t); const y = toY(sample.v); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); ctx.lineTo(w, h); ctx.lineTo(0, h); ctx.closePath(); ctx.fillStyle = fillGrad; ctx.fill(); ctx.beginPath(); headerSigSamples.forEach((sample, i) => { const x = toX(sample.t); const y = toY(sample.v); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); ctx.strokeStyle = strengthGrad; ctx.lineWidth = 1.25; ctx.stroke(); } ctx.restore(); } function formatFreq(hz) { if (!Number.isFinite(hz)) return "--"; if (hz >= 1_000_000_000) { return `${(hz / 1_000_000_000).toFixed(3)} GHz`; } if (hz >= 10_000_000) { return `${(hz / 1_000_000).toFixed(3)} MHz`; } return `${(hz / 1_000).toFixed(1)} kHz`; } function formatFreqForStep(hz, step) { if (!Number.isFinite(hz)) return "--"; if (step >= 1_000_000) return (hz / 1_000_000).toFixed(6); if (step >= 1_000) return (hz / 1_000).toFixed(3); if (step >= 1) return String(Math.round(hz)); return formatFreq(hz); } function formatWavelength(hz) { if (!Number.isFinite(hz) || hz <= 0) return "--"; const meters = 299_792_458 / hz; if (meters >= 1) return `${Math.round(meters)} m`; return `${Math.round(meters * 100)} cm`; } function refreshWavelengthDisplay(hz) { if (!wavelengthEl) return; wavelengthEl.textContent = formatWavelength(hz); } function refreshFreqDisplay() { if (lastFreqHz == null || freqDirty) return; freqEl.value = formatFreqForStep(lastFreqHz, jogStep); refreshWavelengthDisplay(lastFreqHz); } function parseFreqInput(val, defaultStep) { if (!val) return null; const trimmed = val.trim().toLowerCase(); const match = trimmed.match(/^([0-9]+(?:[.,][0-9]+)?)\s*([kmg]hz|[kmg]|hz)?$/); if (!match) return null; let num = parseFloat(match[1].replace(",", ".")); const unit = match[2] || ""; if (Number.isNaN(num)) return null; if (unit.startsWith("gh") || unit === "g") { num *= 1_000_000_000; } else if (unit.startsWith("mh") || unit === "m") { num *= 1_000_000; } else if (unit.startsWith("kh") || unit === "k") { num *= 1_000; } else if (!unit) { // Use currently selected input unit when user omits suffix. if (defaultStep >= 1_000_000) { num *= 1_000_000; } else if (defaultStep >= 1_000) { num *= 1_000; } else if (defaultStep >= 1) { // already Hz } else { // Fallback heuristic. if (num >= 1_000_000) { // Assume already Hz. } else if (num >= 1_000) { num *= 1_000; } else { num *= 1_000_000; } } } return Math.round(num); } function normalizeMinFreqStep(cap) { const val = Number(cap && cap.min_freq_step_hz); if (!Number.isFinite(val) || val < 1) return 1; return Math.round(val); } function alignFreqToRigStep(hz) { if (!Number.isFinite(hz)) return hz; const step = Math.max(1, minFreqStepHz); return Math.round(hz / step) * step; } function updateJogStepSupport(cap) { const nextMinStep = normalizeMinFreqStep(cap); minFreqStepHz = nextMinStep; const stepRoot = document.getElementById("jog-step"); if (!stepRoot) return; const buttons = Array.from(stepRoot.querySelectorAll("button[data-step]")); if (buttons.length === 0) return; buttons.forEach((btn) => { const base = Number(btn.dataset.baseStep || btn.dataset.step); if (Number.isFinite(base) && base > 0) { btn.dataset.baseStep = String(Math.round(base)); btn.dataset.step = String(Math.max(Math.round(base), minFreqStepHz)); } }); const steps = buttons .map((btn) => Number(btn.dataset.step)) .filter((s) => Number.isFinite(s) && s > 0); if (steps.length === 0) return; const current = Number(jogStep); const desired = Number.isFinite(current) && current >= minFreqStepHz ? current : Math.max(steps[0], minFreqStepHz); jogStep = steps.reduce((best, s) => (Math.abs(s - desired) < Math.abs(best - desired) ? s : best), steps[0]); saveSetting("jogStep", jogStep); buttons.forEach((btn) => { btn.classList.toggle("active", Number(btn.dataset.step) === jogStep); }); refreshFreqDisplay(); } function normalizeMode(modeVal) { if (typeof modeVal === "string") return modeVal; if (modeVal && typeof modeVal === "object") { const entries = Object.entries(modeVal); if (entries.length > 0) { const [variant, value] = entries[0]; if (variant === "Other" && typeof value === "string") return value; return variant; } } return ""; } function updateSupportedBands(cap) { if (cap && Array.isArray(cap.supported_bands)) { supportedBands = cap.supported_bands .filter((b) => typeof b.low_hz === "number" && typeof b.high_hz === "number" && b.tx_allowed === true) .map((b) => ({ low: b.low_hz, high: b.high_hz })); } else { supportedBands = []; } } function freqAllowed(hz) { if (!Number.isFinite(hz)) return false; if (supportedBands.length === 0) return true; // if unknown, don't block return supportedBands.some((b) => hz >= b.low && hz <= b.high); } // Convert dBm (wire format) to S-units (S1=-121dBm, S9=-73dBm, 6dB/S-unit). // Above S9, returns 9 + (overshoot in S-unit-equivalent, i.e. dB/10). function dbmToSUnits(dbm) { if (!Number.isFinite(dbm)) return 0; // Guard against bogus backend values to keep display in a realistic range. const clampedDbm = Math.max(-140, Math.min(20, dbm)); if (clampedDbm <= -121) return 0; if (clampedDbm >= -73) return 9 + (clampedDbm + 73) / 10; return (clampedDbm + 121) / 6; } function formatSignal(sUnits) { if (!Number.isFinite(sUnits) || sUnits <= 9) return `S${Math.max(0, sUnits || 0).toFixed(1)}`; // S9+60dB is already extremely strong; cap anything beyond that. const overDb = Math.min(60, (sUnits - 9) * 10); return `S9 + ${overDb.toFixed(0)}dB`; } function setDisabled(disabled) { [freqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => { if (el) el.disabled = disabled; }); } let serverVersion = null; let serverCallsign = null; let serverLat = null; let serverLon = null; function updateTitle() { let title = rigName || "Rig"; if (serverCallsign) title = `${serverCallsign}'s ${title}`; document.getElementById("rig-title").textContent = title; } function render(update) { if (!update) return; if (update.info && update.info.model) rigName = update.info.model; if (update.server_version) serverVersion = update.server_version; if (update.server_callsign) serverCallsign = update.server_callsign; if (update.server_latitude != null) serverLat = update.server_latitude; if (update.server_longitude != null) serverLon = update.server_longitude; updateTitle(); initialized = !!update.initialized; if (!initialized) { const manu = (update.info && update.info.manufacturer) || rigName || "Rig"; const model = (update.info && update.info.model) || rigName || "Rig"; const rev = (update.info && update.info.revision) || ""; const parts = [manu, model, rev].filter(Boolean).join(" "); loadingTitle.textContent = `Initializing ${parts}…`; loadingSub.textContent = ""; console.info("Rig initializing:", { manufacturer: manu, model, revision: rev }); loadingEl.style.display = ""; if (contentEl) contentEl.style.display = "none"; powerHint.textContent = "Initializing rig…"; setDisabled(true); return; } else { loadingEl.style.display = "none"; if (contentEl) contentEl.style.display = ""; } // Server subtitle: "trx-server vX.Y.Z hosted by CALL" if (update.server_version || update.server_callsign) { let parts = "trx-server"; if (update.server_version) parts += ` v${update.server_version}`; if (update.server_callsign) { const cs = update.server_callsign; serverSubtitle.innerHTML = `${parts} hosted by ${cs}`; document.title = `${cs} - ${originalTitle}`; } else { serverSubtitle.textContent = parts; } } setDisabled(false); if (update.info && update.info.capabilities && Array.isArray(update.info.capabilities.supported_modes)) { const modes = update.info.capabilities.supported_modes.map(normalizeMode).filter(Boolean); if (JSON.stringify(modes) !== JSON.stringify(supportedModes)) { supportedModes = modes; modeEl.innerHTML = ""; supportedModes.forEach((m) => { const opt = document.createElement("option"); opt.value = m; opt.textContent = m; modeEl.appendChild(opt); }); } } if (update.info && update.info.capabilities) { updateJogStepSupport(update.info.capabilities); updateSupportedBands(update.info.capabilities); } if (update.status && update.status.freq && typeof update.status.freq.hz === "number") { lastFreqHz = update.status.freq.hz; refreshWavelengthDisplay(lastFreqHz); if (!freqDirty) { refreshFreqDisplay(); } window.ft8BaseHz = update.status.freq.hz; if (window.updateFt8RfDisplay) { window.updateFt8RfDisplay(); } } if (update.status && update.status.mode) { const mode = normalizeMode(update.status.mode); modeEl.value = mode ? mode.toUpperCase() : ""; } const modeUpper = update.status && update.status.mode ? normalizeMode(update.status.mode).toUpperCase() : ""; const aprsStatus = document.getElementById("aprs-status"); const cwStatus = document.getElementById("cw-status"); const ft8Status = document.getElementById("ft8-status"); const wsprStatus = document.getElementById("wspr-status"); if (aprsStatus && modeUpper !== "PKT" && aprsStatus.textContent === "Receiving") { aprsStatus.textContent = "Connected, listening for packets"; } if (cwStatus && modeUpper !== "CW" && modeUpper !== "CWR" && cwStatus.textContent === "Receiving") { cwStatus.textContent = "Connected, listening for packets"; } const ft8Enabled = !!update.ft8_decode_enabled; if (ft8Status && (!ft8Enabled || (modeUpper !== "DIG" && modeUpper !== "USB")) && ft8Status.textContent === "Receiving") { ft8Status.textContent = "Connected, listening for packets"; } const wsprEnabled = !!update.wspr_decode_enabled; if (wsprStatus && (!wsprEnabled || (modeUpper !== "DIG" && modeUpper !== "USB")) && wsprStatus.textContent === "Receiving") { wsprStatus.textContent = "Connected, listening for packets"; } if (update.status && typeof update.status.tx_en === "boolean") { lastTxEn = update.status.tx_en; pttBtn.textContent = update.status.tx_en ? "PTT On" : "PTT Off"; if (update.status.tx_en) { pttBtn.style.background = "var(--accent-red)"; pttBtn.style.borderColor = "var(--accent-red)"; pttBtn.style.color = "white"; } else { pttBtn.style.background = ""; pttBtn.style.borderColor = ""; pttBtn.style.color = ""; } } const ft8ToggleBtn = document.getElementById("ft8-decode-toggle-btn"); if (ft8ToggleBtn) { const ft8On = !!update.ft8_decode_enabled; ft8ToggleBtn.textContent = ft8On ? "Disable FT8" : "Enable FT8"; ft8ToggleBtn.style.borderColor = ft8On ? "#00d17f" : ""; ft8ToggleBtn.style.color = ft8On ? "#00d17f" : ""; } const wsprToggleBtn = document.getElementById("wspr-decode-toggle-btn"); if (wsprToggleBtn) { const wsprOn = !!update.wspr_decode_enabled; wsprToggleBtn.textContent = wsprOn ? "Disable WSPR" : "Enable WSPR"; wsprToggleBtn.style.borderColor = wsprOn ? "#00d17f" : ""; wsprToggleBtn.style.color = wsprOn ? "#00d17f" : ""; } const cwAutoEl = document.getElementById("cw-auto"); const cwWpmEl = document.getElementById("cw-wpm"); const cwToneEl = document.getElementById("cw-tone"); if (cwAutoEl && typeof update.cw_auto === "boolean") { cwAutoEl.checked = update.cw_auto; } if (cwWpmEl && typeof update.cw_wpm === "number") { cwWpmEl.value = update.cw_wpm; } if (cwToneEl && typeof update.cw_tone_hz === "number") { cwToneEl.value = update.cw_tone_hz; } if (cwWpmEl && cwToneEl && typeof update.cw_auto === "boolean") { const disabled = update.cw_auto; cwWpmEl.disabled = disabled; cwWpmEl.readOnly = disabled; cwToneEl.disabled = disabled; cwToneEl.readOnly = disabled; } if (update.status && update.status.vfo && Array.isArray(update.status.vfo.entries)) { const entries = update.status.vfo.entries; const activeIdx = Number.isInteger(update.status.vfo.active) ? update.status.vfo.active : null; vfoPicker.innerHTML = ""; entries.forEach((entry, idx) => { const hz = entry && entry.freq && typeof entry.freq.hz === "number" ? entry.freq.hz : null; if (hz === null) return; const mode = entry.mode ? normalizeMode(entry.mode) : ""; const modeText = mode ? ` [${mode}]` : ""; const label = `${entry.name || String.fromCharCode(65 + idx)}: ${formatFreq(hz)}${modeText}`; const btn = document.createElement("button"); btn.type = "button"; btn.textContent = label; const color = vfoColor(idx); if (activeIdx === idx) { btn.classList.add("active"); btn.style.color = color; freqEl.style.color = color; } else btn.addEventListener("click", async () => { btn.disabled = true; showHint("Toggling VFO…"); try { await postPath("/toggle_vfo"); showHint("VFO toggled", 1200); } catch (err) { showHint("VFO toggle failed", 2000); console.error(err); } finally { btn.disabled = false; } }); vfoPicker.appendChild(btn); }); } else { vfoPicker.innerHTML = ""; } if (update.status && update.status.rx && typeof update.status.rx.sig === "number") { const sUnits = dbmToSUnits(update.status.rx.sig); sigLastSUnits = sUnits; const pct = sUnits <= 9 ? Math.max(0, Math.min(100, (sUnits / 9) * 100)) : 100; signalBar.style.width = `${pct}%`; signalValue.textContent = formatSignal(sUnits); } else { sigLastSUnits = null; signalBar.style.width = "0%"; signalValue.textContent = "--"; } if (bandLabel) { bandLabel.textContent = typeof update.band === "string" ? update.band : "--"; } if (typeof update.enabled === "boolean") { powerBtn.disabled = false; powerBtn.textContent = update.enabled ? "Power Off" : "Power On"; } else { powerBtn.disabled = true; powerBtn.textContent = "Toggle Power"; powerHint.textContent = "State unknown"; } lastControl = update.enabled; if (update.status && update.status.tx && typeof update.status.tx.limit === "number") { txLimitInput.value = update.status.tx.limit; txLimitRow.style.display = ""; } else { txLimitInput.value = ""; txLimitRow.style.display = "none"; } if (typeof update.clients === "number") lastClientCount = update.clients; // Populate About tab if (update.server_version) { document.getElementById("about-server-ver").textContent = `trx-server v${update.server_version}`; } document.getElementById("about-server-addr").textContent = location.host; if (update.server_callsign) { document.getElementById("about-server-call").textContent = update.server_callsign; } if (update.pskreporter_status) { document.getElementById("about-pskreporter").textContent = update.pskreporter_status; } if (update.info) { const parts = [update.info.manufacturer, update.info.model, update.info.revision].filter(Boolean).join(" "); if (parts) document.getElementById("about-rig-info").textContent = parts; const access = update.info.access; if (access) { if (access.Serial) { const serialPath = access.Serial.path || access.Serial.port || "?"; document.getElementById("about-rig-access").textContent = `Serial (${serialPath}, ${access.Serial.baud || "?"} baud)`; } else if (access.Tcp) { document.getElementById("about-rig-access").textContent = `TCP (${access.Tcp.host || "?"}:${access.Tcp.port || "?"})`; } else { const key = Object.keys(access)[0]; if (key) document.getElementById("about-rig-access").textContent = key; } } if (update.info.capabilities) { const cap = update.info.capabilities; if (Array.isArray(cap.supported_modes) && cap.supported_modes.length) { document.getElementById("about-modes").textContent = cap.supported_modes.map(normalizeMode).filter(Boolean).join(", "); } if (typeof cap.num_vfos === "number") { document.getElementById("about-vfos").textContent = cap.num_vfos; } } } if (typeof update.clients === "number") { document.getElementById("about-clients").textContent = update.clients; } if (typeof update.rigctl_clients === "number") { document.getElementById("about-rigctl-clients").textContent = update.rigctl_clients; } if (typeof update.rigctl_addr === "string" && update.rigctl_addr.length > 0) { document.getElementById("about-rigctl-endpoint").textContent = update.rigctl_addr; } powerHint.textContent = readyText(); lastLocked = update.status && update.status.lock === true; lockBtn.textContent = lastLocked ? "Unlock" : "Lock"; const tx = update.status && update.status.tx ? update.status.tx : null; txMeters.style.display = ""; if (tx && typeof tx.power === "number") { const pct = Math.max(0, Math.min(100, tx.power)); pwrBar.style.width = `${pct}%`; pwrValue.textContent = `PWR ${tx.power.toFixed(0)}%`; } else { pwrBar.style.width = "0%"; pwrValue.textContent = "PWR --"; } if (tx && typeof tx.swr === "number") { const swr = Math.max(1, tx.swr); const pct = Math.max(0, Math.min(100, ((swr - 1) / 2) * 100)); swrBar.style.width = `${pct}%`; swrValue.textContent = `SWR ${tx.swr.toFixed(2)}`; } else { swrBar.style.width = "0%"; swrValue.textContent = "SWR --"; } } function scheduleReconnect(delayMs = 1000) { if (reconnectTimer) return; reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, delayMs); } async function pollFreshSnapshot() { try { const resp = await fetch("/status", { cache: "no-store" }); if (!resp.ok) return; const data = await resp.json(); render(data); lastEventAt = Date.now(); } catch (e) { // Ignore network errors; connect() retry loop handles reconnection. } } function connect() { if (es) { es.close(); } if (esHeartbeat) { clearInterval(esHeartbeat); } pollFreshSnapshot(); es = new EventSource("/events"); lastEventAt = Date.now(); es.onopen = () => { pollFreshSnapshot(); }; es.onmessage = (evt) => { try { if (evt.data === lastRendered) return; const data = JSON.parse(evt.data); lastRendered = evt.data; render(data); lastEventAt = Date.now(); if (data.initialized) { powerHint.textContent = readyText(); } } catch (e) { console.error("Bad event data", e); } }; es.onerror = () => { // Check if this is an auth error by looking at readyState if (es.readyState === EventSource.CLOSED) { powerHint.textContent = "Disconnected, retrying…"; es.close(); pollFreshSnapshot(); scheduleReconnect(1000); } }; esHeartbeat = setInterval(() => { const now = Date.now(); if (now - lastEventAt > 15000) { es.close(); pollFreshSnapshot(); scheduleReconnect(250); } }, 5000); } function disconnect() { // Close event sources if (es) { es.close(); es = null; } if (decodeSource) { decodeSource.close(); decodeSource = null; } // Clear timers if (esHeartbeat) { clearInterval(esHeartbeat); esHeartbeat = null; } if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } } async function postPath(path) { const resp = await fetch(path, { method: "POST" }); if (resp.status === 401) { // Not authenticated - return to login authRole = null; if (es) es.close(); showAuthGate(); throw new Error("Authentication required"); } if (resp.status === 403) { // Authenticated but insufficient permissions - don't redirect throw new Error("Insufficient permissions"); } if (!resp.ok) { const text = await resp.text(); throw new Error(text || resp.statusText); } return resp; } powerBtn.addEventListener("click", async () => { powerBtn.disabled = true; showHint("Sending..."); try { await postPath("/toggle_power"); showHint("Toggled, waiting for update…"); } catch (err) { showHint("Toggle failed", 2000); console.error(err); } finally { powerBtn.disabled = false; } }); pttBtn.addEventListener("click", async () => { pttBtn.disabled = true; showHint("Toggling PTT…"); try { const desired = lastTxEn ? "false" : "true"; await postPath(`/set_ptt?ptt=${desired}`); showHint("PTT command sent", 1500); } catch (err) { showHint("PTT toggle failed", 2000); console.error(err); } finally { pttBtn.disabled = false; } }); async function applyFreqFromInput() { const parsedRaw = parseFreqInput(freqEl.value, jogStep); const parsed = alignFreqToRigStep(parsedRaw); if (parsed === null) { showHint("Freq missing", 1500); return; } if (!freqAllowed(parsed)) { showHint("Out of supported bands", 1500); return; } freqDirty = false; freqEl.disabled = true; showHint("Setting frequency…"); try { await postPath(`/set_freq?hz=${parsed}`); showHint("Freq set", 1500); } catch (err) { showHint("Set freq failed", 2000); console.error(err); } finally { freqEl.disabled = false; } } freqEl.addEventListener("keydown", (e) => { freqDirty = true; if (e.key === "Enter") { e.preventDefault(); applyFreqFromInput(); } }); freqEl.addEventListener("wheel", (e) => { e.preventDefault(); const direction = e.deltaY < 0 ? 1 : -1; jogFreq(direction); }, { passive: false }); // --- Jog wheel --- const jogWheel = document.getElementById("jog-wheel"); const jogIndicator = document.getElementById("jog-indicator"); const jogDownBtn = document.getElementById("jog-down"); const jogUpBtn = document.getElementById("jog-up"); const jogStepEl = document.getElementById("jog-step"); async function jogFreq(direction) { if (lastLocked) { showHint("Locked", 1500); return; } if (lastFreqHz === null) return; const newHz = alignFreqToRigStep(lastFreqHz + direction * jogStep); if (!freqAllowed(newHz)) { showHint("Out of supported bands", 1500); return; } jogAngle = (jogAngle + direction * 15) % 360; jogIndicator.style.transform = `translateX(-50%) rotate(${jogAngle}deg)`; showHint("Setting frequency…"); try { await postPath(`/set_freq?hz=${newHz}`); showHint("Freq set", 1000); } catch (err) { showHint("Set freq failed", 2000); console.error(err); } } jogDownBtn.addEventListener("click", () => jogFreq(-1)); jogUpBtn.addEventListener("click", () => jogFreq(1)); jogWheel.addEventListener("wheel", (e) => { e.preventDefault(); const direction = e.deltaY < 0 ? 1 : -1; jogFreq(direction); }, { passive: false }); // Touch drag on jog wheel let jogTouchY = null; jogWheel.addEventListener("touchstart", (e) => { e.preventDefault(); jogTouchY = e.touches[0].clientY; }, { passive: false }); jogWheel.addEventListener("touchmove", (e) => { e.preventDefault(); if (jogTouchY === null) return; const dy = jogTouchY - e.touches[0].clientY; if (Math.abs(dy) > 12) { jogFreq(dy > 0 ? 1 : -1); jogTouchY = e.touches[0].clientY; } }, { passive: false }); jogWheel.addEventListener("touchend", () => { jogTouchY = null; }); // Mouse drag on jog wheel let jogMouseY = null; jogWheel.addEventListener("mousedown", (e) => { e.preventDefault(); jogMouseY = e.clientY; jogWheel.style.cursor = "grabbing"; }); window.addEventListener("mousemove", (e) => { if (jogMouseY === null) return; const dy = jogMouseY - e.clientY; if (Math.abs(dy) > 10) { jogFreq(dy > 0 ? 1 : -1); jogMouseY = e.clientY; } }); window.addEventListener("mouseup", () => { jogMouseY = null; if (jogWheel) jogWheel.style.cursor = "grab"; }); // Step selector jogStepEl.addEventListener("click", (e) => { const btn = e.target.closest("button[data-step]"); if (!btn) return; jogStep = Math.max(parseInt(btn.dataset.step, 10), minFreqStepHz); jogStepEl.querySelectorAll("button").forEach((b) => b.classList.remove("active")); btn.classList.add("active"); saveSetting("jogStep", jogStep); refreshFreqDisplay(); }); // Restore active jog step button from saved setting { const buttons = Array.from(jogStepEl.querySelectorAll("button[data-step]")); const active = buttons.find((b) => parseInt(b.dataset.step, 10) === jogStep) || buttons.find((b) => parseInt(b.dataset.step, 10) === 1000) || buttons[0]; if (active) { jogStep = parseInt(active.dataset.step, 10); buttons.forEach((b) => b.classList.toggle("active", b === active)); } } async function applyModeFromPicker() { const mode = modeEl.value || ""; if (!mode) { showHint("Mode missing", 1500); return; } modeEl.disabled = true; showHint("Setting mode…"); try { await postPath(`/set_mode?mode=${encodeURIComponent(mode)}`); showHint("Mode set", 1500); } catch (err) { showHint("Set mode failed", 2000); console.error(err); } finally { modeEl.disabled = false; } } modeEl.addEventListener("change", applyModeFromPicker); txLimitInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); txLimitBtn.click(); } }); txLimitBtn.addEventListener("click", async () => { const limit = txLimitInput.value; if (limit === "" || limit === "--") { showHint("Limit missing", 1500); return; } txLimitBtn.disabled = true; showHint("Setting TX limit…"); try { await postPath(`/set_tx_limit?limit=${encodeURIComponent(limit)}`); showHint("TX limit set", 1500); } catch (err) { showHint("TX limit failed", 2000); console.error(err); } finally { txLimitBtn.disabled = false; } }); lockBtn.addEventListener("click", async () => { lockBtn.disabled = true; showHint("Toggling lock…"); try { const nextLock = lockBtn.textContent === "Lock"; await postPath(nextLock ? "/lock" : "/unlock"); showHint("Lock toggled", 1500); } catch (err) { showHint("Lock toggle failed", 2000); console.error(err); } finally { lockBtn.disabled = false; } }); // --- Tab navigation --- document.querySelector(".tab-bar").addEventListener("click", (e) => { const btn = e.target.closest(".tab[data-tab]"); if (!btn) return; document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("active")); btn.classList.add("active"); document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none"); document.getElementById(`tab-${btn.dataset.tab}`).style.display = ""; }); // --- Auth startup sequence --- async function initializeApp() { const authStatus = await checkAuthStatus(); if (authStatus.authenticated) { // User has valid session authRole = authStatus.role; updateAuthUI(); applyAuthRestrictions(); connect(); resizeHeaderSignalCanvas(); startHeaderSignalSampling(); } else { // No valid session - show auth gate // Guest button is shown if guest mode is available (role granted without auth) const allowGuest = authStatus.role === "rx"; showAuthGate(allowGuest); } } // Setup auth form document.getElementById("auth-form").addEventListener("submit", async (e) => { e.preventDefault(); const passphrase = document.getElementById("auth-passphrase").value; const btn = document.querySelector("#auth-form button[type=submit]"); btn.disabled = true; btn.textContent = "Logging in..."; try { const result = await authLogin(passphrase); authRole = result.role; document.getElementById("auth-passphrase").value = ""; hideAuthGate(); updateAuthUI(); applyAuthRestrictions(); connect(); resizeHeaderSignalCanvas(); startHeaderSignalSampling(); } catch (err) { showAuthError("Invalid passphrase"); console.error("Login error:", err); } finally { btn.disabled = false; btn.textContent = "Login"; } }); // Setup guest button const guestBtn = document.getElementById("auth-guest-btn"); if (guestBtn) { guestBtn.addEventListener("click", async () => { authRole = "rx"; document.getElementById("auth-passphrase").value = ""; hideAuthGate(); updateAuthUI(); applyAuthRestrictions(); connect(); resizeHeaderSignalCanvas(); startHeaderSignalSampling(); }); } // Setup header auth button (Login/Logout) const headerAuthBtn = document.getElementById("header-auth-btn"); if (headerAuthBtn) { headerAuthBtn.addEventListener("click", async () => { if (authRole) { // Logged in - show logout confirmation if (confirm("Are you sure you want to logout?")) { await authLogout(); } } else { // Not logged in - show auth gate showAuthGate(false); } }); } // Start the app initializeApp(); window.addEventListener("resize", resizeHeaderSignalCanvas); // --- Leaflet Map (lazy-initialized) --- let aprsMap = null; let aprsMapBaseLayer = null; let aprsMapReceiverMarker = null; const stationMarkers = new Map(); const locatorMarkers = new Map(); const mapMarkers = new Set(); const mapFilter = { aprs: true, ft8: true, wspr: true }; function mapTileSpecForTheme(theme) { if (theme === "dark") { return { url: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", options: { maxZoom: 19, subdomains: "abcd", attribution: '© OpenStreetMap © CARTO', }, }; } return { url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", options: { maxZoom: 19, attribution: '© OpenStreetMap', }, }; } function updateMapBaseLayerForTheme(theme) { if (!aprsMap) return; if (aprsMapBaseLayer) { aprsMap.removeLayer(aprsMapBaseLayer); aprsMapBaseLayer = null; } const spec = mapTileSpecForTheme(theme); aprsMapBaseLayer = L.tileLayer(spec.url, spec.options).addTo(aprsMap); } function initAprsMap() { const mapEl = document.getElementById("aprs-map"); if (!mapEl) return; sizeAprsMapToViewport(); if (aprsMap) return; const hasLocation = serverLat != null && serverLon != null; const center = hasLocation ? [serverLat, serverLon] : [20, 0]; const zoom = hasLocation ? 10 : 2; aprsMap = L.map("aprs-map").setView(center, zoom); updateMapBaseLayerForTheme(currentTheme()); if (hasLocation) { const popupText = serverCallsign ? serverCallsign : "Receiver"; aprsMapReceiverMarker = L.circleMarker([serverLat, serverLon], { radius: 8, color: "#3388ff", fillColor: "#3388ff", fillOpacity: 0.8 }).addTo(aprsMap).bindPopup(popupText); } const aprsFilter = document.getElementById("map-filter-aprs"); const ft8Filter = document.getElementById("map-filter-ft8"); const wsprFilter = document.getElementById("map-filter-wspr"); if (aprsFilter) { aprsFilter.addEventListener("change", () => { mapFilter.aprs = aprsFilter.checked; applyMapFilter(); }); } if (ft8Filter) { ft8Filter.addEventListener("change", () => { mapFilter.ft8 = ft8Filter.checked; applyMapFilter(); }); } if (wsprFilter) { wsprFilter.addEventListener("change", () => { mapFilter.wspr = wsprFilter.checked; applyMapFilter(); }); } } function sizeAprsMapToViewport() { const mapEl = document.getElementById("aprs-map"); if (!mapEl) return; const topPadding = parseFloat(getComputedStyle(document.body).paddingTop) || 0; const available = Math.max(0, window.innerHeight - topPadding); const target = Math.max(150, Math.floor(available * 0.6)); mapEl.style.height = `${target}px`; if (aprsMap) aprsMap.invalidateSize(); } function aprsSymbolIcon(symbolTable, symbolCode) { if (!symbolTable || !symbolCode) return null; const sheet = symbolTable === "/" ? 0 : 1; const code = symbolCode.charCodeAt(0) - 33; const col = code % 16; const row = Math.floor(code / 16); const bgX = -(col * 24); const bgY = -(row * 24); const url = `https://raw.githubusercontent.com/hessu/aprs-symbols/master/png/aprs-symbols-24-${sheet}.png`; return L.divIcon({ className: "", html: `
`, iconSize: [24, 24], iconAnchor: [12, 12], popupAnchor: [0, -12] }); } window.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode) { if (!aprsMap) initAprsMap(); if (!aprsMap) return; const popupContent = `${call}