[feat](trx-frontend-http): add scheduler control handoff
Co-authored-by: OpenAI Codex <codex@openai.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -261,7 +261,13 @@
|
||||
</div>
|
||||
<div class="full-row label-below-row" id="vchan-row" style="display:none;">
|
||||
<div class="label"><span>Channels</span></div>
|
||||
<div class="vchan-picker" id="vchan-picker"></div>
|
||||
<div class="vchan-row-controls">
|
||||
<div class="vchan-picker" id="vchan-picker"></div>
|
||||
<div class="scheduler-release-wrap">
|
||||
<button id="scheduler-release-btn" type="button">Release to Scheduler</button>
|
||||
<div id="scheduler-release-status" class="scheduler-release-status">Scheduler is controlling the rig.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="full-row label-below-row">
|
||||
<div class="label"><span>Signal</span></div>
|
||||
|
||||
@@ -308,6 +308,7 @@
|
||||
case "no_supported_decoders": return "Unsupported";
|
||||
case "disabled": return "Disabled";
|
||||
case "handled_by_scheduler": return "Scheduler";
|
||||
case "scheduler_has_control": return "Scheduler";
|
||||
case "handled_by_virtual_channel": return "VChan";
|
||||
default: return "Inactive";
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ let vchanSessionId = null;
|
||||
let vchanRigId = null;
|
||||
let vchanChannels = [];
|
||||
let vchanActiveId = null;
|
||||
let schedulerReleaseState = null;
|
||||
let schedulerReleasePollTimer = null;
|
||||
|
||||
function vchanFmtFreq(hz) {
|
||||
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
||||
@@ -20,11 +22,101 @@ function vchanFmtFreq(hz) {
|
||||
return hz + "\u202fHz";
|
||||
}
|
||||
|
||||
function schedulerReleaseSummaryText(state) {
|
||||
if (!state) return "Scheduler is controlling the rig.";
|
||||
const connected = Number(state.connected_sessions) || 0;
|
||||
const released = Number(state.released_sessions) || 0;
|
||||
if (connected === 0) return "Scheduler can control the rig.";
|
||||
if (state.all_released) {
|
||||
return connected === 1
|
||||
? "Scheduler is controlling the rig."
|
||||
: `Scheduler is controlling the rig for all ${connected} users.`;
|
||||
}
|
||||
if (!state.current_session_released) {
|
||||
const othersReleased = Math.max(released, 0);
|
||||
return othersReleased > 0
|
||||
? `You are holding control. ${othersReleased} other user${othersReleased === 1 ? "" : "s"} already released it.`
|
||||
: "You are holding control. Release it to return control to the scheduler.";
|
||||
}
|
||||
const blocking = Math.max(connected - released, 0);
|
||||
return blocking > 0
|
||||
? `Scheduler is waiting for ${blocking} user${blocking === 1 ? "" : "s"} to stop manual tuning.`
|
||||
: "Scheduler can control the rig.";
|
||||
}
|
||||
|
||||
function vchanRenderSchedulerRelease() {
|
||||
const btn = document.getElementById("scheduler-release-btn");
|
||||
const status = document.getElementById("scheduler-release-status");
|
||||
if (!btn || !status) return;
|
||||
const currentReleased = !!(schedulerReleaseState && schedulerReleaseState.current_session_released);
|
||||
btn.disabled = !vchanSessionId || currentReleased;
|
||||
btn.classList.toggle("active", !currentReleased);
|
||||
btn.textContent = "Release to Scheduler";
|
||||
status.textContent = schedulerReleaseSummaryText(schedulerReleaseState);
|
||||
}
|
||||
|
||||
async function vchanPollSchedulerRelease() {
|
||||
if (!vchanSessionId) {
|
||||
schedulerReleaseState = null;
|
||||
vchanRenderSchedulerRelease();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(`/scheduler-control?session_id=${encodeURIComponent(vchanSessionId)}`);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
schedulerReleaseState = await resp.json();
|
||||
vchanRenderSchedulerRelease();
|
||||
} catch (e) {
|
||||
console.error("scheduler release status failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
function vchanStartSchedulerReleasePolling() {
|
||||
if (schedulerReleasePollTimer) {
|
||||
clearInterval(schedulerReleasePollTimer);
|
||||
}
|
||||
schedulerReleasePollTimer = setInterval(vchanPollSchedulerRelease, 10000);
|
||||
}
|
||||
|
||||
async function vchanToggleSchedulerRelease() {
|
||||
if (!vchanSessionId) return;
|
||||
try {
|
||||
const resp = await fetch("/scheduler-control", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_id: vchanSessionId, released: true }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
schedulerReleaseState = await resp.json();
|
||||
vchanRenderSchedulerRelease();
|
||||
} catch (e) {
|
||||
console.error("scheduler release toggle failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function vchanTakeSchedulerControl() {
|
||||
if (!vchanSessionId) return;
|
||||
if (schedulerReleaseState && !schedulerReleaseState.current_session_released) return;
|
||||
try {
|
||||
const resp = await fetch("/scheduler-control", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_id: vchanSessionId, released: false }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
schedulerReleaseState = await resp.json();
|
||||
vchanRenderSchedulerRelease();
|
||||
} catch (e) {
|
||||
console.error("scheduler control takeover failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Called by app.js when the SSE `session` event arrives.
|
||||
function vchanHandleSession(data) {
|
||||
try {
|
||||
const d = JSON.parse(data);
|
||||
vchanSessionId = d.session_id || null;
|
||||
vchanPollSchedulerRelease();
|
||||
} catch (e) {
|
||||
console.warn("vchan: bad session event", e);
|
||||
}
|
||||
@@ -43,6 +135,7 @@ function vchanHandleChannels(data) {
|
||||
vchanReconnectAudio();
|
||||
}
|
||||
vchanRender();
|
||||
vchanRenderSchedulerRelease();
|
||||
if (typeof renderRdsOverlays === "function") renderRdsOverlays();
|
||||
} catch (e) {
|
||||
console.warn("vchan: bad channels event", e);
|
||||
@@ -94,6 +187,7 @@ function vchanRender() {
|
||||
picker.appendChild(addBtn);
|
||||
|
||||
vchanSyncAccentUI();
|
||||
vchanRenderSchedulerRelease();
|
||||
}
|
||||
|
||||
async function vchanAllocate() {
|
||||
@@ -196,6 +290,7 @@ function vchanApplyCapabilities(caps) {
|
||||
const row = document.getElementById("vchan-row");
|
||||
if (!row) return;
|
||||
row.style.display = (caps && caps.filter_controls) ? "" : "none";
|
||||
vchanRenderSchedulerRelease();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -391,10 +486,22 @@ window.vchanInterceptBandwidth = async function(bwHz) {
|
||||
await vchanSetChannelFreq(freqHz);
|
||||
return;
|
||||
}
|
||||
await vchanTakeSchedulerControl();
|
||||
if (typeof _orig === "function") return _orig(freqHz);
|
||||
};
|
||||
})();
|
||||
|
||||
(function initSchedulerReleaseControl() {
|
||||
const btn = document.getElementById("scheduler-release-btn");
|
||||
if (btn) {
|
||||
btn.addEventListener("click", () => {
|
||||
vchanToggleSchedulerRelease();
|
||||
});
|
||||
}
|
||||
vchanStartSchedulerReleasePolling();
|
||||
vchanRenderSchedulerRelease();
|
||||
})();
|
||||
|
||||
// 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).
|
||||
|
||||
@@ -371,6 +371,28 @@ input.status-input, select.status-input { width: 100%; padding: 0.45rem 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.vchan-row-controls {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
.scheduler-release-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.35rem;
|
||||
min-width: 14rem;
|
||||
}
|
||||
.scheduler-release-wrap button.active {
|
||||
border-color: var(--accent-yellow);
|
||||
color: var(--accent-yellow);
|
||||
}
|
||||
.scheduler-release-status {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
text-align: right;
|
||||
}
|
||||
.vchan-picker button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -3582,6 +3604,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
||||
.bgd-status-state[data-state="out_of_span"],
|
||||
.bgd-status-state[data-state="waiting_for_spectrum"],
|
||||
.bgd-status-state[data-state="waiting_for_user"],
|
||||
.bgd-status-state[data-state="scheduler_has_control"],
|
||||
.bgd-status-state[data-state="inactive"],
|
||||
.bgd-status-state[data-state="handled_by_scheduler"],
|
||||
.bgd-status-state[data-state="handled_by_virtual_channel"] {
|
||||
@@ -3592,6 +3615,17 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
||||
color: var(--accent-red);
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.vchan-row-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.scheduler-release-wrap {
|
||||
align-items: stretch;
|
||||
min-width: 0;
|
||||
}
|
||||
.scheduler-release-status {
|
||||
text-align: left;
|
||||
}
|
||||
.sch-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user