[fix](trx-client): remove dead NOAA APT decoder, fix LRPT bookmark activation
Remove the wxsat/NOAA APT checkbox from bookmark decoder form and all JS references — the APT decoder no longer exists. Fix LRPT decoder not activating when an FM-mode bookmark is applied: bmApply() gated decoder toggles on DIG mode only, so LRPT bookmarks (which use FM) never triggered SetLrptDecodeEnabled. Gate on DIG or FM. Wire satellite pass scheduling into the scheduler loop: check configured satellite entries against live pass predictions, activate the satellite's bookmark (enabling LRPT decoder) when a pass is active, and expose active_satellite in SchedulerStatus for the frontend. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -472,7 +472,6 @@
|
||||
<label class="bm-decoder-check"><input type="checkbox" id="bm-dec-ft2" value="ft2" /> FT2</label>
|
||||
<label class="bm-decoder-check"><input type="checkbox" id="bm-dec-wspr" value="wspr" /> WSPR</label>
|
||||
<label class="bm-decoder-check"><input type="checkbox" id="bm-dec-hf-aprs" value="hf-aprs" /> HF APRS</label>
|
||||
<label class="bm-decoder-check"><input type="checkbox" id="bm-dec-wxsat" value="wxsat" /> NOAA APT</label>
|
||||
<label class="bm-decoder-check"><input type="checkbox" id="bm-dec-lrpt" value="lrpt" /> Meteor LRPT</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -236,7 +236,6 @@ function bmReadDecoders() {
|
||||
if (document.getElementById("bm-dec-ft2").checked) decoders.push("ft2");
|
||||
if (document.getElementById("bm-dec-wspr").checked) decoders.push("wspr");
|
||||
if (document.getElementById("bm-dec-hf-aprs").checked) decoders.push("hf-aprs");
|
||||
if (document.getElementById("bm-dec-wxsat").checked) decoders.push("wxsat");
|
||||
if (document.getElementById("bm-dec-lrpt").checked) decoders.push("lrpt");
|
||||
return decoders;
|
||||
}
|
||||
@@ -251,7 +250,6 @@ function bmWriteDecoders(decoders) {
|
||||
document.getElementById("bm-dec-ft2").checked = list.includes("ft2");
|
||||
document.getElementById("bm-dec-wspr").checked = list.includes("wspr");
|
||||
document.getElementById("bm-dec-hf-aprs").checked = list.includes("hf-aprs");
|
||||
document.getElementById("bm-dec-wxsat").checked = list.includes("wxsat");
|
||||
document.getElementById("bm-dec-lrpt").checked = list.includes("lrpt");
|
||||
}
|
||||
|
||||
@@ -430,8 +428,10 @@ async function bmApply(bm) {
|
||||
await postPath("/set_freq?hz=" + bm.freq_hz);
|
||||
}
|
||||
})();
|
||||
// Decoder toggles (DIG mode) — also fire-and-forget.
|
||||
const decoderPromise = (bm.mode === "DIG" && Array.isArray(bm.decoders)) ? (async () => {
|
||||
// Decoder toggles (DIG / FM modes) — also fire-and-forget.
|
||||
const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0;
|
||||
const decoderMode = bm.mode === "DIG" || bm.mode === "FM";
|
||||
const decoderPromise = (hasDecoders && decoderMode) ? (async () => {
|
||||
const statusResp = await fetch("/status");
|
||||
if (statusResp.ok) {
|
||||
const st = await statusResp.json();
|
||||
@@ -441,7 +441,7 @@ async function bmApply(bm) {
|
||||
toggles.push(postPath("/toggle_" + key.replace(/-/g, "_") + "_decode"));
|
||||
}
|
||||
};
|
||||
check("ft8"); check("ft4"); check("ft2"); check("wspr"); check("hf-aprs"); check("wxsat"); check("lrpt");
|
||||
check("ft8"); check("ft4"); check("ft2"); check("wspr"); check("hf-aprs"); check("lrpt");
|
||||
if (toggles.length) await Promise.all(toggles);
|
||||
}
|
||||
})() : Promise.resolve();
|
||||
|
||||
@@ -520,6 +520,9 @@ pub struct SchedulerStatus {
|
||||
/// Additional bookmark IDs active alongside the primary (virtual channels).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub last_bookmark_ids: Vec<String>,
|
||||
/// Name of the satellite whose pass is currently active (if any).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub active_satellite: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -532,6 +535,7 @@ async fn apply_scheduler_target(
|
||||
bookmark_id: &str,
|
||||
center_hz: Option<u64>,
|
||||
extra_bm_ids: &[String],
|
||||
satellite_name: Option<&str>,
|
||||
) -> Result<SchedulerStatus, String> {
|
||||
let bookmark = bookmarks
|
||||
.get_for_rig(remote, bookmark_id)
|
||||
@@ -595,6 +599,7 @@ async fn apply_scheduler_target(
|
||||
),
|
||||
last_center_hz: center_hz,
|
||||
last_bookmark_ids: extra_bm_ids.to_vec(),
|
||||
active_satellite: satellite_name.map(str::to_string),
|
||||
};
|
||||
|
||||
{
|
||||
@@ -613,6 +618,7 @@ struct AppliedTarget {
|
||||
bookmark_id: String,
|
||||
center_hz: Option<u64>,
|
||||
extra_bookmark_ids: Vec<String>,
|
||||
satellite: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Default)]
|
||||
@@ -680,7 +686,7 @@ impl SchedulerControlManager {
|
||||
pub type SharedSchedulerControlManager = Arc<SchedulerControlManager>;
|
||||
|
||||
pub fn spawn_scheduler_task(
|
||||
_context: Arc<FrontendRuntimeContext>,
|
||||
context: Arc<FrontendRuntimeContext>,
|
||||
rig_tx: mpsc::Sender<RigRequest>,
|
||||
store: Arc<SchedulerStoreMap>,
|
||||
bookmarks: Arc<BookmarkStoreMap>,
|
||||
@@ -709,8 +715,91 @@ pub fn spawn_scheduler_task(
|
||||
|
||||
let configs = store.list_all();
|
||||
let now_min = utc_minutes_now();
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64;
|
||||
|
||||
for config in configs {
|
||||
// ── Satellite pass scheduling ──────────────────────────
|
||||
// Satellite passes take priority over the base scheduler
|
||||
// mode. When a configured satellite has an active pass
|
||||
// above its minimum elevation, we retune to the
|
||||
// satellite's bookmark and enable its decoders (e.g.
|
||||
// LRPT).
|
||||
if let Some(sat_target) = find_active_satellite_target(
|
||||
&config,
|
||||
&context,
|
||||
now_ms,
|
||||
) {
|
||||
let target = AppliedTarget {
|
||||
bookmark_id: sat_target.bookmark_id.clone(),
|
||||
center_hz: sat_target.center_hz,
|
||||
extra_bookmark_ids: sat_target.extra_bm_ids.clone(),
|
||||
satellite: Some(sat_target.satellite.clone()),
|
||||
};
|
||||
|
||||
if last_applied.get(&config.remote) == Some(&target) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(bm) =
|
||||
bookmarks.get_for_rig(&config.remote, &sat_target.bookmark_id)
|
||||
else {
|
||||
warn!(
|
||||
"scheduler: satellite bookmark '{}' not found for remote '{}'",
|
||||
sat_target.bookmark_id, config.remote
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
info!(
|
||||
"scheduler: remote '{}' → satellite '{}' → bookmark '{}' ({} Hz {})",
|
||||
config.remote, sat_target.satellite, bm.name, bm.freq_hz, bm.mode
|
||||
);
|
||||
|
||||
if let Err(e) = apply_scheduler_target(
|
||||
&rig_tx,
|
||||
&config.remote,
|
||||
&status_map,
|
||||
&bookmarks,
|
||||
Some(&sat_target.entry_id),
|
||||
&sat_target.bookmark_id,
|
||||
sat_target.center_hz,
|
||||
&sat_target.extra_bm_ids,
|
||||
Some(&sat_target.satellite),
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
"scheduler: failed to apply satellite target for '{}': {e}",
|
||||
config.remote
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
last_applied.insert(config.remote.clone(), target);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If the previous target was a satellite pass that has
|
||||
// ended, clear it so the base mode can resume.
|
||||
if last_applied
|
||||
.get(&config.remote)
|
||||
.is_some_and(|t| t.satellite.is_some())
|
||||
{
|
||||
last_applied.remove(&config.remote);
|
||||
// Clear the active_satellite from status.
|
||||
if let Ok(mut map) =
|
||||
status_map.write()
|
||||
{
|
||||
if let Some(st) = map.get_mut(&config.remote) {
|
||||
st.active_satellite = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Base scheduler mode ───────────────────────────────
|
||||
if config.mode == SchedulerMode::Disabled {
|
||||
continue;
|
||||
}
|
||||
@@ -746,6 +835,7 @@ pub fn spawn_scheduler_task(
|
||||
bookmark_id: bm_id.clone(),
|
||||
center_hz,
|
||||
extra_bookmark_ids: extra_bm_ids.clone(),
|
||||
satellite: None,
|
||||
};
|
||||
|
||||
// Already at this exact scheduled target — skip.
|
||||
@@ -775,6 +865,7 @@ pub fn spawn_scheduler_task(
|
||||
&bm_id,
|
||||
center_hz,
|
||||
&extra_bm_ids,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -791,6 +882,93 @@ pub fn spawn_scheduler_task(
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Satellite pass helpers
|
||||
// ============================================================================
|
||||
|
||||
struct SatelliteTarget {
|
||||
entry_id: String,
|
||||
satellite: String,
|
||||
bookmark_id: String,
|
||||
center_hz: Option<u64>,
|
||||
extra_bm_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// Check if any configured satellite has an active pass right now.
|
||||
///
|
||||
/// Returns the highest-priority (lowest `priority` value) satellite entry
|
||||
/// whose NORAD ID has a pass in progress with max elevation above the
|
||||
/// entry's configured minimum.
|
||||
fn find_active_satellite_target(
|
||||
config: &SchedulerConfig,
|
||||
context: &FrontendRuntimeContext,
|
||||
now_ms: i64,
|
||||
) -> Option<SatelliteTarget> {
|
||||
let sat_cfg = config.satellites.as_ref().filter(|s| s.enabled)?;
|
||||
if sat_cfg.entries.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let passes = context
|
||||
.routing
|
||||
.sat_passes
|
||||
.read()
|
||||
.ok()
|
||||
.and_then(|g| g.clone())?;
|
||||
|
||||
// Build a lookup: NORAD ID → active pass (AOS ≤ now ≤ LOS).
|
||||
let active_passes: HashMap<u32, &trx_core::geo::PassPrediction> = passes
|
||||
.passes
|
||||
.iter()
|
||||
.filter(|p| now_ms >= p.aos_ms && now_ms <= p.los_ms)
|
||||
.map(|p| (p.norad_id, p))
|
||||
.collect();
|
||||
|
||||
if active_passes.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Among configured satellites with an active pass that meets the
|
||||
// minimum elevation requirement, pick the one with the best (lowest)
|
||||
// priority. Pre-tune window: accept passes that are about to start
|
||||
// within `pretune_secs`.
|
||||
let pretune_ms = (sat_cfg.pretune_secs as i64) * 1000;
|
||||
|
||||
let mut best: Option<(&SatelliteEntry, &trx_core::geo::PassPrediction)> = None;
|
||||
|
||||
for entry in &sat_cfg.entries {
|
||||
// Check for active pass or imminent pass within pretune window.
|
||||
let pass = active_passes.get(&entry.norad_id).copied().or_else(|| {
|
||||
passes.passes.iter().find(|p| {
|
||||
p.norad_id == entry.norad_id
|
||||
&& p.aos_ms > now_ms
|
||||
&& p.aos_ms <= now_ms + pretune_ms
|
||||
})
|
||||
});
|
||||
|
||||
let Some(pass) = pass else { continue };
|
||||
|
||||
if pass.max_elevation_deg < entry.min_elevation_deg {
|
||||
continue;
|
||||
}
|
||||
|
||||
match &best {
|
||||
Some((prev_entry, _)) if entry.priority >= prev_entry.priority => {}
|
||||
_ => best = Some((entry, pass)),
|
||||
}
|
||||
}
|
||||
|
||||
let (entry, _pass) = best?;
|
||||
|
||||
Some(SatelliteTarget {
|
||||
entry_id: entry.id.clone(),
|
||||
satellite: entry.satellite.clone(),
|
||||
bookmark_id: entry.bookmark_id.clone(),
|
||||
center_hz: entry.center_hz,
|
||||
extra_bm_ids: entry.bookmark_ids.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn apply_scheduler_decoders(
|
||||
rig_tx: &mpsc::Sender<RigRequest>,
|
||||
remote: &str,
|
||||
@@ -885,6 +1063,7 @@ async fn apply_last_scheduler_cycle(
|
||||
&bookmark_id,
|
||||
status.last_center_hz,
|
||||
&status.last_bookmark_ids,
|
||||
status.active_satellite.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -1017,6 +1196,7 @@ pub async fn put_scheduler_activate_entry(
|
||||
&entry.bookmark_id,
|
||||
entry.center_hz,
|
||||
&entry.bookmark_ids,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user