[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:
+119
@@ -0,0 +1,119 @@
|
|||||||
|
# Background Decoding Scheduler
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Background Decoding Scheduler automatically retunes the rig to pre-configured
|
||||||
|
bookmarks when no users are connected to the HTTP frontend. It runs as a background
|
||||||
|
tokio task inside `trx-frontend-http`, polling every 30 seconds.
|
||||||
|
|
||||||
|
## Modes
|
||||||
|
|
||||||
|
### Disabled (default)
|
||||||
|
Scheduler is inactive. Rig is not touched automatically.
|
||||||
|
|
||||||
|
### Grayline
|
||||||
|
Retunes around the solar terminator (day/night boundary).
|
||||||
|
|
||||||
|
The user provides:
|
||||||
|
- Station latitude and longitude (decimal degrees)
|
||||||
|
- Optional transition window width (minutes, default 20)
|
||||||
|
- Bookmark IDs for four periods:
|
||||||
|
- **Dawn** – window around sunrise (`sunrise ± window_min/2`)
|
||||||
|
- **Day** – after dawn until dusk
|
||||||
|
- **Dusk** – window around sunset (`sunset ± window_min/2`)
|
||||||
|
- **Night** – after dusk until next dawn
|
||||||
|
|
||||||
|
Period precedence (most specific wins): Dawn > Dusk > Day > Night.
|
||||||
|
|
||||||
|
If no bookmark is assigned to a period, the rig is not retuned for that period.
|
||||||
|
|
||||||
|
Sunrise/sunset is computed inline using the NOAA simplified algorithm.
|
||||||
|
Polar regions (midnight sun / polar night) fall back to Day/Night accordingly.
|
||||||
|
|
||||||
|
### TimeSpan
|
||||||
|
Retunes according to a list of user-defined time windows (UTC).
|
||||||
|
|
||||||
|
Each entry specifies:
|
||||||
|
- `start_hhmm` – start of window (e.g. 600 = 06:00 UTC)
|
||||||
|
- `end_hhmm` – end of window (e.g. 700 = 07:00 UTC)
|
||||||
|
- `bookmark_id` – bookmark to apply
|
||||||
|
- `label` – optional human-readable description
|
||||||
|
|
||||||
|
Windows that span midnight (`end_hhmm < start_hhmm`) are supported.
|
||||||
|
When multiple entries overlap, the first match (by list order) wins.
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
|
||||||
|
Configuration is stored in PickleDB at `~/.config/trx-rs/scheduler.db`.
|
||||||
|
|
||||||
|
Keys: `sch:{rig_id}` → JSON `SchedulerConfig`.
|
||||||
|
|
||||||
|
## HTTP API
|
||||||
|
|
||||||
|
All read endpoints are accessible at the **Rx** role level.
|
||||||
|
Write endpoints require the **Control** role.
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/scheduler/{rig_id}` | Get scheduler config for a rig |
|
||||||
|
| PUT | `/scheduler/{rig_id}` | Save scheduler config (Control only) |
|
||||||
|
| DELETE | `/scheduler/{rig_id}` | Reset config to Disabled (Control only) |
|
||||||
|
| GET | `/scheduler/{rig_id}/status` | Get last-applied bookmark and next event |
|
||||||
|
|
||||||
|
## Activation logic
|
||||||
|
|
||||||
|
Every 30 seconds the scheduler task checks:
|
||||||
|
1. `context.sse_clients.load() == 0` — no users connected
|
||||||
|
2. Active rig has a non-Disabled scheduler config
|
||||||
|
3. Current UTC time matches a scheduled window or grayline period
|
||||||
|
4. If the matching bookmark differs from `last_applied`, send `SetFreq` + `SetMode`
|
||||||
|
|
||||||
|
The scheduler **does not** revert changes when users reconnect. Bookmarks serve as
|
||||||
|
a frequency map — the user can retune manually after connecting.
|
||||||
|
|
||||||
|
## Data model (Rust)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum SchedulerMode { Disabled, Grayline, TimeSpan }
|
||||||
|
|
||||||
|
pub struct GraylineConfig {
|
||||||
|
pub lat: f64,
|
||||||
|
pub lon: f64,
|
||||||
|
pub transition_window_min: u32,
|
||||||
|
pub day_bookmark_id: Option<String>,
|
||||||
|
pub night_bookmark_id: Option<String>,
|
||||||
|
pub dawn_bookmark_id: Option<String>,
|
||||||
|
pub dusk_bookmark_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ScheduleEntry {
|
||||||
|
pub id: String,
|
||||||
|
pub start_hhmm: u32,
|
||||||
|
pub end_hhmm: u32,
|
||||||
|
pub bookmark_id: String,
|
||||||
|
pub label: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SchedulerConfig {
|
||||||
|
pub rig_id: String,
|
||||||
|
pub mode: SchedulerMode,
|
||||||
|
pub grayline: Option<GraylineConfig>,
|
||||||
|
pub entries: Vec<ScheduleEntry>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## UI (Scheduler tab)
|
||||||
|
|
||||||
|
A dedicated sixth tab with a clock icon.
|
||||||
|
|
||||||
|
- **Rig selector**: shows active rig (read-only).
|
||||||
|
- **Mode picker**: Disabled / Grayline / TimeSpan radio buttons.
|
||||||
|
- **Grayline section** (visible when mode = Grayline):
|
||||||
|
- Lat/lon inputs
|
||||||
|
- Transition window slider (5–60 min)
|
||||||
|
- Four bookmark selectors (Dawn / Day / Dusk / Night)
|
||||||
|
- **TimeSpan section** (visible when mode = TimeSpan):
|
||||||
|
- Table of entries with Start, End, Bookmark, Label, Remove button
|
||||||
|
- "Add Entry" row at the bottom
|
||||||
|
- **Status card**: last applied bookmark name and timestamp.
|
||||||
|
- Save button (Control only; form is read-only for Rx users).
|
||||||
@@ -162,6 +162,8 @@ pub struct FrontendRuntimeContext {
|
|||||||
pub wspr_history: Arc<Mutex<VecDeque<(Instant, WsprMessage)>>>,
|
pub wspr_history: Arc<Mutex<VecDeque<(Instant, WsprMessage)>>>,
|
||||||
/// Authentication tokens for HTTP-JSON frontend
|
/// Authentication tokens for HTTP-JSON frontend
|
||||||
pub auth_tokens: HashSet<String>,
|
pub auth_tokens: HashSet<String>,
|
||||||
|
/// Active HTTP SSE clients (incremented on /events connect, decremented on disconnect).
|
||||||
|
pub sse_clients: Arc<AtomicUsize>,
|
||||||
/// Active rigctl TCP clients.
|
/// Active rigctl TCP clients.
|
||||||
pub rigctl_clients: Arc<AtomicUsize>,
|
pub rigctl_clients: Arc<AtomicUsize>,
|
||||||
/// rigctl listen endpoint, if enabled.
|
/// rigctl listen endpoint, if enabled.
|
||||||
@@ -222,6 +224,7 @@ impl FrontendRuntimeContext {
|
|||||||
ft8_history: Arc::new(Mutex::new(VecDeque::new())),
|
ft8_history: Arc::new(Mutex::new(VecDeque::new())),
|
||||||
wspr_history: Arc::new(Mutex::new(VecDeque::new())),
|
wspr_history: Arc::new(Mutex::new(VecDeque::new())),
|
||||||
auth_tokens: HashSet::new(),
|
auth_tokens: HashSet::new(),
|
||||||
|
sse_clients: Arc::new(AtomicUsize::new(0)),
|
||||||
rigctl_clients: Arc::new(AtomicUsize::new(0)),
|
rigctl_clients: Arc::new(AtomicUsize::new(0)),
|
||||||
rigctl_listen_addr: Arc::new(Mutex::new(None)),
|
rigctl_listen_addr: Arc::new(Mutex::new(None)),
|
||||||
decode_collector_started: AtomicBool::new(false),
|
decode_collector_started: AtomicBool::new(false),
|
||||||
|
|||||||
@@ -3347,7 +3347,7 @@ if (spectrumBwSweetBtn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Tab navigation ---
|
// --- Tab navigation ---
|
||||||
const TAB_ORDER = ["main", "bookmarks", "decoders", "map", "about"];
|
const TAB_ORDER = ["main", "bookmarks", "decoders", "map", "scheduler", "about"];
|
||||||
|
|
||||||
function navigateToTab(name) {
|
function navigateToTab(name) {
|
||||||
if (authEnabled && !authRole && name !== "main") return;
|
if (authEnabled && !authRole && name !== "main") return;
|
||||||
@@ -3415,6 +3415,10 @@ document.querySelector(".tab-bar").addEventListener("click", (e) => {
|
|||||||
window.addEventListener("resize", () => { scheduleSpectrumLayout(); });
|
window.addEventListener("resize", () => { scheduleSpectrumLayout(); });
|
||||||
|
|
||||||
// --- Auth startup sequence ---
|
// --- Auth startup sequence ---
|
||||||
|
function getAvailableRigIds() {
|
||||||
|
return lastRigIds || [];
|
||||||
|
}
|
||||||
|
|
||||||
async function initializeApp() {
|
async function initializeApp() {
|
||||||
showAuthGate(false);
|
showAuthGate(false);
|
||||||
const authStatus = await checkAuthStatus();
|
const authStatus = await checkAuthStatus();
|
||||||
@@ -3426,6 +3430,7 @@ async function initializeApp() {
|
|||||||
updateAuthUI();
|
updateAuthUI();
|
||||||
connect();
|
connect();
|
||||||
connectDecode();
|
connectDecode();
|
||||||
|
initSchedulerUI();
|
||||||
resizeHeaderSignalCanvas();
|
resizeHeaderSignalCanvas();
|
||||||
startHeaderSignalSampling();
|
startHeaderSignalSampling();
|
||||||
return;
|
return;
|
||||||
@@ -3439,6 +3444,7 @@ async function initializeApp() {
|
|||||||
applyAuthRestrictions();
|
applyAuthRestrictions();
|
||||||
connect();
|
connect();
|
||||||
connectDecode();
|
connectDecode();
|
||||||
|
initSchedulerUI();
|
||||||
resizeHeaderSignalCanvas();
|
resizeHeaderSignalCanvas();
|
||||||
startHeaderSignalSampling();
|
startHeaderSignalSampling();
|
||||||
} else {
|
} else {
|
||||||
@@ -3449,6 +3455,13 @@ async function initializeApp() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initSchedulerUI() {
|
||||||
|
if (typeof initScheduler === "function") {
|
||||||
|
initScheduler(lastActiveRigId, authRole);
|
||||||
|
wireSchedulerEvents();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Setup auth form
|
// Setup auth form
|
||||||
document.getElementById("auth-form").addEventListener("submit", async (e) => {
|
document.getElementById("auth-form").addEventListener("submit", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -3466,6 +3479,7 @@ document.getElementById("auth-form").addEventListener("submit", async (e) => {
|
|||||||
applyAuthRestrictions();
|
applyAuthRestrictions();
|
||||||
connect();
|
connect();
|
||||||
connectDecode();
|
connectDecode();
|
||||||
|
initSchedulerUI();
|
||||||
resizeHeaderSignalCanvas();
|
resizeHeaderSignalCanvas();
|
||||||
startHeaderSignalSampling();
|
startHeaderSignalSampling();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -3488,6 +3502,7 @@ if (guestBtn) {
|
|||||||
applyAuthRestrictions();
|
applyAuthRestrictions();
|
||||||
connect();
|
connect();
|
||||||
connectDecode();
|
connectDecode();
|
||||||
|
initSchedulerUI();
|
||||||
resizeHeaderSignalCanvas();
|
resizeHeaderSignalCanvas();
|
||||||
startHeaderSignalSampling();
|
startHeaderSignalSampling();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,6 +46,10 @@
|
|||||||
<svg class="tab-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 2a4 4 0 0 1 4 4c0 3-4 8-4 8S4 9 4 6a4 4 0 0 1 4-4z"/><circle cx="8" cy="6" r="1.2" fill="currentColor" stroke="none"/></svg>
|
<svg class="tab-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 2a4 4 0 0 1 4 4c0 3-4 8-4 8S4 9 4 6a4 4 0 0 1 4-4z"/><circle cx="8" cy="6" r="1.2" fill="currentColor" stroke="none"/></svg>
|
||||||
<span class="tab-label">Map</span>
|
<span class="tab-label">Map</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="tab" data-tab="scheduler">
|
||||||
|
<svg class="tab-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6"/><path d="M8 5v3l2 2"/></svg>
|
||||||
|
<span class="tab-label">Scheduler</span>
|
||||||
|
</button>
|
||||||
<button class="tab" data-tab="about">
|
<button class="tab" data-tab="about">
|
||||||
<svg class="tab-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><circle cx="8" cy="8" r="6"/><path d="M8 7v5"/><circle cx="8" cy="5" r="0.5" fill="currentColor" stroke="none"/></svg>
|
<svg class="tab-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><circle cx="8" cy="8" r="6"/><path d="M8 7v5"/><circle cx="8" cy="5" r="0.5" fill="currentColor" stroke="none"/></svg>
|
||||||
<span class="tab-label">About</span>
|
<span class="tab-label">About</span>
|
||||||
@@ -663,6 +667,91 @@
|
|||||||
<div id="aprs-map"></div>
|
<div id="aprs-map"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="tab-scheduler" class="tab-panel" style="display:none;">
|
||||||
|
<div id="scheduler-panel" class="sch-panel">
|
||||||
|
<div class="sch-toast" id="scheduler-toast" style="display:none;"></div>
|
||||||
|
<div class="sch-row">
|
||||||
|
<label class="sch-label">Rig
|
||||||
|
<select id="scheduler-rig-select" class="status-input sch-rig-select" aria-label="Select rig"></select>
|
||||||
|
</label>
|
||||||
|
<label class="sch-label">Mode
|
||||||
|
<select id="scheduler-mode-select" class="status-input" aria-label="Scheduler mode">
|
||||||
|
<option value="disabled">Disabled</option>
|
||||||
|
<option value="grayline">Grayline</option>
|
||||||
|
<option value="time_span">Time Span</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grayline section -->
|
||||||
|
<div id="scheduler-grayline-section" class="sch-section" style="display:none;">
|
||||||
|
<div class="sch-section-title">Grayline Settings</div>
|
||||||
|
<div class="sch-row">
|
||||||
|
<label class="sch-label">Latitude (°)
|
||||||
|
<input type="number" id="scheduler-gl-lat" class="status-input" step="0.001" placeholder="e.g. 54.352" />
|
||||||
|
</label>
|
||||||
|
<label class="sch-label">Longitude (°)
|
||||||
|
<input type="number" id="scheduler-gl-lon" class="status-input" step="0.001" placeholder="e.g. 18.646" />
|
||||||
|
</label>
|
||||||
|
<label class="sch-label">Transition window (min)
|
||||||
|
<input type="number" id="scheduler-gl-window" class="status-input" min="5" max="120" value="20" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="sch-row">
|
||||||
|
<label class="sch-label">Dawn bookmark
|
||||||
|
<select id="scheduler-gl-dawn" class="status-input" aria-label="Dawn bookmark"></select>
|
||||||
|
</label>
|
||||||
|
<label class="sch-label">Day bookmark
|
||||||
|
<select id="scheduler-gl-day" class="status-input" aria-label="Day bookmark"></select>
|
||||||
|
</label>
|
||||||
|
<label class="sch-label">Dusk bookmark
|
||||||
|
<select id="scheduler-gl-dusk" class="status-input" aria-label="Dusk bookmark"></select>
|
||||||
|
</label>
|
||||||
|
<label class="sch-label">Night bookmark
|
||||||
|
<select id="scheduler-gl-night" class="status-input" aria-label="Night bookmark"></select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Time Span section -->
|
||||||
|
<div id="scheduler-timespan-section" class="sch-section" style="display:none;">
|
||||||
|
<div class="sch-section-title">Time Span Entries (UTC)</div>
|
||||||
|
<table class="sch-ts-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Start</th><th>End</th><th>Bookmark</th><th>Label</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="scheduler-ts-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="sch-row sch-add-row">
|
||||||
|
<label class="sch-label">Start (UTC)
|
||||||
|
<input type="time" id="scheduler-ts-start" class="status-input" />
|
||||||
|
</label>
|
||||||
|
<label class="sch-label">End (UTC)
|
||||||
|
<input type="time" id="scheduler-ts-end" class="status-input" />
|
||||||
|
</label>
|
||||||
|
<label class="sch-label">Bookmark
|
||||||
|
<select id="scheduler-ts-bookmark" class="status-input" aria-label="Entry bookmark"></select>
|
||||||
|
</label>
|
||||||
|
<label class="sch-label">Label (optional)
|
||||||
|
<input type="text" id="scheduler-ts-label" class="status-input" placeholder="e.g. 40m FT8" />
|
||||||
|
</label>
|
||||||
|
<button id="scheduler-ts-add-btn" class="sch-write" type="button">+ Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="sch-actions">
|
||||||
|
<button id="scheduler-save-btn" class="sch-write sch-save-btn" type="button" style="display:none;">Save</button>
|
||||||
|
<button id="scheduler-reset-btn" class="sch-write sch-reset-btn" type="button" style="display:none;">Reset to Disabled</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<div class="sch-section">
|
||||||
|
<div class="sch-section-title">Last Activity</div>
|
||||||
|
<div id="scheduler-status-card" class="sch-status-card">No activity yet.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="tab-about" class="tab-panel" style="display:none;">
|
<div id="tab-about" class="tab-panel" style="display:none;">
|
||||||
<div id="auth-badge" style="display:none; margin-bottom: 1rem; padding: 0.5rem; background: var(--bg-secondary); border-radius: 0.25rem; color: var(--text-muted); font-size: 0.85rem;">Authenticated as: <strong id="auth-role-badge">--</strong></div>
|
<div id="auth-badge" style="display:none; margin-bottom: 1rem; padding: 0.5rem; background: var(--bg-secondary); border-radius: 0.25rem; color: var(--text-muted); font-size: 0.85rem;">Authenticated as: <strong id="auth-role-badge">--</strong></div>
|
||||||
<table class="about-table">
|
<table class="about-table">
|
||||||
@@ -701,6 +790,7 @@
|
|||||||
<script src="/wspr.js"></script>
|
<script src="/wspr.js"></script>
|
||||||
<script src="/cw.js"></script>
|
<script src="/cw.js"></script>
|
||||||
<script src="/bookmarks.js"></script>
|
<script src="/bookmarks.js"></script>
|
||||||
|
<script src="/scheduler.js"></script>
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
<script src="/leaflet-ais-tracksymbol.js"></script>
|
<script src="/leaflet-ais-tracksymbol.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
})();
|
||||||
@@ -3330,3 +3330,130 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
|||||||
--wavelength-fg: #3a7a3a;
|
--wavelength-fg: #3a7a3a;
|
||||||
--spectrum-bg: #e0f0e0;
|
--spectrum-bg: #e0f0e0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =========================================================================
|
||||||
|
Scheduler tab
|
||||||
|
========================================================================= */
|
||||||
|
.sch-panel {
|
||||||
|
padding: 1rem;
|
||||||
|
max-width: 900px;
|
||||||
|
}
|
||||||
|
.sch-toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: 0.5rem 1.25rem;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
z-index: 999;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.sch-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.sch-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 9rem;
|
||||||
|
}
|
||||||
|
.sch-rig-select {
|
||||||
|
min-width: 10rem;
|
||||||
|
}
|
||||||
|
.sch-section {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
.sch-section-title {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.sch-ts-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.sch-ts-table th,
|
||||||
|
.sch-ts-table td {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
.sch-ts-table th {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.sch-remove-btn {
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
background: var(--btn-bg);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.sch-remove-btn:hover {
|
||||||
|
background: var(--btn-bg-hover, var(--btn-bg));
|
||||||
|
}
|
||||||
|
.sch-add-row {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.sch-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.sch-save-btn {
|
||||||
|
background: var(--accent-green);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
padding: 0.5rem 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.sch-save-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.sch-reset-btn {
|
||||||
|
background: var(--btn-bg);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
padding: 0.5rem 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.sch-status-card {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.sch-row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.sch-label {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1082,6 +1082,11 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
.service(create_bookmark)
|
.service(create_bookmark)
|
||||||
.service(update_bookmark)
|
.service(update_bookmark)
|
||||||
.service(delete_bookmark)
|
.service(delete_bookmark)
|
||||||
|
// Scheduler
|
||||||
|
.service(crate::server::scheduler::get_scheduler)
|
||||||
|
.service(crate::server::scheduler::put_scheduler)
|
||||||
|
.service(crate::server::scheduler::delete_scheduler)
|
||||||
|
.service(crate::server::scheduler::get_scheduler_status)
|
||||||
.service(crate::server::audio::audio_ws)
|
.service(crate::server::audio::audio_ws)
|
||||||
.service(favicon)
|
.service(favicon)
|
||||||
.service(favicon_png)
|
.service(favicon_png)
|
||||||
@@ -1098,6 +1103,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
.service(wspr_js)
|
.service(wspr_js)
|
||||||
.service(cw_js)
|
.service(cw_js)
|
||||||
.service(bookmarks_js)
|
.service(bookmarks_js)
|
||||||
|
.service(scheduler_js)
|
||||||
// Auth endpoints
|
// Auth endpoints
|
||||||
.service(crate::server::auth::login)
|
.service(crate::server::auth::login)
|
||||||
.service(crate::server::auth::logout)
|
.service(crate::server::auth::logout)
|
||||||
@@ -1249,6 +1255,16 @@ async fn bookmarks_js() -> impl Responder {
|
|||||||
.body(status::BOOKMARKS_JS)
|
.body(status::BOOKMARKS_JS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[get("/scheduler.js")]
|
||||||
|
async fn scheduler_js() -> impl Responder {
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.insert_header((
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
"application/javascript; charset=utf-8",
|
||||||
|
))
|
||||||
|
.body(status::SCHEDULER_JS)
|
||||||
|
}
|
||||||
|
|
||||||
async fn send_command(
|
async fn send_command(
|
||||||
rig_tx: &mpsc::Sender<RigRequest>,
|
rig_tx: &mpsc::Sender<RigRequest>,
|
||||||
cmd: RigCommand,
|
cmd: RigCommand,
|
||||||
|
|||||||
@@ -436,6 +436,7 @@ impl RouteAccess {
|
|||||||
|| path.starts_with("/audio?")
|
|| path.starts_with("/audio?")
|
||||||
|| path.starts_with("/bookmarks?")
|
|| path.starts_with("/bookmarks?")
|
||||||
|| path.starts_with("/bookmarks/")
|
|| path.starts_with("/bookmarks/")
|
||||||
|
|| path.starts_with("/scheduler/")
|
||||||
{
|
{
|
||||||
return Self::Read;
|
return Self::Read;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,504 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
//! Background Decoding Scheduler.
|
||||||
|
//!
|
||||||
|
//! When no SSE clients are connected the scheduler periodically inspects the
|
||||||
|
//! current UTC time, selects the matching bookmark from the per-rig config,
|
||||||
|
//! and issues `SetFreq` + `SetMode` commands to retune the rig automatically.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, RwLock};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use actix_web::{delete, get, put, web, HttpResponse, Responder};
|
||||||
|
use pickledb::{PickleDb, PickleDbDumpPolicy, SerializationMethod};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::sync::{mpsc, oneshot};
|
||||||
|
use tokio::time;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use trx_core::radio::freq::Freq;
|
||||||
|
use trx_core::rig::command::RigCommand;
|
||||||
|
use trx_core::RigRequest;
|
||||||
|
use trx_frontend::FrontendRuntimeContext;
|
||||||
|
|
||||||
|
use crate::server::bookmarks::BookmarkStore;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Data model
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SchedulerMode {
|
||||||
|
#[default]
|
||||||
|
Disabled,
|
||||||
|
Grayline,
|
||||||
|
TimeSpan,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GraylineConfig {
|
||||||
|
pub lat: f64,
|
||||||
|
pub lon: f64,
|
||||||
|
/// Half-width of dawn/dusk transition window in minutes (default 20).
|
||||||
|
#[serde(default = "default_transition_window")]
|
||||||
|
pub transition_window_min: u32,
|
||||||
|
pub day_bookmark_id: Option<String>,
|
||||||
|
pub night_bookmark_id: Option<String>,
|
||||||
|
pub dawn_bookmark_id: Option<String>,
|
||||||
|
pub dusk_bookmark_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_transition_window() -> u32 {
|
||||||
|
20
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ScheduleEntry {
|
||||||
|
pub id: String,
|
||||||
|
/// Start of window as minutes-since-midnight UTC (e.g. 360 = 06:00).
|
||||||
|
pub start_min: u32,
|
||||||
|
/// End of window as minutes-since-midnight UTC. May be < start_min
|
||||||
|
/// to represent a window that spans midnight.
|
||||||
|
pub end_min: u32,
|
||||||
|
pub bookmark_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub label: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct SchedulerConfig {
|
||||||
|
pub rig_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub mode: SchedulerMode,
|
||||||
|
pub grayline: Option<GraylineConfig>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub entries: Vec<ScheduleEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SchedulerStore
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
pub struct SchedulerStore {
|
||||||
|
db: Arc<RwLock<PickleDb>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SchedulerStore {
|
||||||
|
pub fn open(path: &Path) -> Self {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
let db = if path.exists() {
|
||||||
|
PickleDb::load(path, PickleDbDumpPolicy::AutoDump, SerializationMethod::Json)
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
|
PickleDb::new(path, PickleDbDumpPolicy::AutoDump, SerializationMethod::Json)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
PickleDb::new(path, PickleDbDumpPolicy::AutoDump, SerializationMethod::Json)
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
db: Arc::new(RwLock::new(db)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_path() -> PathBuf {
|
||||||
|
dirs::config_dir()
|
||||||
|
.map(|p| p.join("trx-rs").join("scheduler.db"))
|
||||||
|
.unwrap_or_else(|| PathBuf::from("scheduler.db"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, rig_id: &str) -> Option<SchedulerConfig> {
|
||||||
|
let db = self.db.read().unwrap_or_else(|e| e.into_inner());
|
||||||
|
db.get::<SchedulerConfig>(&format!("sch:{rig_id}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn upsert(&self, config: &SchedulerConfig) -> bool {
|
||||||
|
let mut db = self.db.write().unwrap_or_else(|e| e.into_inner());
|
||||||
|
db.set(&format!("sch:{}", config.rig_id), config).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove(&self, rig_id: &str) -> bool {
|
||||||
|
let mut db = self.db.write().unwrap_or_else(|e| e.into_inner());
|
||||||
|
db.rem(&format!("sch:{rig_id}")).unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_all(&self) -> Vec<SchedulerConfig> {
|
||||||
|
let db = self.db.read().unwrap_or_else(|e| e.into_inner());
|
||||||
|
db.iter()
|
||||||
|
.filter_map(|kv| {
|
||||||
|
if kv.get_key().starts_with("sch:") {
|
||||||
|
kv.get_value::<SchedulerConfig>()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Solar / grayline calculation (NOAA simplified algorithm)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Returns `(sunrise_min_utc, sunset_min_utc)` for the current UTC day.
|
||||||
|
/// Both values are in minutes-since-midnight UTC.
|
||||||
|
/// Returns `None` for polar regions where the sun never rises/sets.
|
||||||
|
fn sunrise_sunset_today(lat_deg: f64, lon_deg: f64) -> Option<(f64, f64)> {
|
||||||
|
use std::f64::consts::PI;
|
||||||
|
|
||||||
|
// Current Unix epoch time in seconds.
|
||||||
|
let unix_secs = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.ok()?
|
||||||
|
.as_secs_f64();
|
||||||
|
|
||||||
|
// Julian Day Number for this instant.
|
||||||
|
let jd = unix_secs / 86400.0 + 2440587.5;
|
||||||
|
|
||||||
|
// Julian Century from J2000.0.
|
||||||
|
let jc = (jd - 2451545.0) / 36525.0;
|
||||||
|
|
||||||
|
// Geometric mean longitude of the sun (degrees).
|
||||||
|
let l0 = (280.46646 + jc * (36000.76983 + jc * 0.0003032)).rem_euclid(360.0);
|
||||||
|
|
||||||
|
// Geometric mean anomaly of the sun (degrees).
|
||||||
|
let m = 357.52911 + jc * (35999.05029 - 0.0001537 * jc);
|
||||||
|
let m_rad = m.to_radians();
|
||||||
|
|
||||||
|
// Equation of center.
|
||||||
|
let c = (1.914602 - jc * (0.004817 + 0.000014 * jc)) * m_rad.sin()
|
||||||
|
+ (0.019993 - 0.000101 * jc) * (2.0 * m_rad).sin()
|
||||||
|
+ 0.000289 * (3.0 * m_rad).sin();
|
||||||
|
|
||||||
|
// Sun's true longitude.
|
||||||
|
let sun_lon = l0 + c;
|
||||||
|
|
||||||
|
// Apparent longitude.
|
||||||
|
let omega = 125.04 - 1934.136 * jc;
|
||||||
|
let lambda = sun_lon - 0.00569 - 0.00478 * omega.to_radians().sin();
|
||||||
|
|
||||||
|
// Obliquity of the ecliptic.
|
||||||
|
let eps0 = 23.0
|
||||||
|
+ (26.0
|
||||||
|
+ (21.448 - jc * (46.8150 + jc * (0.00059 - jc * 0.001813))) / 60.0)
|
||||||
|
/ 60.0;
|
||||||
|
let eps = eps0 + 0.00256 * omega.to_radians().cos();
|
||||||
|
|
||||||
|
// Sun's declination.
|
||||||
|
let decl = (eps.to_radians().sin() * lambda.to_radians().sin()).asin();
|
||||||
|
|
||||||
|
// Equation of time (minutes).
|
||||||
|
let y = (eps.to_radians() / 2.0).tan().powi(2);
|
||||||
|
let l0_rad = l0.to_radians();
|
||||||
|
let eot = 4.0
|
||||||
|
* (y * (2.0 * l0_rad).sin()
|
||||||
|
- 2.0 * m_rad.sin()
|
||||||
|
+ 4.0 * y * m_rad.sin() * (2.0 * l0_rad).cos()
|
||||||
|
- 0.5 * y * y * (4.0 * l0_rad).sin()
|
||||||
|
- 1.25 * (2.0 * m_rad).sin())
|
||||||
|
.to_degrees();
|
||||||
|
|
||||||
|
// Hour angle for sunrise/sunset (zenith = 90.833°).
|
||||||
|
let lat_rad = lat_deg.to_radians();
|
||||||
|
let cos_ha = ((PI / 2.0 + 0.833_f64.to_radians()).cos())
|
||||||
|
/ (lat_rad.cos() * decl.cos())
|
||||||
|
- lat_rad.tan() * decl.tan();
|
||||||
|
|
||||||
|
if cos_ha < -1.0 || cos_ha > 1.0 {
|
||||||
|
return None; // Polar day or polar night.
|
||||||
|
}
|
||||||
|
|
||||||
|
let ha_deg = cos_ha.acos().to_degrees();
|
||||||
|
|
||||||
|
// Solar noon (minutes from midnight UTC).
|
||||||
|
let solar_noon = 720.0 - 4.0 * lon_deg - eot;
|
||||||
|
|
||||||
|
Some((solar_noon - 4.0 * ha_deg, solar_noon + 4.0 * ha_deg))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Determine which grayline period is active for the given UTC time.
|
||||||
|
enum GraylinePeriod {
|
||||||
|
Dawn,
|
||||||
|
Dusk,
|
||||||
|
Day,
|
||||||
|
Night,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_grayline_period(gl: &GraylineConfig, now_min: f64) -> GraylinePeriod {
|
||||||
|
match sunrise_sunset_today(gl.lat, gl.lon) {
|
||||||
|
Some((sunrise, sunset)) => {
|
||||||
|
let hw = gl.transition_window_min as f64 / 2.0;
|
||||||
|
let in_dawn = (now_min - sunrise).abs() <= hw;
|
||||||
|
let in_dusk = (now_min - sunset).abs() <= hw;
|
||||||
|
if in_dawn {
|
||||||
|
GraylinePeriod::Dawn
|
||||||
|
} else if in_dusk {
|
||||||
|
GraylinePeriod::Dusk
|
||||||
|
} else if now_min > sunrise + hw && now_min < sunset - hw {
|
||||||
|
GraylinePeriod::Day
|
||||||
|
} else {
|
||||||
|
GraylinePeriod::Night
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Polar: if sun is above horizon most of the day just treat as Day.
|
||||||
|
None => {
|
||||||
|
if gl.lat >= 0.0 {
|
||||||
|
GraylinePeriod::Day
|
||||||
|
} else {
|
||||||
|
GraylinePeriod::Night
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn grayline_bookmark_id(gl: &GraylineConfig, now_min: f64) -> Option<String> {
|
||||||
|
match current_grayline_period(gl, now_min) {
|
||||||
|
GraylinePeriod::Dawn => gl.dawn_bookmark_id.clone(),
|
||||||
|
GraylinePeriod::Dusk => gl.dusk_bookmark_id.clone(),
|
||||||
|
GraylinePeriod::Day => gl.day_bookmark_id.clone(),
|
||||||
|
GraylinePeriod::Night => gl.night_bookmark_id.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn timespan_bookmark_id(entries: &[ScheduleEntry], now_min: f64) -> Option<String> {
|
||||||
|
for entry in entries {
|
||||||
|
let start = entry.start_min as f64;
|
||||||
|
let end = entry.end_min as f64;
|
||||||
|
let in_window = if start <= end {
|
||||||
|
now_min >= start && now_min < end
|
||||||
|
} else {
|
||||||
|
// Spans midnight.
|
||||||
|
now_min >= start || now_min < end
|
||||||
|
};
|
||||||
|
if in_window {
|
||||||
|
return Some(entry.bookmark_id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current UTC time as minutes since midnight.
|
||||||
|
fn utc_minutes_now() -> f64 {
|
||||||
|
let secs = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
((secs % 86400) as f64) / 60.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Scheduler background task
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Status info returned by the `/scheduler/{rig_id}/status` endpoint.
|
||||||
|
#[derive(Debug, Clone, Serialize, Default)]
|
||||||
|
pub struct SchedulerStatus {
|
||||||
|
pub active: bool,
|
||||||
|
pub last_bookmark_id: Option<String>,
|
||||||
|
pub last_bookmark_name: Option<String>,
|
||||||
|
pub last_applied_utc: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared mutable state for scheduler status (one entry per rig).
|
||||||
|
pub type SchedulerStatusMap = Arc<RwLock<HashMap<String, SchedulerStatus>>>;
|
||||||
|
|
||||||
|
pub fn spawn_scheduler_task(
|
||||||
|
context: Arc<FrontendRuntimeContext>,
|
||||||
|
rig_tx: mpsc::Sender<RigRequest>,
|
||||||
|
store: Arc<SchedulerStore>,
|
||||||
|
bookmarks: Arc<BookmarkStore>,
|
||||||
|
status_map: SchedulerStatusMap,
|
||||||
|
) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut interval = time::interval(Duration::from_secs(30));
|
||||||
|
// Track last applied bookmark per rig to avoid redundant retunes.
|
||||||
|
let mut last_applied: HashMap<String, String> = HashMap::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
|
||||||
|
// Skip if any user is currently connected.
|
||||||
|
if context
|
||||||
|
.sse_clients
|
||||||
|
.load(std::sync::atomic::Ordering::Relaxed)
|
||||||
|
> 0
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let configs = store.list_all();
|
||||||
|
let now_min = utc_minutes_now();
|
||||||
|
|
||||||
|
for config in configs {
|
||||||
|
if config.mode == SchedulerMode::Disabled {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let target_bm_id = match &config.mode {
|
||||||
|
SchedulerMode::Disabled => continue,
|
||||||
|
SchedulerMode::Grayline => config
|
||||||
|
.grayline
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|gl| grayline_bookmark_id(gl, now_min)),
|
||||||
|
SchedulerMode::TimeSpan => {
|
||||||
|
timespan_bookmark_id(&config.entries, now_min)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(bm_id) = target_bm_id else { continue };
|
||||||
|
|
||||||
|
// Already at this bookmark — skip.
|
||||||
|
if last_applied.get(&config.rig_id) == Some(&bm_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(bm) = bookmarks.get(&bm_id) else {
|
||||||
|
warn!(
|
||||||
|
"scheduler: bookmark '{}' not found for rig '{}'",
|
||||||
|
bm_id, config.rig_id
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"scheduler: rig '{}' → bookmark '{}' ({} Hz {})",
|
||||||
|
config.rig_id, bm.name, bm.freq_hz, bm.mode
|
||||||
|
);
|
||||||
|
|
||||||
|
// Apply SetFreq.
|
||||||
|
if let Err(e) = scheduler_send(
|
||||||
|
&rig_tx,
|
||||||
|
RigCommand::SetFreq(Freq { hz: bm.freq_hz }),
|
||||||
|
config.rig_id.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
warn!("scheduler: SetFreq failed for '{}': {:?}", config.rig_id, e);
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
last_applied.insert(config.rig_id.clone(), bm_id.clone());
|
||||||
|
|
||||||
|
// Update status map.
|
||||||
|
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),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a single RigCommand from the scheduler context (fire-and-forget style).
|
||||||
|
async fn scheduler_send(
|
||||||
|
rig_tx: &mpsc::Sender<RigRequest>,
|
||||||
|
cmd: RigCommand,
|
||||||
|
rig_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let (resp_tx, resp_rx) = oneshot::channel();
|
||||||
|
rig_tx
|
||||||
|
.send(RigRequest {
|
||||||
|
cmd,
|
||||||
|
respond_to: resp_tx,
|
||||||
|
rig_id_override: Some(rig_id),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("send error: {e:?}"))?;
|
||||||
|
|
||||||
|
let _ = tokio::time::timeout(Duration::from_secs(10), resp_rx).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HTTP handlers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// GET /scheduler/{rig_id}
|
||||||
|
#[get("/scheduler/{rig_id}")]
|
||||||
|
pub async fn get_scheduler(
|
||||||
|
path: web::Path<String>,
|
||||||
|
store: web::Data<Arc<SchedulerStore>>,
|
||||||
|
) -> impl Responder {
|
||||||
|
let rig_id = path.into_inner();
|
||||||
|
let config = store.get(&rig_id).unwrap_or(SchedulerConfig {
|
||||||
|
rig_id: rig_id.clone(),
|
||||||
|
mode: SchedulerMode::Disabled,
|
||||||
|
grayline: None,
|
||||||
|
entries: vec![],
|
||||||
|
});
|
||||||
|
HttpResponse::Ok().json(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT /scheduler/{rig_id}
|
||||||
|
#[put("/scheduler/{rig_id}")]
|
||||||
|
pub async fn put_scheduler(
|
||||||
|
path: web::Path<String>,
|
||||||
|
body: web::Json<SchedulerConfig>,
|
||||||
|
store: web::Data<Arc<SchedulerStore>>,
|
||||||
|
) -> impl Responder {
|
||||||
|
let rig_id = path.into_inner();
|
||||||
|
let mut config = body.into_inner();
|
||||||
|
config.rig_id = rig_id;
|
||||||
|
if store.upsert(&config) {
|
||||||
|
HttpResponse::Ok().json(config)
|
||||||
|
} else {
|
||||||
|
HttpResponse::InternalServerError().body("failed to save scheduler config")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DELETE /scheduler/{rig_id}
|
||||||
|
#[delete("/scheduler/{rig_id}")]
|
||||||
|
pub async fn delete_scheduler(
|
||||||
|
path: web::Path<String>,
|
||||||
|
store: web::Data<Arc<SchedulerStore>>,
|
||||||
|
) -> impl Responder {
|
||||||
|
let rig_id = path.into_inner();
|
||||||
|
store.remove(&rig_id);
|
||||||
|
HttpResponse::Ok().json(serde_json::json!({ "deleted": true }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /scheduler/{rig_id}/status
|
||||||
|
#[get("/scheduler/{rig_id}/status")]
|
||||||
|
pub async fn get_scheduler_status(
|
||||||
|
path: web::Path<String>,
|
||||||
|
status_map: web::Data<SchedulerStatusMap>,
|
||||||
|
) -> impl Responder {
|
||||||
|
let rig_id = path.into_inner();
|
||||||
|
let map = status_map.read().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let status = map.get(&rig_id).cloned().unwrap_or_default();
|
||||||
|
HttpResponse::Ok().json(status)
|
||||||
|
}
|
||||||
@@ -10,12 +10,14 @@ pub mod audio;
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
#[path = "bookmarks.rs"]
|
#[path = "bookmarks.rs"]
|
||||||
pub mod bookmarks;
|
pub mod bookmarks;
|
||||||
|
#[path = "scheduler.rs"]
|
||||||
|
pub mod scheduler;
|
||||||
#[path = "status.rs"]
|
#[path = "status.rs"]
|
||||||
pub mod status;
|
pub mod status;
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::atomic::AtomicUsize;
|
use std::sync::{Arc, RwLock};
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use actix_web::dev::Server;
|
use actix_web::dev::Server;
|
||||||
@@ -33,6 +35,7 @@ use trx_core::RigState;
|
|||||||
use trx_frontend::{FrontendRuntimeContext, FrontendSpawner};
|
use trx_frontend::{FrontendRuntimeContext, FrontendSpawner};
|
||||||
|
|
||||||
use auth::{AuthConfig, AuthState, SameSite};
|
use auth::{AuthConfig, AuthState, SameSite};
|
||||||
|
use scheduler::{SchedulerStatusMap, SchedulerStore};
|
||||||
|
|
||||||
/// HTTP frontend implementation.
|
/// HTTP frontend implementation.
|
||||||
pub struct HttpFrontend;
|
pub struct HttpFrontend;
|
||||||
@@ -61,7 +64,22 @@ async fn serve(
|
|||||||
context: Arc<FrontendRuntimeContext>,
|
context: Arc<FrontendRuntimeContext>,
|
||||||
) -> Result<(), actix_web::Error> {
|
) -> Result<(), actix_web::Error> {
|
||||||
audio::start_decode_history_collector(context.clone());
|
audio::start_decode_history_collector(context.clone());
|
||||||
let server = build_server(addr, state_rx, rig_tx, callsign, context)?;
|
|
||||||
|
let scheduler_path = SchedulerStore::default_path();
|
||||||
|
let scheduler_store = Arc::new(SchedulerStore::open(&scheduler_path));
|
||||||
|
let bookmark_path = bookmarks::BookmarkStore::default_path();
|
||||||
|
let bookmark_store_for_scheduler = Arc::new(bookmarks::BookmarkStore::open(&bookmark_path));
|
||||||
|
let scheduler_status: SchedulerStatusMap = Arc::new(RwLock::new(HashMap::new()));
|
||||||
|
|
||||||
|
scheduler::spawn_scheduler_task(
|
||||||
|
context.clone(),
|
||||||
|
rig_tx.clone(),
|
||||||
|
scheduler_store.clone(),
|
||||||
|
bookmark_store_for_scheduler,
|
||||||
|
scheduler_status.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let server = build_server(addr, state_rx, rig_tx, callsign, context, scheduler_store, scheduler_status)?;
|
||||||
let handle = server.handle();
|
let handle = server.handle();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _ = signal::ctrl_c().await;
|
let _ = signal::ctrl_c().await;
|
||||||
@@ -79,14 +97,21 @@ fn build_server(
|
|||||||
rig_tx: mpsc::Sender<RigRequest>,
|
rig_tx: mpsc::Sender<RigRequest>,
|
||||||
_callsign: Option<String>,
|
_callsign: Option<String>,
|
||||||
context: Arc<FrontendRuntimeContext>,
|
context: Arc<FrontendRuntimeContext>,
|
||||||
|
scheduler_store: Arc<SchedulerStore>,
|
||||||
|
scheduler_status: SchedulerStatusMap,
|
||||||
) -> Result<Server, actix_web::Error> {
|
) -> Result<Server, actix_web::Error> {
|
||||||
let state_data = web::Data::new(state_rx);
|
let state_data = web::Data::new(state_rx);
|
||||||
let rig_tx = web::Data::new(rig_tx);
|
let rig_tx = web::Data::new(rig_tx);
|
||||||
let clients = web::Data::new(Arc::new(AtomicUsize::new(0)));
|
// Share the same AtomicUsize that lives in FrontendRuntimeContext so the
|
||||||
|
// scheduler task can observe the connected-client count.
|
||||||
|
let clients = web::Data::new(context.sse_clients.clone());
|
||||||
|
|
||||||
let bookmark_path = bookmarks::BookmarkStore::default_path();
|
let bookmark_path = bookmarks::BookmarkStore::default_path();
|
||||||
let bookmark_store = web::Data::new(Arc::new(bookmarks::BookmarkStore::open(&bookmark_path)));
|
let bookmark_store = web::Data::new(Arc::new(bookmarks::BookmarkStore::open(&bookmark_path)));
|
||||||
|
|
||||||
|
let scheduler_store = web::Data::new(scheduler_store);
|
||||||
|
let scheduler_status = web::Data::new(scheduler_status);
|
||||||
|
|
||||||
// Extract auth config values before moving context
|
// Extract auth config values before moving context
|
||||||
let same_site = match context.http_auth_cookie_same_site.as_str() {
|
let same_site = match context.http_auth_cookie_same_site.as_str() {
|
||||||
"Strict" => SameSite::Strict,
|
"Strict" => SameSite::Strict,
|
||||||
@@ -126,6 +151,8 @@ fn build_server(
|
|||||||
.app_data(context_data.clone())
|
.app_data(context_data.clone())
|
||||||
.app_data(auth_state.clone())
|
.app_data(auth_state.clone())
|
||||||
.app_data(bookmark_store.clone())
|
.app_data(bookmark_store.clone())
|
||||||
|
.app_data(scheduler_store.clone())
|
||||||
|
.app_data(scheduler_status.clone())
|
||||||
.wrap(Compress::default())
|
.wrap(Compress::default())
|
||||||
.wrap(
|
.wrap(
|
||||||
DefaultHeaders::new()
|
DefaultHeaders::new()
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ pub const FT8_JS: &str = include_str!("../assets/web/plugins/ft8.js");
|
|||||||
pub const WSPR_JS: &str = include_str!("../assets/web/plugins/wspr.js");
|
pub const WSPR_JS: &str = include_str!("../assets/web/plugins/wspr.js");
|
||||||
pub const CW_JS: &str = include_str!("../assets/web/plugins/cw.js");
|
pub const CW_JS: &str = include_str!("../assets/web/plugins/cw.js");
|
||||||
pub const BOOKMARKS_JS: &str = include_str!("../assets/web/plugins/bookmarks.js");
|
pub const BOOKMARKS_JS: &str = include_str!("../assets/web/plugins/bookmarks.js");
|
||||||
|
pub const SCHEDULER_JS: &str = include_str!("../assets/web/plugins/scheduler.js");
|
||||||
|
|
||||||
pub fn index_html() -> String {
|
pub fn index_html() -> String {
|
||||||
INDEX_HTML
|
INDEX_HTML
|
||||||
|
|||||||
Reference in New Issue
Block a user