const freqEl = document.getElementById("freq"); 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 freqBtn = document.getElementById("freq-apply"); const modeBtn = document.getElementById("mode-apply"); 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"); let lastControl; let lastTxEn = null; let lastRendered = null; let rigName = "Rig"; let hintTimer = null; let sigMeasuring = false; let sigSamples = []; let lastFreqHz = null; let jogStep = 1000; // default 1 kHz 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; 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 modeDirty = false; let initialized = false; let lastEventAt = Date.now(); let es; let esHeartbeat; 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 parseFreqInput(val) { 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) { // Heuristic when no unit is provided: large numbers are kHz/Hz, small numbers are MHz. if (num >= 1_000_000) { // Assume already Hz. } else if (num >= 1_000) { num *= 1_000; // treat as kHz } else { num *= 1_000_000; // treat as MHz } } return Math.round(num); } 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 (dbm <= -121) return 0; if (dbm >= -73) return 9 + (dbm + 73) / 10; return (dbm + 121) / 6; } function formatSignal(sUnits) { if (sUnits <= 9) return `S${sUnits.toFixed(1)}`; const overDb = (sUnits - 9) * 10; return `S9 + ${overDb.toFixed(0)}dB`; } function setDisabled(disabled) { [freqEl, modeEl, freqBtn, modeBtn, 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 = "trx-rs"; if (serverVersion) title += ` v${serverVersion}`; if (serverCallsign) title += ` @ ${serverCallsign}'s`; title += ` ${rigName}`; 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 = ""; const empty = document.createElement("option"); empty.value = ""; empty.textContent = "--"; modeEl.appendChild(empty); supportedModes.forEach((m) => { const opt = document.createElement("option"); opt.value = m; opt.textContent = m; modeEl.appendChild(opt); }); } } if (update.info && update.info.capabilities) { updateSupportedBands(update.info.capabilities); } if (update.status && update.status.freq && typeof update.status.freq.hz === "number") { lastFreqHz = update.status.freq.hz; if (!freqDirty) { freqEl.value = formatFreq(update.status.freq.hz); } } if (!modeDirty && update.status && update.status.mode) { const mode = normalizeMode(update.status.mode); modeEl.value = mode ? mode.toUpperCase() : ""; } 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 = ""; } } 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); const pct = sUnits <= 9 ? Math.max(0, Math.min(100, (sUnits / 9) * 100)) : 100; signalBar.style.width = `${pct}%`; signalValue.textContent = formatSignal(sUnits); if (sigMeasuring) { sigSamples.push(sUnits); sigMeasureBtn.textContent = `Stop (${sigSamples.length})`; } } else { signalBar.style.width = "0%"; signalValue.textContent = "--"; } 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.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) { document.getElementById("about-rig-access").textContent = `Serial (${access.Serial.port || "?"}, ${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; } 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 connect() { if (es) { es.close(); } if (esHeartbeat) { clearInterval(esHeartbeat); } es = new EventSource("/events"); lastEventAt = Date.now(); 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 = () => { powerHint.textContent = "Disconnected, retrying…"; es.close(); setTimeout(connect, 1000); }; esHeartbeat = setInterval(() => { const now = Date.now(); if (now - lastEventAt > 15000) { es.close(); connect(); } }, 5000); } async function postPath(path) { const resp = await fetch(path, { method: "POST" }); 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; } }); freqBtn.addEventListener("click", async () => { const parsed = parseFreqInput(freqEl.value); if (parsed === null) { showHint("Freq missing", 1500); return; } if (!freqAllowed(parsed)) { showHint("Out of supported bands", 1500); return; } freqDirty = false; freqBtn.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 { freqBtn.disabled = false; } }); freqEl.addEventListener("keydown", (e) => { freqDirty = true; if (e.key === "Enter") { e.preventDefault(); freqBtn.click(); } }); // --- 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 = 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 = parseInt(btn.dataset.step, 10); jogStepEl.querySelectorAll("button").forEach((b) => b.classList.remove("active")); btn.classList.add("active"); }); modeBtn.addEventListener("click", async () => { const mode = modeEl.value || ""; if (!mode) { showHint("Mode missing", 1500); return; } modeDirty = false; modeBtn.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 { modeBtn.disabled = false; } }); modeEl.addEventListener("input", () => { modeDirty = true; }); 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 = ""; }); connect(); // --- Leaflet Map (lazy-initialized) --- let aprsMap = null; let aprsMapReceiverMarker = null; const stationMarkers = new Map(); function initAprsMap() { if (aprsMap) return; const mapEl = document.getElementById("aprs-map"); if (!mapEl) 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); L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 19, attribution: '© OpenStreetMap' }).addTo(aprsMap); 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); } } 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}