af45c32222
Virtual channel display: - vchan.js: wrap refreshFreqDisplay() so the main freq field always shows the active virtual channel's frequency instead of channel 0's; expose vchanSyncAccentUI() to add vchan-ch-active CSS class (colored border) to #freq and #spectrum-bw-input when on a non-primary channel - style.css: --vchan-color (#38bdf8 sky-blue), .vchan-ch-active box-shadow, vchan-picker active button left-border accent Scheduler multi-channel slots: - scheduler.rs: add center_hz (Option<u64>) and bookmark_ids (Vec<String>) to ScheduleEntry; SchedulerStatus gains last_center_hz and last_bookmark_ids; background task sends SetCenterFreq before SetFreq when center_hz is set and records extra bookmark_ids in status - scheduler.js: center-freq input and extra-channel bookmark picker (tag list with + / × buttons) in the add-entry form; extra channels shown in the entries table - index.html: center freq field + extra bookmark picker widgets; table gains Center freq and Extra channels columns Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Stan Grams <sjg@haxx.space>
288 lines
9.2 KiB
JavaScript
288 lines
9.2 KiB
JavaScript
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
|
//
|
|
// SPDX-License-Identifier: BSD-2-Clause
|
|
|
|
// --- Virtual Channels Plugin ---
|
|
//
|
|
// Handles the `session` and `channels` SSE events emitted by /events and
|
|
// provides the channel picker UI (SDR-only, shown when filter_controls is set).
|
|
|
|
let vchanSessionId = null;
|
|
let vchanRigId = null;
|
|
let vchanChannels = [];
|
|
let vchanActiveId = null;
|
|
|
|
function vchanFmtFreq(hz) {
|
|
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
|
if (hz >= 1e9) return (hz / 1e9).toFixed(4).replace(/\.?0+$/, "") + "\u202fGHz";
|
|
if (hz >= 1e6) return (hz / 1e6).toFixed(4).replace(/\.?0+$/, "") + "\u202fMHz";
|
|
if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + "\u202fkHz";
|
|
return hz + "\u202fHz";
|
|
}
|
|
|
|
// Called by app.js when the SSE `session` event arrives.
|
|
function vchanHandleSession(data) {
|
|
try {
|
|
const d = JSON.parse(data);
|
|
vchanSessionId = d.session_id || null;
|
|
} catch (e) {
|
|
console.warn("vchan: bad session event", e);
|
|
}
|
|
}
|
|
|
|
// Called by app.js when the SSE `channels` event arrives.
|
|
function vchanHandleChannels(data) {
|
|
try {
|
|
const d = JSON.parse(data);
|
|
vchanRigId = d.rig_id || null;
|
|
vchanChannels = d.channels || [];
|
|
// If the active channel was evicted, fall back to channel 0.
|
|
const ids = new Set(vchanChannels.map(c => c.id));
|
|
if (vchanActiveId && !ids.has(vchanActiveId)) {
|
|
vchanActiveId = vchanChannels.length > 0 ? vchanChannels[0].id : null;
|
|
}
|
|
vchanRender();
|
|
} catch (e) {
|
|
console.warn("vchan: bad channels event", e);
|
|
}
|
|
}
|
|
|
|
function vchanRender() {
|
|
const picker = document.getElementById("vchan-picker");
|
|
if (!picker) return;
|
|
picker.innerHTML = "";
|
|
|
|
vchanChannels.forEach(ch => {
|
|
const btn = document.createElement("button");
|
|
btn.type = "button";
|
|
btn.title = `Ch ${ch.index}: ${vchanFmtFreq(ch.freq_hz)} ${ch.mode} · ${ch.subscribers} subscriber${ch.subscribers !== 1 ? "s" : ""}`;
|
|
if (ch.id === vchanActiveId) btn.classList.add("active");
|
|
|
|
const label = document.createElement("span");
|
|
label.className = "vchan-label";
|
|
label.textContent = `${ch.index}: ${vchanFmtFreq(ch.freq_hz)} ${ch.mode}`;
|
|
btn.appendChild(label);
|
|
|
|
if (!ch.permanent) {
|
|
const del = document.createElement("span");
|
|
del.className = "vchan-del";
|
|
del.textContent = "\u00d7";
|
|
del.title = "Delete channel";
|
|
del.addEventListener("click", e => {
|
|
e.stopPropagation();
|
|
vchanDelete(ch.id);
|
|
});
|
|
btn.appendChild(del);
|
|
}
|
|
|
|
btn.addEventListener("click", () => {
|
|
if (ch.id !== vchanActiveId) vchanSubscribe(ch.id);
|
|
});
|
|
|
|
picker.appendChild(btn);
|
|
});
|
|
|
|
// "+" button — allocate a new channel at the current VFO frequency.
|
|
const addBtn = document.createElement("button");
|
|
addBtn.type = "button";
|
|
addBtn.className = "vchan-add";
|
|
addBtn.textContent = "+";
|
|
addBtn.title = "Allocate new virtual channel at current frequency";
|
|
addBtn.addEventListener("click", vchanAllocate);
|
|
picker.appendChild(addBtn);
|
|
|
|
vchanSyncAccentUI();
|
|
}
|
|
|
|
async function vchanAllocate() {
|
|
if (!vchanSessionId || !vchanRigId) return;
|
|
|
|
// Use the last known rig frequency and mode as the starting point.
|
|
const freqHz = (typeof lastFreqHz === "number" && lastFreqHz > 0)
|
|
? lastFreqHz
|
|
: 0;
|
|
const modeEl = document.getElementById("mode");
|
|
const mode = modeEl ? (modeEl.value || "USB") : "USB";
|
|
|
|
try {
|
|
const resp = await fetch(`/channels/${encodeURIComponent(vchanRigId)}`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ session_id: vchanSessionId, freq_hz: freqHz, mode }),
|
|
});
|
|
if (!resp.ok) {
|
|
const msg = await resp.text().catch(() => String(resp.status));
|
|
console.warn("vchan: allocate failed —", msg);
|
|
return;
|
|
}
|
|
const ch = await resp.json();
|
|
vchanActiveId = ch.id;
|
|
// The SSE `channels` event will trigger vchanRender(); optimistically
|
|
// mark active so the picker feels responsive even before the event arrives.
|
|
vchanRender();
|
|
} catch (e) {
|
|
console.error("vchan: allocate error", e);
|
|
}
|
|
}
|
|
|
|
async function vchanDelete(channelId) {
|
|
if (!vchanRigId) return;
|
|
try {
|
|
const resp = await fetch(
|
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}`,
|
|
{ method: "DELETE" }
|
|
);
|
|
if (!resp.ok) {
|
|
console.warn("vchan: delete failed", resp.status);
|
|
}
|
|
// Channel list updates via SSE `channels` event.
|
|
} catch (e) {
|
|
console.error("vchan: delete error", e);
|
|
}
|
|
}
|
|
|
|
async function vchanSubscribe(channelId) {
|
|
if (!vchanSessionId || !vchanRigId) return;
|
|
try {
|
|
const resp = await fetch(
|
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ session_id: vchanSessionId }),
|
|
}
|
|
);
|
|
if (!resp.ok) {
|
|
console.warn("vchan: subscribe failed", resp.status);
|
|
return;
|
|
}
|
|
vchanActiveId = channelId;
|
|
vchanRender();
|
|
} catch (e) {
|
|
console.error("vchan: subscribe error", e);
|
|
}
|
|
}
|
|
|
|
// Called by app.js from applyCapabilities().
|
|
// Shows the channel picker only for SDR rigs.
|
|
function vchanApplyCapabilities(caps) {
|
|
const row = document.getElementById("vchan-row");
|
|
if (!row) return;
|
|
row.style.display = (caps && caps.filter_controls) ? "" : "none";
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Freq / mode interception + UI accent
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Returns true when the active channel is a non-primary (virtual) channel.
|
|
function vchanIsOnVirtual() {
|
|
if (!vchanActiveId || vchanChannels.length === 0) return false;
|
|
return vchanActiveId !== vchanChannels[0].id;
|
|
}
|
|
|
|
function vchanActiveChannel() {
|
|
return vchanChannels.find(c => c.id === vchanActiveId) || null;
|
|
}
|
|
|
|
// Update the main freq input to show the virtual channel's frequency.
|
|
function vchanUpdateFreqDisplay() {
|
|
const ch = vchanActiveChannel();
|
|
if (!ch) return;
|
|
const el = document.getElementById("freq");
|
|
if (!el) return;
|
|
if (typeof formatFreqForStep === "function" && typeof jogUnit !== "undefined") {
|
|
el.value = formatFreqForStep(ch.freq_hz, jogUnit);
|
|
} else {
|
|
el.value = (ch.freq_hz / 1e6).toFixed(6).replace(/\.?0+$/, "");
|
|
}
|
|
}
|
|
|
|
// Add / remove the vchan accent class from the freq and BW inputs.
|
|
function vchanSyncAccentUI() {
|
|
const onVirtual = vchanIsOnVirtual();
|
|
const freqEl = document.getElementById("freq");
|
|
const bwEl = document.getElementById("spectrum-bw-input");
|
|
if (freqEl) freqEl.classList.toggle("vchan-ch-active", onVirtual);
|
|
if (bwEl) bwEl.classList.toggle("vchan-ch-active", onVirtual);
|
|
if (onVirtual) {
|
|
vchanUpdateFreqDisplay();
|
|
} else if (typeof _origRefreshFreqDisplay === "function") {
|
|
_origRefreshFreqDisplay();
|
|
}
|
|
}
|
|
|
|
// Saved reference to the original refreshFreqDisplay from app.js.
|
|
let _origRefreshFreqDisplay = null;
|
|
|
|
async function vchanSetChannelFreq(freqHz) {
|
|
if (!vchanRigId || !vchanActiveId) return;
|
|
try {
|
|
const resp = await fetch(
|
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/freq`,
|
|
{
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ freq_hz: Math.round(freqHz) }),
|
|
}
|
|
);
|
|
if (!resp.ok) console.warn("vchan: set freq failed", resp.status);
|
|
} catch (e) {
|
|
console.error("vchan: set freq error", e);
|
|
}
|
|
}
|
|
|
|
async function vchanSetChannelMode(mode) {
|
|
if (!vchanRigId || !vchanActiveId) return;
|
|
try {
|
|
const resp = await fetch(
|
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/mode`,
|
|
{
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ mode }),
|
|
}
|
|
);
|
|
if (!resp.ok) console.warn("vchan: set mode failed", resp.status);
|
|
} catch (e) {
|
|
console.error("vchan: set mode error", e);
|
|
}
|
|
}
|
|
|
|
// Called by app.js (applyModeFromPicker) and bookmarks.js (bmApply) before
|
|
// sending /set_mode to the server. Returns true if the change was handled
|
|
// by the virtual channel (caller should skip the server request).
|
|
window.vchanInterceptMode = async function(mode) {
|
|
if (!vchanIsOnVirtual()) return false;
|
|
await vchanSetChannelMode(mode);
|
|
return true;
|
|
};
|
|
|
|
// 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 _orig = window.setRigFrequency;
|
|
window.setRigFrequency = async function(freqHz) {
|
|
if (vchanIsOnVirtual()) {
|
|
await vchanSetChannelFreq(freqHz);
|
|
return;
|
|
}
|
|
if (typeof _orig === "function") return _orig(freqHz);
|
|
};
|
|
})();
|
|
|
|
// Wrap refreshFreqDisplay so the main freq field stays in sync with the
|
|
// active virtual channel's frequency (SSE rig-state updates would otherwise
|
|
// constantly overwrite it with channel 0's freq).
|
|
(function() {
|
|
_origRefreshFreqDisplay = window.refreshFreqDisplay;
|
|
window.refreshFreqDisplay = function() {
|
|
if (vchanIsOnVirtual()) {
|
|
vchanUpdateFreqDisplay();
|
|
return;
|
|
}
|
|
if (typeof _origRefreshFreqDisplay === "function") _origRefreshFreqDisplay();
|
|
};
|
|
})();
|