[feat](trx-frontend-http): vchan freq display sync, BW accent, scheduler multi-channel

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>
This commit is contained in:
2026-03-11 07:22:36 +01:00
parent cef1741e40
commit af45c32222
5 changed files with 209 additions and 16 deletions
@@ -728,7 +728,7 @@
</div> </div>
<table class="sch-ts-table"> <table class="sch-ts-table">
<thead> <thead>
<tr><th>Start</th><th>End</th><th>Bookmark</th><th>Label</th><th>Interleave (min)</th><th></th></tr> <tr><th>Start</th><th>End</th><th>Center freq</th><th>Primary bookmark</th><th>Extra channels</th><th>Label</th><th>Interleave (min)</th><th></th></tr>
</thead> </thead>
<tbody id="scheduler-ts-tbody"></tbody> <tbody id="scheduler-ts-tbody"></tbody>
</table> </table>
@@ -739,9 +739,19 @@
<label class="sch-label">End (UTC) <label class="sch-label">End (UTC)
<input type="time" id="scheduler-ts-end" class="status-input" title="Set both to 00:00 for all-day" /> <input type="time" id="scheduler-ts-end" class="status-input" title="Set both to 00:00 for all-day" />
</label> </label>
<label class="sch-label">Bookmark <label class="sch-label" id="scheduler-ts-center-hz-wrap" title="SDR only — sets center frequency before tuning">Center freq (Hz, SDR)
<input type="number" id="scheduler-ts-center-hz" class="status-input" min="0" placeholder="optional" style="width:9rem;" />
</label>
<label class="sch-label">Primary bookmark
<select id="scheduler-ts-bookmark" class="status-input" aria-label="Entry bookmark"></select> <select id="scheduler-ts-bookmark" class="status-input" aria-label="Entry bookmark"></select>
</label> </label>
<label class="sch-label">Extra channels (virtual)
<div id="scheduler-ts-extra-bm-list" class="sch-extra-bm-list"></div>
<div style="display:flex;gap:0.4rem;margin-top:0.3rem;">
<select id="scheduler-ts-extra-bm-pick" class="status-input" aria-label="Extra bookmark"></select>
<button id="scheduler-ts-extra-bm-add" type="button" class="sch-write" style="padding:0 0.7rem;">+</button>
</div>
</label>
<label class="sch-label">Label (optional) <label class="sch-label">Label (optional)
<input type="text" id="scheduler-ts-label" class="status-input" placeholder="e.g. 40m FT8" /> <input type="text" id="scheduler-ts-label" class="status-input" placeholder="e.g. 40m FT8" />
</label> </label>
@@ -279,10 +279,17 @@
const tr = document.createElement("tr"); const tr = document.createElement("tr");
const il = entry.interleave_min ? String(entry.interleave_min) + " min" : "—"; const il = entry.interleave_min ? String(entry.interleave_min) + " min" : "—";
const allDay = entry.start_min === entry.end_min; const allDay = entry.start_min === entry.end_min;
const centerCell = entry.center_hz ? formatFreq(entry.center_hz) : "—";
const extraIds = Array.isArray(entry.bookmark_ids) ? entry.bookmark_ids : [];
const extraCell = extraIds.length
? extraIds.map(function (id) { return escHtml(bmName(id)); }).join(", ")
: "—";
tr.innerHTML = tr.innerHTML =
'<td>' + (allDay ? "All day" : minToHHMM(entry.start_min)) + '</td>' + '<td>' + (allDay ? "All day" : minToHHMM(entry.start_min)) + '</td>' +
'<td>' + (allDay ? "—" : minToHHMM(entry.end_min)) + '</td>' + '<td>' + (allDay ? "—" : minToHHMM(entry.end_min)) + '</td>' +
'<td>' + centerCell + '</td>' +
'<td>' + bmName(entry.bookmark_id) + '</td>' + '<td>' + bmName(entry.bookmark_id) + '</td>' +
'<td>' + extraCell + '</td>' +
'<td>' + escHtml(entry.label || "") + '</td>' + '<td>' + escHtml(entry.label || "") + '</td>' +
'<td>' + il + '</td>' + '<td>' + il + '</td>' +
'<td><button class="sch-write sch-remove-btn" data-idx="' + idx + '" type="button">Remove</button></td>'; '<td><button class="sch-write sch-remove-btn" data-idx="' + idx + '" type="button">Remove</button></td>';
@@ -334,6 +341,7 @@
const bmEl = document.getElementById("scheduler-ts-bookmark"); const bmEl = document.getElementById("scheduler-ts-bookmark");
const labelEl = document.getElementById("scheduler-ts-label"); const labelEl = document.getElementById("scheduler-ts-label");
const ilEl = document.getElementById("scheduler-ts-entry-interleave"); const ilEl = document.getElementById("scheduler-ts-entry-interleave");
const centerHzEl = document.getElementById("scheduler-ts-center-hz");
if (!startEl || !endEl || !bmEl) return; if (!startEl || !endEl || !bmEl) return;
const startMin = hhmmToMin(startEl.value); const startMin = hhmmToMin(startEl.value);
@@ -342,9 +350,12 @@
const label = labelEl ? labelEl.value.trim() : ""; const label = labelEl ? labelEl.value.trim() : "";
const ilVal = ilEl ? parseInt(ilEl.value, 10) : NaN; const ilVal = ilEl ? parseInt(ilEl.value, 10) : NaN;
const entryInterleave = !isNaN(ilVal) && ilVal > 0 ? ilVal : null; const entryInterleave = !isNaN(ilVal) && ilVal > 0 ? ilVal : null;
const centerHzRaw = centerHzEl ? parseInt(centerHzEl.value, 10) : NaN;
const centerHz = !isNaN(centerHzRaw) && centerHzRaw > 0 ? centerHzRaw : null;
const extraBmIds = pendingExtraBmIds.slice();
if (!bmId) { if (!bmId) {
alert("Please select a bookmark."); alert("Please select a primary bookmark.");
return; return;
} }
@@ -361,6 +372,8 @@
bookmark_id: bmId, bookmark_id: bmId,
label: label || null, label: label || null,
interleave_min: entryInterleave, interleave_min: entryInterleave,
center_hz: centerHz,
bookmark_ids: extraBmIds,
}); });
startEl.value = ""; startEl.value = "";
@@ -368,6 +381,9 @@
bmEl.value = ""; bmEl.value = "";
if (labelEl) labelEl.value = ""; if (labelEl) labelEl.value = "";
if (ilEl) ilEl.value = ""; if (ilEl) ilEl.value = "";
if (centerHzEl) centerHzEl.value = "";
pendingExtraBmIds = [];
renderExtraBmList();
renderTimespanEntries(); renderTimespanEntries();
} }
@@ -497,21 +513,64 @@
const addBtn = document.getElementById("scheduler-ts-add-btn"); const addBtn = document.getElementById("scheduler-ts-add-btn");
if (addBtn) addBtn.addEventListener("click", addEntry); if (addBtn) addBtn.addEventListener("click", addEntry);
wireExtraBmAdd();
} }
function populateTsBookmarkSelect() { function populateTsBookmarkSelect() {
const sel = document.getElementById("scheduler-ts-bookmark"); const sel = document.getElementById("scheduler-ts-bookmark");
if (!sel) return; const extraSel = document.getElementById("scheduler-ts-extra-bm-pick");
const prev = sel.value; [sel, extraSel].forEach(function (el) {
sel.innerHTML = '<option value="">— select bookmark —</option>'; if (!el) return;
bookmarkList.forEach(function (bm) { const prev = el.value;
const opt = document.createElement("option"); el.innerHTML = '<option value="">— select bookmark —</option>';
opt.value = bm.id; bookmarkList.forEach(function (bm) {
opt.textContent = bm.name + " (" + formatFreq(bm.freq_hz) + " " + bm.mode + ")"; const opt = document.createElement("option");
sel.appendChild(opt); opt.value = bm.id;
opt.textContent = bm.name + " (" + formatFreq(bm.freq_hz) + " " + bm.mode + ")";
el.appendChild(opt);
});
if (prev) el.value = prev;
});
}
// Pending extra bookmark IDs for the entry being composed in the add form.
let pendingExtraBmIds = [];
function renderExtraBmList() {
const container = document.getElementById("scheduler-ts-extra-bm-list");
if (!container) return;
container.innerHTML = "";
pendingExtraBmIds.forEach(function (id, idx) {
const bm = bookmarkList.find(function (b) { return b.id === id; });
const tag = document.createElement("span");
tag.className = "sch-extra-bm-tag";
tag.textContent = bm ? bm.name : id;
const rm = document.createElement("span");
rm.className = "sch-extra-bm-rm";
rm.textContent = "×";
rm.title = "Remove";
rm.addEventListener("click", function () {
pendingExtraBmIds.splice(idx, 1);
renderExtraBmList();
});
tag.appendChild(rm);
container.appendChild(tag);
});
}
function wireExtraBmAdd() {
const addBtn = document.getElementById("scheduler-ts-extra-bm-add");
if (!addBtn || addBtn._wired) return;
addBtn._wired = true;
addBtn.addEventListener("click", function () {
const pick = document.getElementById("scheduler-ts-extra-bm-pick");
if (!pick || !pick.value) return;
if (!pendingExtraBmIds.includes(pick.value)) {
pendingExtraBmIds.push(pick.value);
renderExtraBmList();
}
pick.value = "";
}); });
// Restore previous selection if still valid.
if (prev) sel.value = prev;
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -90,6 +90,8 @@ function vchanRender() {
addBtn.title = "Allocate new virtual channel at current frequency"; addBtn.title = "Allocate new virtual channel at current frequency";
addBtn.addEventListener("click", vchanAllocate); addBtn.addEventListener("click", vchanAllocate);
picker.appendChild(addBtn); picker.appendChild(addBtn);
vchanSyncAccentUI();
} }
async function vchanAllocate() { async function vchanAllocate() {
@@ -170,7 +172,7 @@ function vchanApplyCapabilities(caps) {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Freq / mode interception // Freq / mode interception + UI accent
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Returns true when the active channel is a non-primary (virtual) channel. // Returns true when the active channel is a non-primary (virtual) channel.
@@ -179,6 +181,40 @@ function vchanIsOnVirtual() {
return vchanActiveId !== vchanChannels[0].id; 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) { async function vchanSetChannelFreq(freqHz) {
if (!vchanRigId || !vchanActiveId) return; if (!vchanRigId || !vchanActiveId) return;
try { try {
@@ -235,3 +271,17 @@ window.vchanInterceptMode = async function(mode) {
if (typeof _orig === "function") return _orig(freqHz); 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();
};
})();
@@ -12,6 +12,7 @@
--accent-green: #c24b1a; --accent-green: #c24b1a;
--accent-yellow: #f0ad4e; --accent-yellow: #f0ad4e;
--accent-red: #e55353; --accent-red: #e55353;
--vchan-color: #38bdf8;
--control-height: 2.6rem; --control-height: 2.6rem;
--jog-hi: #243a5b; --jog-hi: #243a5b;
--jog-lo: #14233a; --jog-lo: #14233a;
@@ -388,7 +389,13 @@ input.status-input, select.status-input { width: 100%; padding: 0.45rem 0.5rem;
background: var(--btn-bg); background: var(--btn-bg);
color: var(--text); color: var(--text);
font-weight: 600; font-weight: 600;
border-color: var(--btn-border, var(--border-light)); border-color: var(--vchan-color);
box-shadow: inset 3px 0 0 var(--vchan-color);
}
/* Applied to #freq and #spectrum-bw-input when on a virtual channel */
.vchan-ch-active {
border-color: var(--vchan-color) !important;
box-shadow: 0 0 0 1px var(--vchan-color);
} }
.vchan-del { .vchan-del {
opacity: 0.5; opacity: 0.5;
@@ -3457,6 +3464,30 @@ button:focus-visible, input:focus-visible, select:focus-visible {
font-size: 0.9rem; font-size: 0.9rem;
color: var(--text-muted); color: var(--text-muted);
} }
.sch-extra-bm-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
min-height: 1.6rem;
}
.sch-extra-bm-tag {
display: inline-flex;
align-items: center;
gap: 3px;
background: var(--btn-bg);
border: 1px solid var(--border-light);
border-radius: 4px;
padding: 0.1rem 0.4rem;
font-size: 0.8rem;
color: var(--text-muted);
}
.sch-extra-bm-rm {
cursor: pointer;
opacity: 0.6;
font-size: 1rem;
line-height: 1;
}
.sch-extra-bm-rm:hover { opacity: 1; }
@media (max-width: 600px) { @media (max-width: 600px) {
.sch-row { .sch-row {
flex-direction: column; flex-direction: column;
@@ -65,6 +65,8 @@ pub struct ScheduleEntry {
/// End of window as minutes-since-midnight UTC. May be < start_min /// End of window as minutes-since-midnight UTC. May be < start_min
/// to represent a window that spans midnight. /// to represent a window that spans midnight.
pub end_min: u32, pub end_min: u32,
/// Primary bookmark (channel 0). Must not be empty for single-channel
/// entries; may be empty when `bookmark_ids` provides all channels.
pub bookmark_id: String, pub bookmark_id: String,
#[serde(default)] #[serde(default)]
pub label: Option<String>, pub label: Option<String>,
@@ -73,6 +75,15 @@ pub struct ScheduleEntry {
/// sized slice of the interleave cycle. /// sized slice of the interleave cycle.
#[serde(default)] #[serde(default)]
pub interleave_min: Option<u32>, pub interleave_min: Option<u32>,
/// SDR center frequency in Hz. When set the scheduler issues
/// `SetCenterFreq` before applying `SetFreq`/`SetMode`.
#[serde(default)]
pub center_hz: Option<u64>,
/// Additional bookmarks to monitor as virtual channels alongside the
/// primary. The background task records these in the status so the
/// frontend can allocate the corresponding virtual channels on connect.
#[serde(default)]
pub bookmark_ids: Vec<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
@@ -348,6 +359,12 @@ pub struct SchedulerStatus {
pub last_bookmark_id: Option<String>, pub last_bookmark_id: Option<String>,
pub last_bookmark_name: Option<String>, pub last_bookmark_name: Option<String>,
pub last_applied_utc: Option<i64>, pub last_applied_utc: Option<i64>,
/// Center frequency applied with the last slot (SDR only).
#[serde(skip_serializing_if = "Option::is_none")]
pub last_center_hz: Option<u64>,
/// Additional bookmark IDs active alongside the primary (virtual channels).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub last_bookmark_ids: Vec<String>,
} }
/// Shared mutable state for scheduler status (one entry per rig). /// Shared mutable state for scheduler status (one entry per rig).
@@ -411,11 +428,34 @@ pub fn spawn_scheduler_task(
continue; continue;
}; };
// Resolve the matching entry to pick up center_hz / bookmark_ids.
let active_entry = config.entries.iter().find(|e| e.bookmark_id == bm_id);
let center_hz = active_entry.and_then(|e| e.center_hz);
let extra_bm_ids: Vec<String> = active_entry
.map(|e| e.bookmark_ids.clone())
.unwrap_or_default();
info!( info!(
"scheduler: rig '{}' → bookmark '{}' ({} Hz {})", "scheduler: rig '{}' → bookmark '{}' ({} Hz {})",
config.rig_id, bm.name, bm.freq_hz, bm.mode config.rig_id, bm.name, bm.freq_hz, bm.mode
); );
// Apply SetCenterFreq first if this is a multi-channel SDR slot.
if let Some(chz) = center_hz {
if let Err(e) = scheduler_send(
&rig_tx,
RigCommand::SetCenterFreq(Freq { hz: chz }),
config.rig_id.clone(),
)
.await
{
warn!(
"scheduler: SetCenterFreq failed for '{}': {:?}",
config.rig_id, e
);
}
}
// Apply SetFreq. // Apply SetFreq.
if let Err(e) = scheduler_send( if let Err(e) = scheduler_send(
&rig_tx, &rig_tx,
@@ -447,7 +487,8 @@ pub fn spawn_scheduler_task(
last_applied.insert(config.rig_id.clone(), bm_id.clone()); last_applied.insert(config.rig_id.clone(), bm_id.clone());
// Update status map. // Update status map (includes center_hz + extra bookmark_ids
// so the JS frontend can set up virtual channels on connect).
let now_ts = std::time::SystemTime::now() let now_ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default() .unwrap_or_default()
@@ -461,6 +502,8 @@ pub fn spawn_scheduler_task(
last_bookmark_id: Some(bm_id), last_bookmark_id: Some(bm_id),
last_bookmark_name: Some(bm.name), last_bookmark_name: Some(bm.name),
last_applied_utc: Some(now_ts), last_applied_utc: Some(now_ts),
last_center_hz: center_hz,
last_bookmark_ids: extra_bm_ids,
}, },
); );
} }