diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js index 03242457..d6b2abef 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js @@ -3625,12 +3625,19 @@ async function ensureTunedBandwidthCoverage(freqHz, bandwidthHz = coverageGuardB } var _freqOptimisticHz = null; var _freqOptimisticSeq = 0; +function armOptimisticFrequency(freqHz) { + if (!isFiniteNumber(freqHz)) return; + _freqOptimisticSeq += 1; + _freqOptimisticHz = Math.round(freqHz); +} function setRigFrequency(freqHz) { const targetHz = Math.round(freqHz); if (!freqAllowed(targetHz)) { showUnsupportedFreqPopup(targetHz); throw new Error(`Unsupported frequency: ${targetHz}`); } + if (window.trx?.modules.vchan?.interceptFrequency(targetHz)) return; + void window.trx?.modules.vchan?.takeSchedulerControl(); const prevFreqHz = lastFreqHz; const seq = ++_freqOptimisticSeq; _freqOptimisticHz = targetHz; @@ -5713,6 +5720,9 @@ Object.defineProperties(trxState, { decodeHistoryRetentionMin: { get() { return decodeHistoryRetentionMin; } }, + authEnabled: { get() { + return authEnabled; + } }, authRole: { get() { return authRole; } }, @@ -5816,6 +5826,12 @@ var trxCore = Object.freeze({ scheduleUiFrameJob, navigateToTab, rigBadgeColor, + setRigFrequency, + applyLocalTunedFrequency, + armOptimisticFrequency, + syncBandwidthInput, + scheduleSpectrumDraw, + onDecoderRegistryReady, latLonToMaidenhead, locatorToLatLon, haversineKm, diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js index b7bfd423..28a9df3d 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js @@ -1,10 +1,15 @@ // src/plugins/bookmarks.ts var bridge = window; +var trxState = bridge.trx.state; +var trxCore = bridge.trx.core; function bmEl(id) { const element = document.getElementById(id); if (!element) throw new Error(`Missing bookmark element #${id}`); return element; } +function bmOptionalEl(id) { + return document.getElementById(id); +} function errorMessage(error) { return error instanceof Error ? error.message : String(error); } @@ -34,7 +39,7 @@ function bmEsc(str) { return d.innerHTML; } function bmCanControl() { - return typeof bridge.authEnabled !== "undefined" && !bridge.authEnabled || typeof bridge.authRole !== "undefined" && bridge.authRole === "control"; + return !trxState.authEnabled || trxState.authRole === "control"; } function bmSyncAccess() { const canCtrl = bmCanControl(); @@ -44,8 +49,7 @@ function bmSyncAccess() { if (selectAllBtn) selectAllBtn.style.display = canCtrl ? "" : "none"; } function bmListScope() { - const rig = typeof bridge.lastActiveRigId !== "undefined" ? bridge.lastActiveRigId : null; - return rig || "general"; + return trxState.lastActiveRigId || "general"; } async function bmFetchOverlay() { const overlayScope = bmListScope(); @@ -61,7 +65,7 @@ async function bmFetchOverlay() { if (typeof bridge.syncBookmarkMapLocators === "function") { bridge.syncBookmarkMapLocators(bmOverlayList); } - if (typeof bridge.scheduleSpectrumDraw === "function") bridge.scheduleSpectrumDraw(); + trxCore.scheduleSpectrumDraw(); } async function bmFetch(categoryFilter) { let url = "/bookmarks"; @@ -184,12 +188,12 @@ function bmChangePage(delta) { bmRender(bmFilteredList); } function bmReadDecoders() { - return (bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable).filter((d) => bmEl("bm-dec-" + d.id)?.checked).map((d) => d.id); + return trxState.decoderRegistry.filter((d) => d.bookmark_selectable).filter((d) => bmOptionalEl("bm-dec-" + d.id)?.checked).map((d) => d.id); } function bmWriteDecoders(decoders) { const set = new Set(decoders || []); - (bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable).forEach((d) => { - const el = bmEl("bm-dec-" + d.id); + trxState.decoderRegistry.filter((d) => d.bookmark_selectable).forEach((d) => { + const el = bmOptionalEl("bm-dec-" + d.id); if (el) el.checked = set.has(d.id); }); } @@ -197,7 +201,7 @@ function bmBuildDecoderCheckboxes() { const container = bmEl("bm-decoder-checkboxes"); if (!container) return; container.innerHTML = ""; - (bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable).forEach((d) => { + trxState.decoderRegistry.filter((d) => d.bookmark_selectable).forEach((d) => { const label = document.createElement("label"); label.className = "bm-decoder-check"; label.innerHTML = ' ' + d.label; @@ -227,19 +231,17 @@ function bmCloseForm() { if (wrap) wrap.style.display = "none"; } function bmPrefillFromStatus() { - if (typeof bridge.lastFreqHz === "number" && Number.isFinite(bridge.lastFreqHz)) { - bmEl("bm-freq").value = String(Math.round(bridge.lastFreqHz)); + const freqHz = trxState.lastFreqHz; + if (freqHz != null && Number.isFinite(freqHz)) { + bmEl("bm-freq").value = String(Math.round(freqHz)); } - if (typeof bridge.lastModeName === "string" && bridge.lastModeName) { - bmEl("bm-mode").value = bridge.lastModeName; + if (trxState.lastModeName) { + bmEl("bm-mode").value = trxState.lastModeName; } - if (typeof bridge.currentBandwidthHz === "number" && bridge.currentBandwidthHz > 0) { - bmEl("bm-bw").value = String(Math.round(bridge.currentBandwidthHz)); + if (trxState.currentBandwidthHz > 0) { + bmEl("bm-bw").value = String(Math.round(trxState.currentBandwidthHz)); } - const activeDecoders = (bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable && d.activation === "toggle").filter((d) => { - const btn = bmEl(d.id + "-decode-toggle-btn"); - return btn && btn.dataset.enabled === "true"; - }).map((d) => d.id); + const activeDecoders = trxState.decoderRegistry.filter((d) => d.bookmark_selectable && d.activation === "toggle").filter((d) => bmOptionalEl(d.id + "-decode-toggle-btn")?.dataset.enabled === "true").map((d) => d.id); bmWriteDecoders(activeDecoders); } async function bmSave(e) { @@ -320,55 +322,43 @@ async function bmDelete(id) { } function bmApply(bm) { try { - if (typeof bridge.modeEl !== "undefined" && bridge.modeEl) { - bridge.modeEl.value = (bm.mode || "").toUpperCase(); + const modeEl = document.getElementById("mode"); + if (modeEl) { + modeEl.value = (bm.mode || "").toUpperCase(); } if (bm.bandwidth_hz) { - if (typeof bridge.currentBandwidthHz !== "undefined") { - bridge.currentBandwidthHz = bm.bandwidth_hz; - } - bridge.currentBandwidthHz = bm.bandwidth_hz; - if (typeof bridge.syncBandwidthInput === "function") { - bridge.syncBandwidthInput(bm.bandwidth_hz); - } + trxState.currentBandwidthHz = bm.bandwidth_hz; + trxCore.syncBandwidthInput(bm.bandwidth_hz); } - if (typeof bridge.applyLocalTunedFrequency === "function") { - if (typeof bridge._freqOptimisticSeq !== "undefined") { - ++bridge._freqOptimisticSeq; - bridge._freqOptimisticHz = bm.freq_hz; - } - bridge.applyLocalTunedFrequency(bm.freq_hz, true); - } - if (typeof bridge.scheduleSpectrumDraw === "function" && typeof bridge.lastSpectrumData !== "undefined" && bridge.lastSpectrumData) { - bridge.scheduleSpectrumDraw(); + trxCore.armOptimisticFrequency(bm.freq_hz); + trxCore.applyLocalTunedFrequency(bm.freq_hz, true); + if (trxState.lastSpectrumData) { + trxCore.scheduleSpectrumDraw(); } const tunePromise = (async () => { - await bridge.trx?.modules?.vchan?.takeSchedulerControl(); - const onVirtual = await bridge.trx?.modules?.vchan?.interceptMode(bm.mode) ?? false; + await bridge.trx.modules.vchan?.takeSchedulerControl(); + const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false; if (!onVirtual) { - await bridge.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode)); + await trxCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode)); } if (bm.bandwidth_hz) { - const bwHandledByVchan = await bridge.trx?.modules?.vchan?.interceptBandwidth(bm.bandwidth_hz) ?? false; + const bwHandledByVchan = await bridge.trx.modules.vchan?.interceptBandwidth(bm.bandwidth_hz) ?? false; if (!bwHandledByVchan) { - await bridge.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`); + await trxCore.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`); } } - if (typeof bridge.setRigFrequency === "function") { - await bridge.setRigFrequency(bm.freq_hz); - } else { - await bridge.postPath(`/set_freq?hz=${bm.freq_hz}`); - } + trxCore.setRigFrequency(bm.freq_hz); })(); const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0; const modeUp = (bm.mode || "").toUpperCase(); - const allToggleDecoders = (bridge.decoderRegistry || []).filter( + const allToggleDecoders = trxState.decoderRegistry.filter( (d) => d.activation === "toggle" ); const decoderPromise = allToggleDecoders.length ? (async () => { let statusUrl = "/status"; - if (typeof bridge.lastActiveRigId !== "undefined" && bridge.lastActiveRigId) { - statusUrl += "?remote=" + encodeURIComponent(bridge.lastActiveRigId); + const rigId = trxState.lastActiveRigId; + if (rigId) { + statusUrl += "?remote=" + encodeURIComponent(rigId); } const statusResp = await fetch(statusUrl); if (!statusResp.ok) return; @@ -387,7 +377,7 @@ function bmApply(bm) { wanted = currentlyOn; } if (wanted !== currentlyOn) { - toggles.push(bridge.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode")); + toggles.push(trxCore.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode")); } } if (toggles.length) await Promise.all(toggles); @@ -399,8 +389,6 @@ function bmApply(bm) { console.error("Failed to apply bookmark:", err); } } -bridge.trx ??= {}; -bridge.trx.modules ??= {}; bridge.trx.modules.bookmarks = { get overlayList() { return bmOverlayList; @@ -439,8 +427,8 @@ function bmUpdateSelectionUi() { function bmPopulateMoveTarget() { const sel = bmEl("bm-move-target"); if (!sel) return; - const rigIds = typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds) ? bridge.lastRigIds : []; - const displayNames = typeof bridge.lastRigDisplayNames !== "undefined" ? bridge.lastRigDisplayNames : {}; + const rigIds = trxState.lastRigIds; + const displayNames = trxState.lastRigDisplayNames; const prev = sel.value; sel.innerHTML = ""; if (bmScope !== "general") { @@ -545,8 +533,8 @@ async function bmDeleteSelected() { function bmPopulateScopePicker() { const picker = bmEl("bm-scope-picker"); if (!picker) return; - const rigIds = typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds) ? bridge.lastRigIds : []; - const displayNames = typeof bridge.lastRigDisplayNames !== "undefined" ? bridge.lastRigDisplayNames : {}; + const rigIds = trxState.lastRigIds; + const displayNames = trxState.lastRigDisplayNames; const prev = picker.value; while (picker.options.length > 1) picker.remove(1); rigIds.forEach((id) => { @@ -565,9 +553,7 @@ function bmPopulateScopePicker() { (function initBookmarks() { bmSyncAccess(); bmBuildDecoderCheckboxes(); - if (typeof bridge.onDecoderRegistryReady === "function") { - bridge.onDecoderRegistryReady(bmBuildDecoderCheckboxes); - } + trxCore.onDecoderRegistryReady(bmBuildDecoderCheckboxes); bmPopulateScopePicker(); const scopePicker = bmEl("bm-scope-picker"); if (scopePicker) { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vchan.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vchan.js index a3d42565..fd805c97 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vchan.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vchan.js @@ -411,6 +411,15 @@ async function vchanInterceptBandwidth(bwHz) { await vchanSetChannelBandwidth(bwHz); return true; } +function vchanInterceptFrequency(freqHz) { + if (!vchanIsOnVirtual()) return false; + const core = vchanWindow.trx?.core; + const targetHz = Math.round(freqHz); + core?.armOptimisticFrequency(targetHz); + core?.applyLocalTunedFrequency(targetHz); + vchanSetChannelFreq(freqHz); + return true; +} vchanWindow.trx ??= {}; vchanWindow.trx.modules ??= {}; vchanWindow.trx.modules.vchan = { @@ -427,27 +436,10 @@ vchanWindow.trx.modules.vchan = { isOnVirtual: vchanIsOnVirtual, interceptMode: vchanInterceptMode, interceptBandwidth: vchanInterceptBandwidth, + interceptFrequency: vchanInterceptFrequency, takeSchedulerControl: vchanTakeSchedulerControl, releaseToScheduler: vchanToggleSchedulerRelease }; -(function() { - const original = vchanWindow.setRigFrequency; - vchanWindow.setRigFrequency = function(freqHz) { - if (vchanIsOnVirtual()) { - if (vchanWindow.applyLocalTunedFrequency) { - if (typeof vchanWindow._freqOptimisticSeq === "number") { - vchanWindow._freqOptimisticSeq += 1; - vchanWindow._freqOptimisticHz = Math.round(freqHz); - } - vchanWindow.applyLocalTunedFrequency(Math.round(freqHz)); - } - vchanSetChannelFreq(freqHz); - return; - } - void vchanTakeSchedulerControl(); - original?.(freqHz); - }; -})(); (function initSchedulerReleaseControl() { const btn = document.getElementById("scheduler-release-btn"); if (btn) { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts index fb0f8b9e..a6ee84e6 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts @@ -15,6 +15,7 @@ import { applyDecoderRegistryVisibility, decoderRegistry, loadDecoderRegistry, + onDecoderRegistryReady, } from "./core/decoder-registry.js"; import { fetchAuthSession, @@ -201,6 +202,7 @@ interface TrxModules { handleChannels(data: string): void; handleSession(data: string): void; interceptBandwidth(bandwidthHz: number): Promise; + interceptFrequency(frequencyHz: number): boolean; interceptMode(mode: string): Promise; isOnVirtual(): boolean; takeSchedulerControl(): Promise; @@ -217,6 +219,7 @@ interface TrxState { readonly lastRigDisplayNames: Record; readonly initialMapZoom: number; readonly decodeHistoryRetentionMin: number; + readonly authEnabled: boolean; readonly authRole: AuthRole | null; readonly decoderRegistry: typeof decoderRegistry; readonly sseSessionId: string | null; @@ -2476,12 +2479,26 @@ async function ensureTunedBandwidthCoverage(freqHz: number, bandwidthHz = covera let _freqOptimisticHz: number | null = null; let _freqOptimisticSeq = 0; +// Lazy features paint their own optimistic frequency before starting the tune +// round-trip (a bookmark updates the marker before mode and bandwidth settle). +// Arming the guard from there keeps SSE from snapping back to the stale value. +function armOptimisticFrequency(freqHz: number) { + if (!isFiniteNumber(freqHz)) return; + _freqOptimisticSeq += 1; + _freqOptimisticHz = Math.round(freqHz); +} + function setRigFrequency(freqHz: number) { const targetHz = Math.round(freqHz); if (!freqAllowed(targetHz)) { showUnsupportedFreqPopup(targetHz); throw new Error(`Unsupported frequency: ${targetHz}`); } + // A virtual channel owns its own tuning path: the plugin applies the + // optimistic update and posts to the channel API instead of the rig. + if (window.trx?.modules.vchan?.interceptFrequency(targetHz)) return; + // Scheduler control is fire-and-forget — don't block the freq change. + void window.trx?.modules.vchan?.takeSchedulerControl(); // Optimistic local update — visual is instant via CSS overlay + guard. const prevFreqHz = lastFreqHz; const seq = ++_freqOptimisticSeq; @@ -4822,6 +4839,7 @@ Object.defineProperties(trxState, { lastRigDisplayNames: { get() { return lastRigDisplayNames; } }, initialMapZoom: { get() { return initialMapZoom; } }, decodeHistoryRetentionMin: { get() { return decodeHistoryRetentionMin; } }, + authEnabled: { get() { return authEnabled; } }, authRole: { get() { return authRole; } }, decoderRegistry: { get() { return decoderRegistry; } }, sseSessionId: { get() { return sseSessionId; } }, @@ -4855,6 +4873,8 @@ const trxCore = Object.freeze({ saveSetting, loadSetting, showHint, escapeMapHtml, formatFreq, formatFreqForHumans, formatWavelength, formatBwLabel, formatUptime, formatSigStrength, formatSignal, postPath, scheduleUiFrameJob, navigateToTab, rigBadgeColor, + setRigFrequency, applyLocalTunedFrequency, armOptimisticFrequency, + syncBandwidthInput, scheduleSpectrumDraw, onDecoderRegistryReady, latLonToMaidenhead, locatorToLatLon, haversineKm, formatDistanceKm, formatTimeAgo, bookmarkDistanceText, buildBookmarkTooltipText, nearestBookmarkForHz, currentDecodeHistoryRetentionMs, diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/bookmarks.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/bookmarks.ts index 88d14650..d842b6bf 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/bookmarks.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/bookmarks.ts @@ -43,42 +43,60 @@ interface VirtualChannelService { takeSchedulerControl(): Promise; } +/* Application state read through the `window.trx` host contract. This plugin + * is a separate bundle, so it cannot import the application module directly. */ +interface BookmarkHostState { + readonly authEnabled: boolean; + readonly authRole: string | null; + readonly lastActiveRigId: string | null; + readonly lastRigIds: string[]; + readonly lastRigDisplayNames: Record; + readonly lastFreqHz: number | null; + readonly lastModeName: string; + readonly lastSpectrumData: unknown; + currentBandwidthHz: number; + readonly decoderRegistry: readonly DecoderDescriptor[]; +} + +interface BookmarkHostCore { + postPath(path: string): Promise; + setRigFrequency(frequencyHz: number): void; + applyLocalTunedFrequency(frequencyHz: number, forceDisplay?: boolean): void; + armOptimisticFrequency(frequencyHz: number): void; + syncBandwidthInput(bandwidthHz: number): void; + scheduleSpectrumDraw(): void; + onDecoderRegistryReady(callback: () => void): void; +} + interface BookmarkBridge { - authEnabled?: boolean; - authRole?: string | null; - lastActiveRigId?: string | null; - lastRigIds?: string[]; - lastRigDisplayNames?: Record; - lastFreqHz?: number; - lastModeName?: string; - lastSpectrumData?: unknown; - currentBandwidthHz?: number; - modeEl?: HTMLSelectElement | null; - decoderRegistry?: DecoderDescriptor[]; - trx?: { modules?: { bookmarks?: BookmarkService; vchan?: VirtualChannelService } }; + trx: { + state: BookmarkHostState; + core: BookmarkHostCore; + modules: { bookmarks?: BookmarkService; vchan?: VirtualChannelService }; + }; trxUi: { confirm(options: { title: string; message: string; confirmLabel: string; danger?: boolean }): Promise; notify?(message: string, options: { kind: "error" }): void; }; syncBookmarkMapLocators?(bookmarks: readonly Bookmark[]): void; - scheduleSpectrumDraw?(): void; - syncBandwidthInput?(bandwidthHz: number): void; - applyLocalTunedFrequency?(frequencyHz: number, force?: boolean): void; - setRigFrequency?(frequencyHz: number): Promise; - postPath(path: string): Promise; - onDecoderRegistryReady?(callback: () => void): void; - _freqOptimisticSeq?: number; - _freqOptimisticHz?: number; } type BookmarkElement = HTMLElement & HTMLInputElement & HTMLSelectElement; const bridge = window as unknown as BookmarkBridge; +const trxState = bridge.trx.state; +const trxCore = bridge.trx.core; function bmEl(id: string): BookmarkElement { const element = document.getElementById(id); if (!element) throw new Error(`Missing bookmark element #${id}`); return element as BookmarkElement; } +/* Decoder checkboxes and decoder toggle buttons are built from the runtime + * registry, so their elements are legitimately absent for unbuilt decoders. */ +function bmOptionalEl(id: string): BookmarkElement | null { + return document.getElementById(id) as BookmarkElement | null; +} + function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } @@ -119,10 +137,7 @@ function bmEsc(str: unknown): string { } function bmCanControl() { - return ( - (typeof bridge.authEnabled !== "undefined" && !bridge.authEnabled) || - (typeof bridge.authRole !== "undefined" && bridge.authRole === "control") - ); + return !trxState.authEnabled || trxState.authRole === "control"; } // Show/hide the Add Bookmark / Select All buttons based on the current auth role. @@ -136,8 +151,7 @@ function bmSyncAccess() { /** The listing scope: always the active rig (to merge general + rig bookmarks). */ function bmListScope() { - const rig = (typeof bridge.lastActiveRigId !== "undefined") ? bridge.lastActiveRigId : null; - return rig || "general"; + return trxState.lastActiveRigId || "general"; } async function bmFetchOverlay() { @@ -154,7 +168,7 @@ async function bmFetchOverlay() { if (typeof bridge.syncBookmarkMapLocators === "function") { bridge.syncBookmarkMapLocators(bmOverlayList); } - if (typeof bridge.scheduleSpectrumDraw === "function") bridge.scheduleSpectrumDraw(); + trxCore.scheduleSpectrumDraw(); } async function bmFetch(categoryFilter: string): Promise { @@ -310,19 +324,19 @@ function bmChangePage(delta: number): void { // Read decoder checkboxes and return an array of selected decoder names. function bmReadDecoders(): string[] { - return (bridge.decoderRegistry || []) + return trxState.decoderRegistry .filter(d => d.bookmark_selectable) - .filter(d => bmEl("bm-dec-" + d.id)?.checked) + .filter(d => bmOptionalEl("bm-dec-" + d.id)?.checked) .map(d => d.id); } // Set decoder checkboxes to match the given array. function bmWriteDecoders(decoders: readonly string[]): void { const set = new Set(decoders || []); - (bridge.decoderRegistry || []) + trxState.decoderRegistry .filter(d => d.bookmark_selectable) .forEach(d => { - const el = bmEl("bm-dec-" + d.id); + const el = bmOptionalEl("bm-dec-" + d.id); if (el) el.checked = set.has(d.id); }); } @@ -332,7 +346,7 @@ function bmBuildDecoderCheckboxes() { const container = bmEl("bm-decoder-checkboxes"); if (!container) return; container.innerHTML = ""; - (bridge.decoderRegistry || []) + trxState.decoderRegistry .filter(d => d.bookmark_selectable) .forEach(d => { const label = document.createElement("label"); @@ -372,23 +386,21 @@ function bmCloseForm() { } function bmPrefillFromStatus() { - // Use globals maintained by app.js (updated by SSE stream) - if (typeof bridge.lastFreqHz === "number" && Number.isFinite(bridge.lastFreqHz)) { - bmEl("bm-freq").value = String(Math.round(bridge.lastFreqHz)); + // Read live rig state from the host contract (updated by the SSE stream). + const freqHz = trxState.lastFreqHz; + if (freqHz != null && Number.isFinite(freqHz)) { + bmEl("bm-freq").value = String(Math.round(freqHz)); } - if (typeof bridge.lastModeName === "string" && bridge.lastModeName) { - bmEl("bm-mode").value = bridge.lastModeName; + if (trxState.lastModeName) { + bmEl("bm-mode").value = trxState.lastModeName; } - if (typeof bridge.currentBandwidthHz === "number" && bridge.currentBandwidthHz > 0) { - bmEl("bm-bw").value = String(Math.round(bridge.currentBandwidthHz)); + if (trxState.currentBandwidthHz > 0) { + bmEl("bm-bw").value = String(Math.round(trxState.currentBandwidthHz)); } // Prefill decoder checkboxes from current toggle button state. - const activeDecoders = (bridge.decoderRegistry || []) + const activeDecoders = trxState.decoderRegistry .filter(d => d.bookmark_selectable && d.activation === "toggle") - .filter(d => { - const btn = bmEl(d.id + "-decode-toggle-btn"); - return btn && btn.dataset.enabled === "true"; - }) + .filter(d => bmOptionalEl(d.id + "-decode-toggle-btn")?.dataset.enabled === "true") .map(d => d.id); bmWriteDecoders(activeDecoders); } @@ -478,58 +490,44 @@ async function bmDelete(id: string): Promise { function bmApply(bm: Bookmark): void { try { // --- Optimistic UI updates (instant, before any network round-trips) --- - if (typeof bridge.modeEl !== "undefined" && bridge.modeEl) { - bridge.modeEl.value = (bm.mode || "").toUpperCase(); + const modeEl = document.getElementById("mode") as HTMLSelectElement | null; + if (modeEl) { + modeEl.value = (bm.mode || "").toUpperCase(); } if (bm.bandwidth_hz) { - if (typeof bridge.currentBandwidthHz !== "undefined") { - bridge.currentBandwidthHz = bm.bandwidth_hz; - } - bridge.currentBandwidthHz = bm.bandwidth_hz; - if (typeof bridge.syncBandwidthInput === "function") { - bridge.syncBandwidthInput(bm.bandwidth_hz); - } + trxState.currentBandwidthHz = bm.bandwidth_hz; + trxCore.syncBandwidthInput(bm.bandwidth_hz); } - if (typeof bridge.applyLocalTunedFrequency === "function") { - // Set optimistic guard before applying so SSE cannot snap back. - if (typeof bridge._freqOptimisticSeq !== "undefined") { - ++bridge._freqOptimisticSeq; - bridge._freqOptimisticHz = bm.freq_hz; - } - // Force display so the BW overlay is repositioned even when freq is unchanged. - bridge.applyLocalTunedFrequency(bm.freq_hz, true); - } - if (typeof bridge.scheduleSpectrumDraw === "function" && typeof bridge.lastSpectrumData !== "undefined" && bridge.lastSpectrumData) { - bridge.scheduleSpectrumDraw(); + // Set optimistic guard before applying so SSE cannot snap back. + trxCore.armOptimisticFrequency(bm.freq_hz); + // Force display so the BW overlay is repositioned even when freq is unchanged. + trxCore.applyLocalTunedFrequency(bm.freq_hz, true); + if (trxState.lastSpectrumData) { + trxCore.scheduleSpectrumDraw(); } // Take scheduler control up front, then apply mode before bandwidth so a // late SetMode cannot revert a saved WFM bookmark bandwidth to 180 kHz. const tunePromise = (async () => { - await bridge.trx?.modules?.vchan?.takeSchedulerControl(); + await bridge.trx.modules.vchan?.takeSchedulerControl(); - const onVirtual = await bridge.trx?.modules?.vchan?.interceptMode(bm.mode) ?? false; + const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false; if (!onVirtual) { - await bridge.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode)); + await trxCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode)); } if (bm.bandwidth_hz) { const bwHandledByVchan = - await bridge.trx?.modules?.vchan?.interceptBandwidth(bm.bandwidth_hz) ?? false; + await bridge.trx.modules.vchan?.interceptBandwidth(bm.bandwidth_hz) ?? false; if (!bwHandledByVchan) { - await bridge.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`); + await trxCore.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`); } } - // bridge.setRigFrequency is wrapped by vchan.js to redirect to the channel API - // when on a virtual channel, so this call works correctly in both cases. - // It also does its own optimistic update (bridge.applyLocalTunedFrequency) but - // that's a no-op since we already set the same value above. - if (typeof bridge.setRigFrequency === "function") { - await bridge.setRigFrequency(bm.freq_hz); - } else { - await bridge.postPath(`/set_freq?hz=${bm.freq_hz}`); - } + // setRigFrequency redirects to the channel API when a virtual channel is + // active. It repeats the optimistic update applied above, which is a + // no-op because the value is unchanged. + trxCore.setRigFrequency(bm.freq_hz); })(); // Decoder toggles — fire-and-forget. // - Decoders incompatible with the new mode are always turned off @@ -539,13 +537,14 @@ function bmApply(bm: Bookmark): void { // alone. const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0; const modeUp = (bm.mode || "").toUpperCase(); - const allToggleDecoders = (bridge.decoderRegistry || []).filter(d => + const allToggleDecoders = trxState.decoderRegistry.filter(d => d.activation === "toggle" ); const decoderPromise = allToggleDecoders.length ? (async () => { let statusUrl = "/status"; - if (typeof bridge.lastActiveRigId !== "undefined" && bridge.lastActiveRigId) { - statusUrl += "?remote=" + encodeURIComponent(bridge.lastActiveRigId); + const rigId = trxState.lastActiveRigId; + if (rigId) { + statusUrl += "?remote=" + encodeURIComponent(rigId); } const statusResp = await fetch(statusUrl); if (!statusResp.ok) return; @@ -567,7 +566,7 @@ function bmApply(bm: Bookmark): void { wanted = currentlyOn; } if (wanted !== currentlyOn) { - toggles.push(bridge.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode")); + toggles.push(trxCore.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode")); } } if (toggles.length) await Promise.all(toggles); @@ -582,8 +581,6 @@ function bmApply(bm: Bookmark): void { } } -bridge.trx ??= {}; -bridge.trx.modules ??= {}; bridge.trx.modules.bookmarks = { get overlayList() { return bmOverlayList; }, get overlayRevision() { return bmOverlayRevision; }, @@ -619,8 +616,8 @@ function bmUpdateSelectionUi() { function bmPopulateMoveTarget() { const sel = bmEl("bm-move-target"); if (!sel) return; - const rigIds = (typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds)) ? bridge.lastRigIds : []; - const displayNames = (typeof bridge.lastRigDisplayNames !== "undefined") ? bridge.lastRigDisplayNames : {}; + const rigIds = trxState.lastRigIds; + const displayNames = trxState.lastRigDisplayNames; const prev = sel.value; sel.innerHTML = ""; if (bmScope !== "general") { @@ -728,8 +725,8 @@ async function bmDeleteSelected() { function bmPopulateScopePicker() { const picker = bmEl("bm-scope-picker"); if (!picker) return; - const rigIds = (typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds)) ? bridge.lastRigIds : []; - const displayNames = (typeof bridge.lastRigDisplayNames !== "undefined") ? bridge.lastRigDisplayNames : {}; + const rigIds = trxState.lastRigIds; + const displayNames = trxState.lastRigDisplayNames; // Preserve current selection if still valid. const prev = picker.value; while (picker.options.length > 1) picker.remove(1); @@ -756,9 +753,7 @@ function bmPopulateScopePicker() { // Build decoder checkboxes from registry. The registry is fetched async // so we rebuild once it arrives to ensure checkboxes are present. bmBuildDecoderCheckboxes(); - if (typeof bridge.onDecoderRegistryReady === "function") { - bridge.onDecoderRegistryReady(bmBuildDecoderCheckboxes); - } + trxCore.onDecoderRegistryReady(bmBuildDecoderCheckboxes); // Scope picker bmPopulateScopePicker(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vchan.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vchan.ts index 6fe984b8..b616e45b 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vchan.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vchan.ts @@ -31,8 +31,6 @@ interface VirtualChannelBridge { jogUnit?: number; rxActive?: boolean; _audioChannelOverride?: string | null; - _freqOptimisticSeq?: number; - _freqOptimisticHz?: number; renderRdsOverlays?: () => void; updateDocumentTitle?: (rds: unknown) => void; activeChannelRds?: () => unknown; @@ -47,10 +45,16 @@ interface VirtualChannelBridge { positionRdsPsOverlay?: () => void; mwDefaultsForMode?: (mode: string) => [number, ...unknown[]]; showHint?: (message: string, durationMs: number) => void; - applyLocalTunedFrequency?: (frequencyHz: number) => void; - setRigFrequency?: (frequencyHz: number) => void; refreshFreqDisplay?: () => void; - trx?: { modules?: { vchan?: VirtualChannelService } }; + trx?: { + core?: VirtualChannelCoreServices; + modules?: { vchan?: VirtualChannelService }; + }; +} + +interface VirtualChannelCoreServices { + applyLocalTunedFrequency(frequencyHz: number, forceDisplay?: boolean): void; + armOptimisticFrequency(frequencyHz: number): void; } interface VirtualChannelService { @@ -63,6 +67,7 @@ interface VirtualChannelService { isOnVirtual(): boolean; interceptMode(mode: string): Promise; interceptBandwidth(bandwidthHz: number): Promise; + interceptFrequency(frequencyHz: number): boolean; takeSchedulerControl(): Promise; releaseToScheduler(): Promise; } @@ -571,6 +576,19 @@ async function vchanInterceptBandwidth(bwHz: number): Promise { return true; } +// Called by setRigFrequency before it posts /set_freq. When a non-primary +// channel is active the change belongs to the channel API instead of the rig, +// so this applies the optimistic local update and reports the tune as handled. +function vchanInterceptFrequency(freqHz: number): boolean { + if (!vchanIsOnVirtual()) return false; + const core = vchanWindow.trx?.core; + const targetHz = Math.round(freqHz); + core?.armOptimisticFrequency(targetHz); + core?.applyLocalTunedFrequency(targetHz); + vchanSetChannelFreq(freqHz); + return true; +} + vchanWindow.trx ??= {}; vchanWindow.trx.modules ??= {}; vchanWindow.trx.modules.vchan = { @@ -583,34 +601,11 @@ vchanWindow.trx.modules.vchan = { isOnVirtual: vchanIsOnVirtual, interceptMode: vchanInterceptMode, interceptBandwidth: vchanInterceptBandwidth, + interceptFrequency: vchanInterceptFrequency, takeSchedulerControl: vchanTakeSchedulerControl, releaseToScheduler: vchanToggleSchedulerRelease, }; -// Wrap setRigFrequency (defined in app.js, loaded before this file) so that -// frequency changes are redirected to the active virtual channel instead of -// the server when on a non-primary channel. -(function() { - const original = vchanWindow.setRigFrequency; - vchanWindow.setRigFrequency = function(freqHz: number) { - if (vchanIsOnVirtual()) { - // Optimistic local update first, then fire-and-forget channel API. - if (vchanWindow.applyLocalTunedFrequency) { - if (typeof vchanWindow._freqOptimisticSeq === "number") { - vchanWindow._freqOptimisticSeq += 1; - vchanWindow._freqOptimisticHz = Math.round(freqHz); - } - vchanWindow.applyLocalTunedFrequency(Math.round(freqHz)); - } - vchanSetChannelFreq(freqHz); - return; - } - // Scheduler control is fire-and-forget — don't block the freq change. - void vchanTakeSchedulerControl(); - original?.(freqHz); - }; -})(); - (function initSchedulerReleaseControl() { const btn = document.getElementById("scheduler-release-btn"); if (btn) { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/bookmarks.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/bookmarks.test.mjs index 15b9440b..b1ae08ed 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/bookmarks.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/bookmarks.test.mjs @@ -26,6 +26,48 @@ class ElementFixture { querySelector() { return null; } } +// Mirrors the `window.trx` host contract published by app.ts. The plugin is a +// separate bundle, so every application service it uses arrives this way. +function hostFixture(overrides = {}) { + const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0 }; + const state = { + authEnabled: false, + authRole: "control", + lastActiveRigId: null, + lastRigIds: [], + lastRigDisplayNames: {}, + lastFreqHz: 14_074_000, + lastModeName: "USB", + lastSpectrumData: null, + currentBandwidthHz: 2400, + decoderRegistry: [], + ...overrides, + }; + const core = { + postPath: async (path) => { calls.postPath.push(path); }, + setRigFrequency: (hz) => { calls.setRigFrequency.push(hz); }, + applyLocalTunedFrequency: (hz, force) => { calls.applyLocalTunedFrequency.push([hz, force]); }, + armOptimisticFrequency: (hz) => { calls.armOptimisticFrequency.push(hz); }, + syncBandwidthInput: (hz) => { calls.syncBandwidthInput.push(hz); }, + scheduleSpectrumDraw: () => { calls.scheduleSpectrumDraw += 1; }, + onDecoderRegistryReady: () => {}, + }; + return { window: { trx: { state, core, modules: {} }, trxUi: { confirm: async () => true } }, calls }; +} + +function documentFixture(element) { + return { + getElementById: element, + querySelector: () => new ElementFixture(), + querySelectorAll: () => [], + createElement: () => new ElementFixture(), + createTextNode: (text) => ({ textContent: text }), + addEventListener() {}, + }; +} + +const source = await readFile(new URL("../../assets/web/generated/bookmarks.js", import.meta.url), "utf8"); + test("bookmarks register an explicit typed service for application consumers", async () => { const elements = new Map(); const element = (id) => { @@ -36,33 +78,15 @@ test("bookmarks register an explicit typed service for application consumers", a element("bm-category-filter").options.push({ value: "" }); element("bm-mode-filter").options.push({ value: "" }); const bookmarks = [{ id: "one", name: "Local", freq_hz: 145_500_000, mode: "FM", scope: "general" }]; - const window = { - trx: { modules: {} }, - trxUi: { confirm: async () => true }, - decoderRegistry: [], - }; + const { window } = hostFixture(); const context = vm.createContext({ window, - document: { - getElementById: element, - querySelector: () => new ElementFixture(), - querySelectorAll: () => [], - createElement: () => new ElementFixture(), - createTextNode: (text) => ({ textContent: text }), - addEventListener() {}, - }, + document: documentFixture(element), fetch: async () => ({ ok: true, json: async () => bookmarks }), CSS: { escape: (value) => value }, Element: ElementFixture, - Set, - Map, - Array, - Number, - String, - Promise, console, }); - const source = await readFile(new URL("../../assets/web/generated/bookmarks.js", import.meta.url), "utf8"); new vm.Script(source).runInContext(context); await new Promise((resolve) => setImmediate(resolve)); @@ -73,3 +97,80 @@ test("bookmarks register an explicit typed service for application consumers", a assert.equal(typeof window.trx.modules.bookmarks.populateScopePicker, "function"); assert.equal(globalThis.bmOverlayList, undefined); }); + +test("applying a bookmark drives tuning through the host services", async () => { + const elements = new Map(); + const element = (id) => { + if (!elements.has(id)) elements.set(id, new ElementFixture()); + return elements.get(id); + }; + const { window, calls } = hostFixture({ + lastActiveRigId: "sdr", + decoderRegistry: [ + { id: "ft8", label: "FT8", activation: "toggle", active_modes: ["USB"], bookmark_selectable: true }, + { id: "cw", label: "CW", activation: "toggle", active_modes: ["CW"], bookmark_selectable: true }, + ], + }); + const context = vm.createContext({ + window, + document: documentFixture(element), + fetch: async (url) => ({ + ok: true, + json: async () => (url.startsWith("/status") ? { ft8_decode_enabled: false, cw_decode_enabled: true } : []), + }), + CSS: { escape: (value) => value }, + Element: ElementFixture, + console, + }); + new vm.Script(source).runInContext(context); + await new Promise((resolve) => setImmediate(resolve)); + calls.postPath.length = 0; + + window.trx.modules.bookmarks.apply({ + id: "ft8-20m", name: "FT8 20m", freq_hz: 14_074_000, mode: "USB", bandwidth_hz: 3000, decoders: ["ft8"], + }); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + + // Optimistic UI: the guard is armed before the display update so a stale SSE + // frame cannot snap the marker back while the tune is in flight. + assert.deepEqual(calls.armOptimisticFrequency, [14_074_000]); + assert.deepEqual(calls.applyLocalTunedFrequency, [[14_074_000, true]]); + assert.deepEqual(calls.syncBandwidthInput, [3000]); + assert.equal(window.trx.state.currentBandwidthHz, 3000); + assert.equal(element("mode").value, "USB"); + + // Rig commands: mode and bandwidth over postPath, frequency via the core + // tuning service so virtual-channel redirection still applies. + assert.deepEqual(calls.setRigFrequency, [14_074_000]); + assert.ok(calls.postPath.includes("/set_mode?mode=USB")); + assert.ok(calls.postPath.includes("/set_bandwidth?hz=3000")); + // ft8 is selected and off; cw is on but incompatible with USB. + assert.ok(calls.postPath.includes("/toggle_ft8_decode")); + assert.ok(calls.postPath.includes("/toggle_cw_decode")); +}); + +test("bookmark controls follow the host authentication state", async () => { + const elements = new Map(); + const element = (id) => { + if (!elements.has(id)) elements.set(id, new ElementFixture()); + return elements.get(id); + }; + const { window } = hostFixture({ authEnabled: true, authRole: "rx" }); + const context = vm.createContext({ + window, + document: documentFixture(element), + fetch: async () => ({ ok: true, json: async () => [] }), + CSS: { escape: (value) => value }, + Element: ElementFixture, + console, + }); + new vm.Script(source).runInContext(context); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(element("bm-add-btn").style.display, "none"); + + window.trx.state.authRole = "control"; + await window.trx.modules.bookmarks.fetch(""); + assert.equal(element("bm-add-btn").style.display, ""); +}); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/vchan.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/vchan.test.mjs index b555f8e5..6636ad11 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/vchan.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/vchan.test.mjs @@ -34,6 +34,8 @@ test("virtual channels expose typed SSE and interception boundaries", async () = assert.equal(typeof service.applyCapabilities, "function"); assert.equal(await service.interceptMode("USB"), false); assert.equal(await service.interceptBandwidth(2400), false); + // No virtual channel is active, so tuning stays with the physical rig. + assert.equal(service.interceptFrequency(14_074_000), false); assert.equal(window.vchanHandleSession, undefined); assert.equal(window.vchanInterceptBandwidth, undefined); });