[fix](trx-frontend-http): improve scheduler entry controls
Add previous/next scheduler entry controls for overlapping\ntime-span slots and fix interleave timing calculations so\nthe active slot and countdown align with the overlap window.\n\nVerification: cargo test -p trx-frontend-http scheduler\nVerification: node --check assets/web/plugins/scheduler.js\n\nCo-authored-by: OpenAI Codex <codex@openai.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -266,6 +266,10 @@
|
|||||||
<div class="scheduler-control-row">
|
<div class="scheduler-control-row">
|
||||||
<div class="scheduler-release-wrap">
|
<div class="scheduler-release-wrap">
|
||||||
<button id="scheduler-release-btn" type="button">Release to Scheduler</button>
|
<button id="scheduler-release-btn" type="button">Release to Scheduler</button>
|
||||||
|
<div class="scheduler-step-controls">
|
||||||
|
<button id="scheduler-prev-btn" type="button">Previous Entry</button>
|
||||||
|
<button id="scheduler-next-btn" type="button">Next Entry</button>
|
||||||
|
</div>
|
||||||
<div id="scheduler-release-status" class="scheduler-release-status">Scheduler is controlling the rig.</div>
|
<div id="scheduler-release-status" class="scheduler-release-status">Scheduler is controlling the rig.</div>
|
||||||
<div id="scheduler-cycle-status" class="scheduler-cycle-status">Interleaving: --</div>
|
<div id="scheduler-cycle-status" class="scheduler-cycle-status">Interleaving: --</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
let bookmarkList = []; // [{id, name, freq_hz, mode}, ...]
|
let bookmarkList = []; // [{id, name, freq_hz, mode}, ...]
|
||||||
let statusInterval = null;
|
let statusInterval = null;
|
||||||
let interleaveTicker = null;
|
let interleaveTicker = null;
|
||||||
|
let schedulerStepPending = false;
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Init
|
// Init
|
||||||
@@ -91,6 +92,17 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function apiActivateSchedulerEntry(rigId, entryId) {
|
||||||
|
return fetch("/scheduler/" + encodeURIComponent(rigId) + "/activate", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ entry_id: entryId }),
|
||||||
|
}).then(function (r) {
|
||||||
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function apiGetBookmarks() {
|
function apiGetBookmarks() {
|
||||||
return fetch("/bookmarks").then(function (r) {
|
return fetch("/bookmarks").then(function (r) {
|
||||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||||
@@ -156,15 +168,38 @@
|
|||||||
return nowMin >= start || nowMin < end;
|
return nowMin >= start || nowMin < end;
|
||||||
}
|
}
|
||||||
|
|
||||||
function schedulerInterleaveSummary(config) {
|
function schedulerEntryCurrentWindowStart(entry, nowMin) {
|
||||||
if (!config || config.mode !== "time_span") return "Interleaving: off";
|
const start = Number(entry && entry.start_min);
|
||||||
|
const end = Number(entry && entry.end_min);
|
||||||
|
if (!Number.isFinite(start) || !Number.isFinite(end)) return Number.NEGATIVE_INFINITY;
|
||||||
|
if (start === end) return 0;
|
||||||
|
if (start < end) return start;
|
||||||
|
return nowMin >= start ? start : (start - 1440);
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulerEntryDisplayName(entry) {
|
||||||
|
if (!entry) return "Scheduler entry";
|
||||||
|
if (entry.label) return String(entry.label);
|
||||||
|
const bookmarkName = bmName(entry.bookmark_id);
|
||||||
|
return bookmarkName || "Scheduler entry";
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulerInterleaveState(config) {
|
||||||
|
if (!config || config.mode !== "time_span") {
|
||||||
|
return { activeEntries: [], currentIndex: -1, remainingSec: 0, cycleMin: 0 };
|
||||||
|
}
|
||||||
const entries = Array.isArray(config.entries) ? config.entries : [];
|
const entries = Array.isArray(config.entries) ? config.entries : [];
|
||||||
const minuteInfo = schedulerUtcMinuteInfo();
|
const minuteInfo = schedulerUtcMinuteInfo();
|
||||||
const nowMin = minuteInfo.minuteOfDay;
|
const nowMin = minuteInfo.minuteOfDay;
|
||||||
const active = entries.filter(function (entry) {
|
const active = entries.filter(function (entry) {
|
||||||
return schedulerEntryIsActive(entry, nowMin);
|
return schedulerEntryIsActive(entry, nowMin);
|
||||||
});
|
});
|
||||||
if (active.length <= 1) return "Interleaving: off";
|
if (active.length === 0) {
|
||||||
|
return { activeEntries: [], currentIndex: -1, remainingSec: 0, cycleMin: 0 };
|
||||||
|
}
|
||||||
|
if (active.length === 1) {
|
||||||
|
return { activeEntries: active, currentIndex: 0, remainingSec: 0, cycleMin: 0 };
|
||||||
|
}
|
||||||
const defaultInterleave = Number(config.interleave_min);
|
const defaultInterleave = Number(config.interleave_min);
|
||||||
const durations = active.map(function (entry) {
|
const durations = active.map(function (entry) {
|
||||||
const own = Number(entry && entry.interleave_min);
|
const own = Number(entry && entry.interleave_min);
|
||||||
@@ -173,27 +208,76 @@
|
|||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
const cycleMin = durations.reduce(function (sum, value) { return sum + value; }, 0);
|
const cycleMin = durations.reduce(function (sum, value) { return sum + value; }, 0);
|
||||||
if (!(cycleMin > 0)) return "Interleaving: off";
|
if (!(cycleMin > 0)) {
|
||||||
const posMin = minuteInfo.minuteOfDay % cycleMin;
|
return { activeEntries: active, currentIndex: 0, remainingSec: 0, cycleMin: 0 };
|
||||||
|
}
|
||||||
|
const overlapStart = active.reduce(function (maxStart, entry) {
|
||||||
|
return Math.max(maxStart, schedulerEntryCurrentWindowStart(entry, nowMin));
|
||||||
|
}, Number.NEGATIVE_INFINITY);
|
||||||
|
if (!Number.isFinite(overlapStart)) {
|
||||||
|
return { activeEntries: active, currentIndex: 0, remainingSec: 0, cycleMin: 0 };
|
||||||
|
}
|
||||||
|
const nowMinPrecise = minuteInfo.minuteOfDay + (minuteInfo.secondOfMinute / 60);
|
||||||
|
const posMin = ((nowMinPrecise - overlapStart) % cycleMin + cycleMin) % cycleMin;
|
||||||
let cumulative = 0;
|
let cumulative = 0;
|
||||||
|
let slotStart = 0;
|
||||||
|
let currentIndex = 0;
|
||||||
let currentDuration = 0;
|
let currentDuration = 0;
|
||||||
for (let i = 0; i < durations.length; i += 1) {
|
for (let i = 0; i < durations.length; i += 1) {
|
||||||
cumulative += durations[i];
|
const nextCumulative = cumulative + durations[i];
|
||||||
if (posMin < cumulative) {
|
if (posMin < nextCumulative) {
|
||||||
|
slotStart = cumulative;
|
||||||
|
cumulative = nextCumulative;
|
||||||
|
currentIndex = i;
|
||||||
currentDuration = durations[i];
|
currentDuration = durations[i];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
cumulative = nextCumulative;
|
||||||
}
|
}
|
||||||
if (!(currentDuration > 0)) return "Interleaving: off";
|
if (!(currentDuration > 0)) {
|
||||||
const remainingMin = cumulative - posMin;
|
return { activeEntries: active, currentIndex: 0, remainingSec: 0, cycleMin: 0 };
|
||||||
const remainingSec = Math.max(1, (remainingMin * 60) - minuteInfo.secondOfMinute);
|
}
|
||||||
return "Interleaving: next switch in " + remainingSec + "s (" + cycleMin + " min cycle)";
|
const elapsedSlotSec = Math.max(0, Math.floor((posMin - slotStart) * 60));
|
||||||
|
const remainingSec = Math.max(1, (currentDuration * 60) - elapsedSlotSec);
|
||||||
|
return {
|
||||||
|
activeEntries: active,
|
||||||
|
currentIndex: currentIndex,
|
||||||
|
remainingSec: remainingSec,
|
||||||
|
cycleMin: cycleMin,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulerInterleaveSummary(config) {
|
||||||
|
const state = schedulerInterleaveState(config);
|
||||||
|
if (state.activeEntries.length <= 1 || !(state.cycleMin > 0)) return "Interleaving: off";
|
||||||
|
const activeName = schedulerEntryDisplayName(state.activeEntries[state.currentIndex]);
|
||||||
|
return "Interleaving: " + activeName + " · next switch in " + state.remainingSec + "s (" + state.cycleMin + " min cycle)";
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSchedulerInterleaveStatus() {
|
function renderSchedulerInterleaveStatus() {
|
||||||
const el = document.getElementById("scheduler-cycle-status");
|
const el = document.getElementById("scheduler-cycle-status");
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.textContent = schedulerInterleaveSummary(currentConfig);
|
el.textContent = schedulerInterleaveSummary(currentConfig);
|
||||||
|
renderSchedulerStepControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSchedulerStepControls() {
|
||||||
|
const prevBtn = document.getElementById("scheduler-prev-btn");
|
||||||
|
const nextBtn = document.getElementById("scheduler-next-btn");
|
||||||
|
if (!prevBtn || !nextBtn) return;
|
||||||
|
const state = schedulerInterleaveState(currentConfig);
|
||||||
|
const enabled =
|
||||||
|
schedulerRole === "control" &&
|
||||||
|
!!currentRigId &&
|
||||||
|
!schedulerStepPending &&
|
||||||
|
state.activeEntries.length > 1;
|
||||||
|
prevBtn.disabled = !enabled;
|
||||||
|
nextBtn.disabled = !enabled;
|
||||||
|
const hint = enabled
|
||||||
|
? "Select a different active scheduler entry"
|
||||||
|
: "Available only when multiple scheduler entries are active";
|
||||||
|
prevBtn.title = hint;
|
||||||
|
nextBtn.title = hint;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pollStatus() {
|
function pollStatus() {
|
||||||
@@ -350,7 +434,7 @@
|
|||||||
'<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>' + centerCell + '</td>' +
|
||||||
'<td>' + bmName(entry.bookmark_id) + '</td>' +
|
'<td>' + escHtml(bmName(entry.bookmark_id)) + '</td>' +
|
||||||
'<td>' + extraCell + '</td>' +
|
'<td>' + extraCell + '</td>' +
|
||||||
'<td>' + escHtml(entry.label || "") + '</td>' +
|
'<td>' + escHtml(entry.label || "") + '</td>' +
|
||||||
'<td>' + il + '</td>' +
|
'<td>' + il + '</td>' +
|
||||||
@@ -366,7 +450,7 @@
|
|||||||
|
|
||||||
function bmName(id) {
|
function bmName(id) {
|
||||||
const bm = bookmarkList.find(function (b) { return b.id === id; });
|
const bm = bookmarkList.find(function (b) { return b.id === id; });
|
||||||
return bm ? escHtml(bm.name) : escHtml(id);
|
return bm ? bm.name : String(id || "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function minToHHMM(min) {
|
function minToHHMM(min) {
|
||||||
@@ -388,6 +472,38 @@
|
|||||||
.replace(/"/g, """);
|
.replace(/"/g, """);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function schedulerSelectRelativeEntry(delta) {
|
||||||
|
const state = schedulerInterleaveState(currentConfig);
|
||||||
|
if (!currentRigId || schedulerStepPending || state.activeEntries.length <= 1) return;
|
||||||
|
const count = state.activeEntries.length;
|
||||||
|
const currentIndex = state.currentIndex >= 0 ? state.currentIndex : 0;
|
||||||
|
const targetIndex = (currentIndex + delta + count) % count;
|
||||||
|
const target = state.activeEntries[targetIndex];
|
||||||
|
if (!target || !target.id) return;
|
||||||
|
|
||||||
|
schedulerStepPending = true;
|
||||||
|
renderSchedulerStepControls();
|
||||||
|
|
||||||
|
Promise.resolve(typeof vchanTakeSchedulerControl === "function" ? vchanTakeSchedulerControl() : null)
|
||||||
|
.then(function () {
|
||||||
|
return apiActivateSchedulerEntry(currentRigId, target.id);
|
||||||
|
})
|
||||||
|
.then(function (status) {
|
||||||
|
renderStatus(status);
|
||||||
|
renderSchedulerInterleaveStatus();
|
||||||
|
showSchedulerToast("Selected " + schedulerEntryDisplayName(target) + ".");
|
||||||
|
pollStatus();
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
console.error("scheduler entry selection failed", e);
|
||||||
|
showSchedulerToast("Scheduler entry selection failed: " + e.message, true);
|
||||||
|
})
|
||||||
|
.finally(function () {
|
||||||
|
schedulerStepPending = false;
|
||||||
|
renderSchedulerStepControls();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function removeEntry(idx) {
|
function removeEntry(idx) {
|
||||||
if (!currentConfig || !currentConfig.entries) return;
|
if (!currentConfig || !currentConfig.entries) return;
|
||||||
currentConfig.entries.splice(idx, 1);
|
currentConfig.entries.splice(idx, 1);
|
||||||
@@ -566,6 +682,16 @@
|
|||||||
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);
|
||||||
|
|
||||||
|
const prevBtn = document.getElementById("scheduler-prev-btn");
|
||||||
|
if (prevBtn) prevBtn.addEventListener("click", function () {
|
||||||
|
schedulerSelectRelativeEntry(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextBtn = document.getElementById("scheduler-next-btn");
|
||||||
|
if (nextBtn) nextBtn.addEventListener("click", function () {
|
||||||
|
schedulerSelectRelativeEntry(1);
|
||||||
|
});
|
||||||
|
|
||||||
wireExtraBmAdd();
|
wireExtraBmAdd();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -396,6 +396,14 @@ input.status-input, select.status-input { width: 100%; padding: 0.45rem 0.5rem;
|
|||||||
border-color: var(--accent-yellow);
|
border-color: var(--accent-yellow);
|
||||||
color: var(--accent-yellow);
|
color: var(--accent-yellow);
|
||||||
}
|
}
|
||||||
|
.scheduler-step-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.scheduler-step-controls button {
|
||||||
|
min-width: 7.6rem;
|
||||||
|
}
|
||||||
.scheduler-release-status {
|
.scheduler-release-status {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
|
|||||||
@@ -1322,6 +1322,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
.service(crate::server::scheduler::put_scheduler)
|
.service(crate::server::scheduler::put_scheduler)
|
||||||
.service(crate::server::scheduler::delete_scheduler)
|
.service(crate::server::scheduler::delete_scheduler)
|
||||||
.service(crate::server::scheduler::get_scheduler_status)
|
.service(crate::server::scheduler::get_scheduler_status)
|
||||||
|
.service(crate::server::scheduler::put_scheduler_activate_entry)
|
||||||
.service(crate::server::scheduler::get_scheduler_control)
|
.service(crate::server::scheduler::get_scheduler_control)
|
||||||
.service(crate::server::scheduler::put_scheduler_control)
|
.service(crate::server::scheduler::put_scheduler_control)
|
||||||
.service(crate::server::background_decode::get_background_decode)
|
.service(crate::server::background_decode::get_background_decode)
|
||||||
|
|||||||
@@ -302,39 +302,79 @@ fn entry_is_active(entry: &ScheduleEntry, now_min: f64) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn entry_current_window_start(entry: &ScheduleEntry, now_min: f64) -> f64 {
|
||||||
|
let start = entry.start_min as f64;
|
||||||
|
let end = entry.end_min as f64;
|
||||||
|
if start == end {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
if start < end {
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
if now_min >= start {
|
||||||
|
start
|
||||||
|
} else {
|
||||||
|
start - 1440.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn timespan_active_entries(entries: &[ScheduleEntry], now_min: f64) -> Vec<&ScheduleEntry> {
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| entry_is_active(entry, now_min))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn timespan_cycle_slot(
|
||||||
|
active: &[&ScheduleEntry],
|
||||||
|
now_min: f64,
|
||||||
|
default_interleave: Option<u32>,
|
||||||
|
) -> Option<usize> {
|
||||||
|
if active.len() <= 1 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let durations: Vec<u32> = active
|
||||||
|
.iter()
|
||||||
|
.map(|entry| entry.interleave_min.or(default_interleave).unwrap_or(0))
|
||||||
|
.collect();
|
||||||
|
let cycle: u32 = durations.iter().sum();
|
||||||
|
if cycle == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let overlap_start = active
|
||||||
|
.iter()
|
||||||
|
.map(|entry| entry_current_window_start(entry, now_min))
|
||||||
|
.fold(f64::NEG_INFINITY, f64::max);
|
||||||
|
if !overlap_start.is_finite() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let elapsed = (now_min - overlap_start).rem_euclid(cycle as f64);
|
||||||
|
let mut cumulative = 0.0;
|
||||||
|
for (idx, duration) in durations.iter().enumerate() {
|
||||||
|
cumulative += *duration as f64;
|
||||||
|
if elapsed < cumulative {
|
||||||
|
return Some(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
durations.len().checked_sub(1)
|
||||||
|
}
|
||||||
|
|
||||||
fn timespan_active_entry(
|
fn timespan_active_entry(
|
||||||
entries: &[ScheduleEntry],
|
entries: &[ScheduleEntry],
|
||||||
now_min: f64,
|
now_min: f64,
|
||||||
default_interleave: Option<u32>,
|
default_interleave: Option<u32>,
|
||||||
) -> Option<&ScheduleEntry> {
|
) -> Option<&ScheduleEntry> {
|
||||||
let active: Vec<&ScheduleEntry> = entries
|
let active = timespan_active_entries(entries, now_min);
|
||||||
.iter()
|
|
||||||
.filter(|e| entry_is_active(e, now_min))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if active.is_empty() {
|
if active.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// With interleaving and more than one active entry, use a weighted cycle.
|
if let Some(idx) = timespan_cycle_slot(&active, now_min, default_interleave) {
|
||||||
// Each entry's effective duration is its own interleave_min, falling back
|
return Some(active[idx]);
|
||||||
// to the config-level default. The cycle length is the sum of all durations.
|
|
||||||
if active.len() > 1 {
|
|
||||||
let durations: Vec<u32> = active
|
|
||||||
.iter()
|
|
||||||
.map(|e| e.interleave_min.or(default_interleave).unwrap_or(0))
|
|
||||||
.collect();
|
|
||||||
let cycle: u32 = durations.iter().sum();
|
|
||||||
if cycle > 0 {
|
|
||||||
let pos = (now_min as u64) % (cycle as u64);
|
|
||||||
let mut cum = 0u64;
|
|
||||||
for (entry, &dur) in active.iter().zip(durations.iter()) {
|
|
||||||
cum += dur as u64;
|
|
||||||
if pos < cum {
|
|
||||||
return Some(*entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: first matching entry wins.
|
// Default: first matching entry wins.
|
||||||
@@ -346,8 +386,8 @@ fn utc_minutes_now() -> f64 {
|
|||||||
let secs = std::time::SystemTime::now()
|
let secs = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.as_secs();
|
.as_secs_f64();
|
||||||
((secs % 86400) as f64) / 60.0
|
(secs % 86400.0) / 60.0
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -369,6 +409,73 @@ pub struct SchedulerStatus {
|
|||||||
pub last_bookmark_ids: Vec<String>,
|
pub last_bookmark_ids: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn apply_scheduler_target(
|
||||||
|
rig_tx: &mpsc::Sender<RigRequest>,
|
||||||
|
rig_id: &str,
|
||||||
|
status_map: &SchedulerStatusMap,
|
||||||
|
bookmarks: &BookmarkStore,
|
||||||
|
bookmark_id: &str,
|
||||||
|
center_hz: Option<u64>,
|
||||||
|
extra_bm_ids: &[String],
|
||||||
|
) -> Result<SchedulerStatus, String> {
|
||||||
|
let bookmark = bookmarks
|
||||||
|
.get(bookmark_id)
|
||||||
|
.ok_or_else(|| format!("bookmark '{bookmark_id}' not found for rig '{rig_id}'"))?;
|
||||||
|
|
||||||
|
let extra_bookmarks: Vec<_> = extra_bm_ids
|
||||||
|
.iter()
|
||||||
|
.filter_map(|id| bookmarks.get(id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if let Some(chz) = center_hz {
|
||||||
|
scheduler_send(
|
||||||
|
rig_tx,
|
||||||
|
RigCommand::SetCenterFreq(Freq { hz: chz }),
|
||||||
|
rig_id.to_string(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduler_send(
|
||||||
|
rig_tx,
|
||||||
|
RigCommand::SetFreq(Freq {
|
||||||
|
hz: bookmark.freq_hz,
|
||||||
|
}),
|
||||||
|
rig_id.to_string(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
scheduler_send(
|
||||||
|
rig_tx,
|
||||||
|
RigCommand::SetMode(trx_protocol::parse_mode(&bookmark.mode)),
|
||||||
|
rig_id.to_string(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
apply_scheduler_decoders(rig_tx, rig_id, &bookmark, &extra_bookmarks).await;
|
||||||
|
|
||||||
|
let status = SchedulerStatus {
|
||||||
|
active: true,
|
||||||
|
last_bookmark_id: Some(bookmark_id.to_string()),
|
||||||
|
last_bookmark_name: Some(bookmark.name.clone()),
|
||||||
|
last_applied_utc: Some(
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs() as i64,
|
||||||
|
),
|
||||||
|
last_center_hz: center_hz,
|
||||||
|
last_bookmark_ids: extra_bm_ids.to_vec(),
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut map = status_map.write().unwrap_or_else(|e| e.into_inner());
|
||||||
|
map.insert(rig_id.to_string(), status.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(status)
|
||||||
|
}
|
||||||
|
|
||||||
/// Shared mutable state for scheduler status (one entry per rig).
|
/// Shared mutable state for scheduler status (one entry per rig).
|
||||||
pub type SchedulerStatusMap = Arc<RwLock<HashMap<String, SchedulerStatus>>>;
|
pub type SchedulerStatusMap = Arc<RwLock<HashMap<String, SchedulerStatus>>>;
|
||||||
|
|
||||||
@@ -524,91 +631,27 @@ pub fn spawn_scheduler_task(
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
let extra_bookmarks: Vec<_> = extra_bm_ids
|
|
||||||
.iter()
|
|
||||||
.filter_map(|id| bookmarks.get(id))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
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 Err(e) = apply_scheduler_target(
|
||||||
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.
|
|
||||||
if let Err(e) = scheduler_send(
|
|
||||||
&rig_tx,
|
&rig_tx,
|
||||||
RigCommand::SetFreq(Freq { hz: bm.freq_hz }),
|
&config.rig_id,
|
||||||
config.rig_id.clone(),
|
&status_map,
|
||||||
|
&bookmarks,
|
||||||
|
&bm_id,
|
||||||
|
center_hz,
|
||||||
|
&extra_bm_ids,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
warn!("scheduler: SetFreq failed for '{}': {:?}", config.rig_id, e);
|
warn!("scheduler: failed to apply target for '{}': {e}", config.rig_id);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply SetMode.
|
|
||||||
{
|
|
||||||
let mode = trx_protocol::parse_mode(&bm.mode);
|
|
||||||
if let Err(e) = scheduler_send(
|
|
||||||
&rig_tx,
|
|
||||||
RigCommand::SetMode(mode),
|
|
||||||
config.rig_id.clone(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
warn!(
|
|
||||||
"scheduler: SetMode failed for '{}': {:?}",
|
|
||||||
config.rig_id, e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
apply_scheduler_decoders(
|
|
||||||
&rig_tx,
|
|
||||||
&config.rig_id,
|
|
||||||
&bm,
|
|
||||||
&extra_bookmarks,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
last_applied.insert(config.rig_id.clone(), target);
|
last_applied.insert(config.rig_id.clone(), target);
|
||||||
|
|
||||||
// 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()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs() as i64;
|
|
||||||
{
|
|
||||||
let mut map = status_map.write().unwrap_or_else(|e| e.into_inner());
|
|
||||||
map.insert(
|
|
||||||
config.rig_id.clone(),
|
|
||||||
SchedulerStatus {
|
|
||||||
active: true,
|
|
||||||
last_bookmark_id: Some(bm_id),
|
|
||||||
last_bookmark_name: Some(bm.name),
|
|
||||||
last_applied_utc: Some(now_ts),
|
|
||||||
last_center_hz: center_hz,
|
|
||||||
last_bookmark_ids: extra_bm_ids,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -675,57 +718,27 @@ async fn apply_last_scheduler_cycle(
|
|||||||
let Some(bookmark_id) = status.last_bookmark_id else {
|
let Some(bookmark_id) = status.last_bookmark_id else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Some(bookmark) = bookmarks.get(&bookmark_id) else {
|
if bookmarks.get(&bookmark_id).is_none() {
|
||||||
warn!(
|
warn!(
|
||||||
"scheduler: last bookmark '{}' not found for rig '{}'",
|
"scheduler: last bookmark '{}' not found for rig '{}'",
|
||||||
bookmark_id, rig_id
|
bookmark_id, rig_id
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
};
|
|
||||||
|
|
||||||
let extra_bookmarks: Vec<_> = status
|
|
||||||
.last_bookmark_ids
|
|
||||||
.iter()
|
|
||||||
.filter_map(|id| bookmarks.get(id))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if let Some(center_hz) = status.last_center_hz {
|
|
||||||
if let Err(e) = scheduler_send(
|
|
||||||
rig_tx,
|
|
||||||
RigCommand::SetCenterFreq(Freq { hz: center_hz }),
|
|
||||||
rig_id.to_string(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
warn!(
|
|
||||||
"scheduler: restore SetCenterFreq failed for '{}': {:?}",
|
|
||||||
rig_id, e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(e) = scheduler_send(
|
if let Err(e) = apply_scheduler_target(
|
||||||
rig_tx,
|
rig_tx,
|
||||||
RigCommand::SetFreq(Freq { hz: bookmark.freq_hz }),
|
rig_id,
|
||||||
rig_id.to_string(),
|
status_map,
|
||||||
|
bookmarks,
|
||||||
|
&bookmark_id,
|
||||||
|
status.last_center_hz,
|
||||||
|
&status.last_bookmark_ids,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
warn!("scheduler: restore SetFreq failed for '{}': {:?}", rig_id, e);
|
warn!("scheduler: restore failed for '{}': {e}", rig_id);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(e) = scheduler_send(
|
|
||||||
rig_tx,
|
|
||||||
RigCommand::SetMode(trx_protocol::parse_mode(&bookmark.mode)),
|
|
||||||
rig_id.to_string(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
warn!("scheduler: restore SetMode failed for '{}': {:?}", rig_id, e);
|
|
||||||
}
|
|
||||||
|
|
||||||
apply_scheduler_decoders(rig_tx, rig_id, &bookmark, &extra_bookmarks).await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a single RigCommand from the scheduler context (fire-and-forget style).
|
/// Send a single RigCommand from the scheduler context (fire-and-forget style).
|
||||||
@@ -809,6 +822,53 @@ pub async fn get_scheduler_status(
|
|||||||
HttpResponse::Ok().json(status)
|
HttpResponse::Ok().json(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SchedulerActivateEntryRequest {
|
||||||
|
pub entry_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT /scheduler/{rig_id}/activate
|
||||||
|
#[put("/scheduler/{rig_id}/activate")]
|
||||||
|
pub async fn put_scheduler_activate_entry(
|
||||||
|
path: web::Path<String>,
|
||||||
|
body: web::Json<SchedulerActivateEntryRequest>,
|
||||||
|
store: web::Data<Arc<SchedulerStore>>,
|
||||||
|
status_map: web::Data<SchedulerStatusMap>,
|
||||||
|
bookmarks: web::Data<Arc<BookmarkStore>>,
|
||||||
|
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||||
|
) -> impl Responder {
|
||||||
|
let rig_id = path.into_inner();
|
||||||
|
let Some(config) = store.get(&rig_id) else {
|
||||||
|
return HttpResponse::NotFound().body("scheduler config not found");
|
||||||
|
};
|
||||||
|
if config.mode != SchedulerMode::TimeSpan {
|
||||||
|
return HttpResponse::BadRequest().body("scheduler mode is not time_span");
|
||||||
|
}
|
||||||
|
|
||||||
|
let entry_id = body.entry_id.trim();
|
||||||
|
let Some(entry) = config.entries.iter().find(|entry| entry.id == entry_id) else {
|
||||||
|
return HttpResponse::NotFound().body("scheduler entry not found");
|
||||||
|
};
|
||||||
|
if entry.bookmark_id.trim().is_empty() {
|
||||||
|
return HttpResponse::BadRequest().body("scheduler entry has no primary bookmark");
|
||||||
|
}
|
||||||
|
|
||||||
|
match apply_scheduler_target(
|
||||||
|
rig_tx.get_ref(),
|
||||||
|
&rig_id,
|
||||||
|
status_map.get_ref(),
|
||||||
|
bookmarks.get_ref().as_ref(),
|
||||||
|
&entry.bookmark_id,
|
||||||
|
entry.center_hz,
|
||||||
|
&entry.bookmark_ids,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(status) => HttpResponse::Ok().json(status),
|
||||||
|
Err(err) => HttpResponse::InternalServerError().body(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct SchedulerControlQuery {
|
pub struct SchedulerControlQuery {
|
||||||
pub session_id: Option<Uuid>,
|
pub session_id: Option<Uuid>,
|
||||||
@@ -856,7 +916,7 @@ pub async fn put_scheduler_control(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{timespan_active_entry, ScheduleEntry};
|
use super::{timespan_active_entry, timespan_cycle_slot, timespan_active_entries, ScheduleEntry};
|
||||||
|
|
||||||
fn entry(
|
fn entry(
|
||||||
id: &str,
|
id: &str,
|
||||||
@@ -901,4 +961,31 @@ mod tests {
|
|||||||
assert_eq!(active.id, "slot-a");
|
assert_eq!(active.id, "slot-a");
|
||||||
assert_eq!(active.center_hz, Some(14_100_000));
|
assert_eq!(active.center_hz, Some(14_100_000));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn timespan_cycle_is_anchored_to_overlap_start() {
|
||||||
|
let entries = vec![
|
||||||
|
entry("slot-a", 60, 180, "bm-a", Some(14_100_000), Some(10)),
|
||||||
|
entry("slot-b", 90, 180, "bm-b", Some(14_200_000), Some(10)),
|
||||||
|
];
|
||||||
|
|
||||||
|
let active = timespan_active_entry(&entries, 100.0, None).expect("active entry");
|
||||||
|
assert_eq!(active.id, "slot-b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn timespan_cycle_handles_overlap_spanning_midnight() {
|
||||||
|
let entries = vec![
|
||||||
|
entry("slot-a", 1380, 120, "bm-a", Some(14_100_000), Some(10)),
|
||||||
|
entry("slot-b", 0, 120, "bm-b", Some(14_200_000), Some(10)),
|
||||||
|
];
|
||||||
|
|
||||||
|
let active_entries = timespan_active_entries(&entries, 5.0);
|
||||||
|
assert_eq!(active_entries.len(), 2);
|
||||||
|
let slot = timespan_cycle_slot(&active_entries, 5.0, None).expect("cycle slot");
|
||||||
|
assert_eq!(slot, 0);
|
||||||
|
|
||||||
|
let active = timespan_active_entry(&entries, 10.0, None).expect("active entry");
|
||||||
|
assert_eq!(active.id, "slot-b");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user