[feat](trx-frontend-http): add Background Decoding Scheduler

Implements a scheduler that retunes the rig automatically when no SSE
clients are connected.  Two modes are supported:

- Grayline: tunes to per-period bookmarks (dawn/day/dusk/night) based on
  an inline NOAA solar algorithm given station lat/lon.
- Time Span: tunes to bookmarks within user-defined UTC windows; midnight-
  spanning intervals supported.

Backend:
- SchedulerStore (PickleDB, sch:{rig_id} keys) in scheduler.rs
- spawn_scheduler_task polls every 30 s, checks context.sse_clients == 0,
  sends SetFreq + SetMode via RigRequest with rig_id_override
- HTTP API: GET/PUT/DELETE /scheduler/{rig_id}, GET …/status
- sse_clients Arc<AtomicUsize> added to FrontendRuntimeContext and shared
  with the SSE counter in build_server (single source of truth)
- /scheduler/ added to Read auth routes (write requires Control)

Frontend:
- Scheduler tab (clock icon, 6th position) with Grayline/TimeSpan UI
- scheduler.js plugin: loads config + bookmarks, live status polling
  every 15 s, write controls hidden for Rx-role users
- CSS .sch-* component styles added to style.css
- SCHEDULER.md design document at repo root

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-10 23:20:42 +01:00
parent 46c0f8d0bb
commit 6874055b1c
11 changed files with 1393 additions and 5 deletions
@@ -0,0 +1,485 @@
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
//
// SPDX-License-Identifier: BSD-2-Clause
// Background Decoding Scheduler UI
(function () {
"use strict";
// -------------------------------------------------------------------------
// State
// -------------------------------------------------------------------------
let schedulerRole = null; // "control" | "rx" | null
let currentRigId = null;
let currentConfig = null;
let bookmarkList = []; // [{id, name, freq_hz, mode}, ...]
let statusInterval = null;
// -------------------------------------------------------------------------
// Init
// -------------------------------------------------------------------------
function initScheduler(rigId, role) {
schedulerRole = role;
currentRigId = rigId || null;
renderSchedulerRigSelect();
loadScheduler();
startStatusPolling();
}
function destroyScheduler() {
if (statusInterval) {
clearInterval(statusInterval);
statusInterval = null;
}
}
// -------------------------------------------------------------------------
// Rig selector (mirrors current rig from app state)
// -------------------------------------------------------------------------
function renderSchedulerRigSelect() {
const sel = document.getElementById("scheduler-rig-select");
if (!sel) return;
// Populate from global rig list exposed by app.js
const rigs = (typeof getAvailableRigIds === "function") ? getAvailableRigIds() : [];
sel.innerHTML = "";
if (!rigs.length) {
const opt = document.createElement("option");
opt.textContent = "No rigs available";
sel.appendChild(opt);
return;
}
rigs.forEach(function (id) {
const opt = document.createElement("option");
opt.value = id;
opt.textContent = id;
if (id === currentRigId) opt.selected = true;
sel.appendChild(opt);
});
}
// -------------------------------------------------------------------------
// API helpers
// -------------------------------------------------------------------------
function apiGetScheduler(rigId) {
return fetch("/scheduler/" + encodeURIComponent(rigId)).then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
});
}
function apiPutScheduler(rigId, config) {
return fetch("/scheduler/" + encodeURIComponent(rigId), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(config),
}).then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
});
}
function apiDeleteScheduler(rigId) {
return fetch("/scheduler/" + encodeURIComponent(rigId), {
method: "DELETE",
}).then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
});
}
function apiGetStatus(rigId) {
return fetch("/scheduler/" + encodeURIComponent(rigId) + "/status").then(
function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
}
);
}
function apiGetBookmarks() {
return fetch("/bookmarks").then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
});
}
// -------------------------------------------------------------------------
// Load config + bookmarks
// -------------------------------------------------------------------------
function loadScheduler() {
const rig = currentRigId;
if (!rig) return;
Promise.all([apiGetScheduler(rig), apiGetBookmarks()])
.then(function ([config, bms]) {
currentConfig = config;
bookmarkList = Array.isArray(bms) ? bms : [];
renderScheduler();
})
.catch(function (e) {
console.error("scheduler load failed", e);
});
}
// -------------------------------------------------------------------------
// Status polling
// -------------------------------------------------------------------------
function startStatusPolling() {
if (statusInterval) clearInterval(statusInterval);
statusInterval = setInterval(pollStatus, 15000);
pollStatus();
}
function pollStatus() {
const rig = currentRigId;
if (!rig) return;
apiGetStatus(rig)
.then(function (st) {
renderStatus(st);
})
.catch(function () {});
}
function renderStatus(st) {
const el = document.getElementById("scheduler-status-card");
if (!el) return;
if (!st || (!st.active && !st.last_bookmark_id)) {
el.textContent = "No activity yet.";
return;
}
const name = st.last_bookmark_name || st.last_bookmark_id || "—";
let ts = "";
if (st.last_applied_utc) {
const d = new Date(st.last_applied_utc * 1000);
ts = " at " + d.toUTCString();
}
el.textContent = "Last applied: " + name + ts;
}
// -------------------------------------------------------------------------
// Render the full scheduler panel
// -------------------------------------------------------------------------
function renderScheduler() {
const panel = document.getElementById("scheduler-panel");
if (!panel) return;
const mode = (currentConfig && currentConfig.mode) || "disabled";
const isControl = schedulerRole === "control";
// Mode selector
setSelected("scheduler-mode-select", mode);
// Show/hide sections
const glSection = document.getElementById("scheduler-grayline-section");
const tsSection = document.getElementById("scheduler-timespan-section");
if (glSection) glSection.style.display = mode === "grayline" ? "" : "none";
if (tsSection) tsSection.style.display = mode === "time_span" ? "" : "none";
// Grayline inputs
if (mode === "grayline" && currentConfig && currentConfig.grayline) {
const gl = currentConfig.grayline;
setInputValue("scheduler-gl-lat", gl.lat != null ? gl.lat : "");
setInputValue("scheduler-gl-lon", gl.lon != null ? gl.lon : "");
setInputValue("scheduler-gl-window", gl.transition_window_min != null ? gl.transition_window_min : 20);
renderBookmarkSelect("scheduler-gl-dawn", gl.dawn_bookmark_id);
renderBookmarkSelect("scheduler-gl-day", gl.day_bookmark_id);
renderBookmarkSelect("scheduler-gl-dusk", gl.dusk_bookmark_id);
renderBookmarkSelect("scheduler-gl-night", gl.night_bookmark_id);
} else {
renderBookmarkSelect("scheduler-gl-dawn", null);
renderBookmarkSelect("scheduler-gl-day", null);
renderBookmarkSelect("scheduler-gl-dusk", null);
renderBookmarkSelect("scheduler-gl-night", null);
}
// TimeSpan entries
renderTimespanEntries();
// Enable/disable controls
const formEls = panel.querySelectorAll("input, select, button.sch-write");
formEls.forEach(function (el) {
el.disabled = !isControl;
});
const saveBtn = document.getElementById("scheduler-save-btn");
if (saveBtn) {
saveBtn.style.display = isControl ? "" : "none";
}
const resetBtn = document.getElementById("scheduler-reset-btn");
if (resetBtn) {
resetBtn.style.display = isControl ? "" : "none";
}
}
function setSelected(id, value) {
const el = document.getElementById(id);
if (el) el.value = value;
}
function setInputValue(id, value) {
const el = document.getElementById(id);
if (el) el.value = value;
}
function renderBookmarkSelect(id, selectedId) {
const sel = document.getElementById(id);
if (!sel) return;
sel.innerHTML = '<option value="">— none —</option>';
bookmarkList.forEach(function (bm) {
const opt = document.createElement("option");
opt.value = bm.id;
opt.textContent = bm.name + " (" + formatFreq(bm.freq_hz) + " " + bm.mode + ")";
if (bm.id === selectedId) opt.selected = true;
sel.appendChild(opt);
});
}
function formatFreq(hz) {
if (hz >= 1e6) return (hz / 1e6).toFixed(3) + " MHz";
if (hz >= 1e3) return (hz / 1e3).toFixed(1) + " kHz";
return hz + " Hz";
}
// -------------------------------------------------------------------------
// TimeSpan entries table
// -------------------------------------------------------------------------
function renderTimespanEntries() {
const tbody = document.getElementById("scheduler-ts-tbody");
if (!tbody) return;
tbody.innerHTML = "";
const entries =
currentConfig && Array.isArray(currentConfig.entries)
? currentConfig.entries
: [];
entries.forEach(function (entry, idx) {
const tr = document.createElement("tr");
tr.innerHTML =
'<td>' + minToHHMM(entry.start_min) + '</td>' +
'<td>' + minToHHMM(entry.end_min) + '</td>' +
'<td>' + bmName(entry.bookmark_id) + '</td>' +
'<td>' + escHtml(entry.label || "") + '</td>' +
'<td><button class="sch-write sch-remove-btn" data-idx="' + idx + '" type="button">Remove</button></td>';
tbody.appendChild(tr);
});
tbody.querySelectorAll(".sch-remove-btn").forEach(function (btn) {
btn.addEventListener("click", function () {
removeEntry(parseInt(btn.dataset.idx, 10));
});
});
}
function bmName(id) {
const bm = bookmarkList.find(function (b) { return b.id === id; });
return bm ? escHtml(bm.name) : escHtml(id);
}
function minToHHMM(min) {
const h = Math.floor(min / 60) % 24;
const m = min % 60;
return String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0");
}
function hhmmToMin(str) {
const parts = str.split(":");
return parseInt(parts[0] || "0", 10) * 60 + parseInt(parts[1] || "0", 10);
}
function escHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function removeEntry(idx) {
if (!currentConfig || !currentConfig.entries) return;
currentConfig.entries.splice(idx, 1);
renderTimespanEntries();
}
// -------------------------------------------------------------------------
// Add entry
// -------------------------------------------------------------------------
function addEntry() {
const startEl = document.getElementById("scheduler-ts-start");
const endEl = document.getElementById("scheduler-ts-end");
const bmEl = document.getElementById("scheduler-ts-bookmark");
const labelEl = document.getElementById("scheduler-ts-label");
if (!startEl || !endEl || !bmEl) return;
const startMin = hhmmToMin(startEl.value);
const endMin = hhmmToMin(endEl.value);
const bmId = bmEl.value;
const label = labelEl ? labelEl.value.trim() : "";
if (!bmId) {
alert("Please select a bookmark.");
return;
}
if (!currentConfig) {
currentConfig = { rig_id: currentRigId, mode: "time_span", entries: [] };
}
if (!currentConfig.entries) currentConfig.entries = [];
const id = "ts_" + Date.now().toString(36);
currentConfig.entries.push({
id,
start_min: startMin,
end_min: endMin,
bookmark_id: bmId,
label: label || null,
});
startEl.value = "";
endEl.value = "";
if (labelEl) labelEl.value = "";
renderTimespanEntries();
}
// -------------------------------------------------------------------------
// Save
// -------------------------------------------------------------------------
function saveScheduler() {
const rig = currentRigId;
if (!rig) return;
const modeEl = document.getElementById("scheduler-mode-select");
const mode = modeEl ? modeEl.value : "disabled";
const config = {
rig_id: rig,
mode,
grayline: null,
entries: [],
};
if (mode === "grayline") {
const lat = parseFloat(document.getElementById("scheduler-gl-lat").value);
const lon = parseFloat(document.getElementById("scheduler-gl-lon").value);
const win = parseInt(document.getElementById("scheduler-gl-window").value, 10);
config.grayline = {
lat: isNaN(lat) ? 0 : lat,
lon: isNaN(lon) ? 0 : lon,
transition_window_min: isNaN(win) ? 20 : win,
dawn_bookmark_id: selectVal("scheduler-gl-dawn") || null,
day_bookmark_id: selectVal("scheduler-gl-day") || null,
dusk_bookmark_id: selectVal("scheduler-gl-dusk") || null,
night_bookmark_id: selectVal("scheduler-gl-night") || null,
};
} else if (mode === "time_span") {
config.entries =
currentConfig && currentConfig.entries ? currentConfig.entries : [];
}
const btn = document.getElementById("scheduler-save-btn");
if (btn) btn.disabled = true;
apiPutScheduler(rig, config)
.then(function (saved) {
currentConfig = saved;
renderScheduler();
showSchedulerToast("Scheduler saved.");
})
.catch(function (e) {
showSchedulerToast("Save failed: " + e.message, true);
})
.finally(function () {
if (btn) btn.disabled = false;
});
}
function selectVal(id) {
const el = document.getElementById(id);
return el ? el.value : "";
}
function resetScheduler() {
const rig = currentRigId;
if (!rig) return;
if (!confirm("Reset scheduler for this rig to Disabled?")) return;
apiDeleteScheduler(rig)
.then(function () {
currentConfig = {
rig_id: rig,
mode: "disabled",
grayline: null,
entries: [],
};
renderScheduler();
showSchedulerToast("Scheduler reset.");
})
.catch(function (e) {
showSchedulerToast("Reset failed: " + e.message, true);
});
}
// -------------------------------------------------------------------------
// Toast helper
// -------------------------------------------------------------------------
function showSchedulerToast(msg, isError) {
const el = document.getElementById("scheduler-toast");
if (!el) return;
el.textContent = msg;
el.style.background = isError ? "var(--color-error, #c00)" : "var(--accent-green)";
el.style.display = "block";
setTimeout(function () {
el.style.display = "none";
}, 3000);
}
// -------------------------------------------------------------------------
// Wire events (called once DOM is ready)
// -------------------------------------------------------------------------
function wireSchedulerEvents() {
const modeEl = document.getElementById("scheduler-mode-select");
if (modeEl) {
modeEl.addEventListener("change", function () {
if (!currentConfig) currentConfig = { rig_id: currentRigId, mode: modeEl.value, entries: [] };
currentConfig.mode = modeEl.value;
renderScheduler();
});
}
const rigSel = document.getElementById("scheduler-rig-select");
if (rigSel) {
rigSel.addEventListener("change", function () {
currentRigId = rigSel.value;
loadScheduler();
pollStatus();
});
}
const saveBtn = document.getElementById("scheduler-save-btn");
if (saveBtn) saveBtn.addEventListener("click", saveScheduler);
const resetBtn = document.getElementById("scheduler-reset-btn");
if (resetBtn) resetBtn.addEventListener("click", resetScheduler);
const addBtn = document.getElementById("scheduler-ts-add-btn");
if (addBtn) addBtn.addEventListener("click", addEntry);
// Populate add-entry bookmark selector
const tsBookmarkEl = document.getElementById("scheduler-ts-bookmark");
if (tsBookmarkEl) {
tsBookmarkEl.innerHTML = '<option value="">— select bookmark —</option>';
bookmarkList.forEach(function (bm) {
const opt = document.createElement("option");
opt.value = bm.id;
opt.textContent = bm.name + " (" + formatFreq(bm.freq_hz) + " " + bm.mode + ")";
tsBookmarkEl.appendChild(opt);
});
}
}
// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------
window.initScheduler = initScheduler;
window.destroyScheduler = destroyScheduler;
window.wireSchedulerEvents = wireSchedulerEvents;
window.reloadSchedulerRigSelect = renderSchedulerRigSelect;
})();