[feat](trx-rs): rework satellite predictions with category filter and live countdown
Add category selector (All/Weather/Ham Radio/Other) to predictions panel. Split predictions into currently receivable passes with live countdown timer and upcoming passes table. Add SatCategory enum to geo types for CelesTrak group classification. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -866,6 +866,12 @@
|
|||||||
<div id="sat-predictions-view" style="display:none;">
|
<div id="sat-predictions-view" style="display:none;">
|
||||||
<div class="ft8-controls">
|
<div class="ft8-controls">
|
||||||
<input id="sat-pred-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. ISS, NOAA, Meteor)" />
|
<input id="sat-pred-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. ISS, NOAA, Meteor)" />
|
||||||
|
<select id="sat-pred-category" class="sat-sort-select">
|
||||||
|
<option value="all">All</option>
|
||||||
|
<option value="weather">Weather</option>
|
||||||
|
<option value="amateur">Ham Radio</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</select>
|
||||||
<select id="sat-pred-min-el" class="sat-sort-select">
|
<select id="sat-pred-min-el" class="sat-sort-select">
|
||||||
<option value="0">All passes</option>
|
<option value="0">All passes</option>
|
||||||
<option value="10">Min 10°</option>
|
<option value="10">Min 10°</option>
|
||||||
@@ -873,14 +879,31 @@
|
|||||||
<option value="45">Min 45°</option>
|
<option value="45">Min 45°</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="sat-pred-header">
|
<!-- Current passes -->
|
||||||
<span class="sat-pred-col-time">AOS (UTC)</span>
|
<div id="sat-pred-current-section">
|
||||||
<span class="sat-pred-col-sat">Satellite</span>
|
<div class="sat-pred-section-title">Currently receivable</div>
|
||||||
<span class="sat-pred-col-el">Max El</span>
|
<div class="sat-pred-header sat-pred-header-current">
|
||||||
<span class="sat-pred-col-dur">Duration</span>
|
<span class="sat-pred-col-sat">Satellite</span>
|
||||||
<span class="sat-pred-col-dir">Direction</span>
|
<span class="sat-pred-col-el">Max El</span>
|
||||||
|
<span class="sat-pred-col-time">AOS Start</span>
|
||||||
|
<span class="sat-pred-col-time">AOS End</span>
|
||||||
|
<span class="sat-pred-col-countdown">Time left</span>
|
||||||
|
<span class="sat-pred-col-dir">Direction</span>
|
||||||
|
</div>
|
||||||
|
<div id="sat-pred-current-list"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Upcoming passes -->
|
||||||
|
<div id="sat-pred-upcoming-section">
|
||||||
|
<div class="sat-pred-section-title">Upcoming passes</div>
|
||||||
|
<div class="sat-pred-header">
|
||||||
|
<span class="sat-pred-col-time">AOS (UTC)</span>
|
||||||
|
<span class="sat-pred-col-sat">Satellite</span>
|
||||||
|
<span class="sat-pred-col-el">Max El</span>
|
||||||
|
<span class="sat-pred-col-dur">Duration</span>
|
||||||
|
<span class="sat-pred-col-dir">Direction</span>
|
||||||
|
</div>
|
||||||
|
<div id="sat-pred-list"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="sat-pred-list"></div>
|
|
||||||
<small id="sat-pred-status" style="color:var(--text-muted);font-size:0.75rem;">Loading predictions…</small>
|
<small id="sat-pred-status" style="color:var(--text-muted);font-size:0.75rem;">Loading predictions…</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -292,12 +292,18 @@ document
|
|||||||
let satPredData = [];
|
let satPredData = [];
|
||||||
let satPredFilterText = "";
|
let satPredFilterText = "";
|
||||||
let satPredMinEl = 0;
|
let satPredMinEl = 0;
|
||||||
|
let satPredCategory = "all";
|
||||||
let satPredSatCount = 0;
|
let satPredSatCount = 0;
|
||||||
|
let satPredCountdownTimer = null;
|
||||||
const satPredFilterInput = document.getElementById("sat-pred-filter");
|
const satPredFilterInput = document.getElementById("sat-pred-filter");
|
||||||
const satPredMinElSelect = document.getElementById("sat-pred-min-el");
|
const satPredMinElSelect = document.getElementById("sat-pred-min-el");
|
||||||
|
const satPredCategorySelect = document.getElementById("sat-pred-category");
|
||||||
|
|
||||||
function getFilteredPredictions() {
|
function getFilteredPredictions() {
|
||||||
let items = satPredData;
|
let items = satPredData;
|
||||||
|
if (satPredCategory !== "all") {
|
||||||
|
items = items.filter((p) => p.category === satPredCategory);
|
||||||
|
}
|
||||||
if (satPredMinEl > 0) {
|
if (satPredMinEl > 0) {
|
||||||
items = items.filter((p) => p.max_elevation_deg >= satPredMinEl);
|
items = items.filter((p) => p.max_elevation_deg >= satPredMinEl);
|
||||||
}
|
}
|
||||||
@@ -307,14 +313,23 @@ function getFilteredPredictions() {
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyPredFilters() {
|
||||||
|
renderSatPredictions(getFilteredPredictions());
|
||||||
|
}
|
||||||
|
|
||||||
satPredFilterInput?.addEventListener("input", () => {
|
satPredFilterInput?.addEventListener("input", () => {
|
||||||
satPredFilterText = satPredFilterInput.value.trim().toUpperCase();
|
satPredFilterText = satPredFilterInput.value.trim().toUpperCase();
|
||||||
renderSatPredictions(getFilteredPredictions());
|
applyPredFilters();
|
||||||
});
|
});
|
||||||
|
|
||||||
satPredMinElSelect?.addEventListener("change", () => {
|
satPredMinElSelect?.addEventListener("change", () => {
|
||||||
satPredMinEl = parseInt(satPredMinElSelect.value, 10) || 0;
|
satPredMinEl = parseInt(satPredMinElSelect.value, 10) || 0;
|
||||||
renderSatPredictions(getFilteredPredictions());
|
applyPredFilters();
|
||||||
|
});
|
||||||
|
|
||||||
|
satPredCategorySelect?.addEventListener("change", () => {
|
||||||
|
satPredCategory = satPredCategorySelect.value;
|
||||||
|
applyPredFilters();
|
||||||
});
|
});
|
||||||
|
|
||||||
function azToCardinal(deg) {
|
function azToCardinal(deg) {
|
||||||
@@ -339,55 +354,142 @@ function formatPredDuration(s) {
|
|||||||
return `${s}s`;
|
return `${s}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatCountdown(ms) {
|
||||||
|
const totalSec = Math.max(0, Math.floor(ms / 1000));
|
||||||
|
const m = Math.floor(totalSec / 60);
|
||||||
|
const s = totalSec % 60;
|
||||||
|
return `${m}:${String(s).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderSatPredictions(passes, error) {
|
function renderSatPredictions(passes, error) {
|
||||||
const list = document.getElementById("sat-pred-list");
|
const currentList = document.getElementById("sat-pred-current-list");
|
||||||
|
const upcomingList = document.getElementById("sat-pred-list");
|
||||||
|
const currentSection = document.getElementById("sat-pred-current-section");
|
||||||
|
const upcomingSection = document.getElementById("sat-pred-upcoming-section");
|
||||||
const status = document.getElementById("sat-pred-status");
|
const status = document.getElementById("sat-pred-status");
|
||||||
if (!list) return;
|
|
||||||
|
// Stop any previous countdown timer
|
||||||
|
if (satPredCountdownTimer) { clearInterval(satPredCountdownTimer); satPredCountdownTimer = null; }
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
list.innerHTML = "";
|
if (currentList) currentList.innerHTML = "";
|
||||||
|
if (upcomingList) upcomingList.innerHTML = "";
|
||||||
|
if (currentSection) currentSection.style.display = "none";
|
||||||
|
if (upcomingSection) upcomingSection.style.display = "none";
|
||||||
if (status) status.textContent = error;
|
if (status) status.textContent = error;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Array.isArray(passes) || passes.length === 0) {
|
if (!Array.isArray(passes) || passes.length === 0) {
|
||||||
list.innerHTML = "";
|
if (currentList) currentList.innerHTML = "";
|
||||||
|
if (upcomingList) upcomingList.innerHTML = "";
|
||||||
|
if (currentSection) currentSection.style.display = "none";
|
||||||
|
if (upcomingSection) upcomingSection.style.display = "none";
|
||||||
if (status) status.textContent = "No passes found in the next 24 hours.";
|
if (status) status.textContent = "No passes found in the next 24 hours.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fragment = document.createDocumentFragment();
|
const now = Date.now();
|
||||||
for (const pass of passes) {
|
const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now);
|
||||||
const row = document.createElement("div");
|
const upcoming = passes.filter((p) => p.aos_ms > now);
|
||||||
row.className = "sat-pred-row";
|
|
||||||
const elClass = pass.max_elevation_deg >= 45
|
// ── Current passes ──
|
||||||
? "sat-pred-el-high"
|
if (currentSection) currentSection.style.display = current.length > 0 ? "" : "none";
|
||||||
: pass.max_elevation_deg >= 10
|
if (currentList) {
|
||||||
? "sat-pred-el-mid"
|
if (current.length === 0) {
|
||||||
: "sat-pred-el-low";
|
currentList.innerHTML = "";
|
||||||
const dir = `${azToCardinal(pass.azimuth_aos_deg)} → ${azToCardinal(pass.azimuth_los_deg)}`;
|
} else {
|
||||||
row.innerHTML = [
|
const frag = document.createDocumentFragment();
|
||||||
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
for (const pass of current) {
|
||||||
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
const row = document.createElement("div");
|
||||||
`<span class="sat-pred-col-el ${elClass}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
|
row.className = "sat-pred-row-current";
|
||||||
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
|
const elClass = pass.max_elevation_deg >= 45
|
||||||
`<span class="sat-pred-col-dir">${dir}</span>`,
|
? "sat-pred-el-high"
|
||||||
].join("");
|
: pass.max_elevation_deg >= 10
|
||||||
fragment.appendChild(row);
|
? "sat-pred-el-mid"
|
||||||
|
: "sat-pred-el-low";
|
||||||
|
const dir = `${azToCardinal(pass.azimuth_aos_deg)} → ${azToCardinal(pass.azimuth_los_deg)}`;
|
||||||
|
const remaining = Math.max(0, pass.los_ms - now);
|
||||||
|
row.innerHTML = [
|
||||||
|
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
||||||
|
`<span class="sat-pred-col-el ${elClass}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
|
||||||
|
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
||||||
|
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`,
|
||||||
|
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`,
|
||||||
|
`<span class="sat-pred-col-dir">${dir}</span>`,
|
||||||
|
].join("");
|
||||||
|
frag.appendChild(row);
|
||||||
|
}
|
||||||
|
currentList.replaceChildren(frag);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
list.replaceChildren(fragment);
|
|
||||||
|
// ── Upcoming passes ──
|
||||||
|
if (upcomingSection) upcomingSection.style.display = upcoming.length > 0 ? "" : "none";
|
||||||
|
if (upcomingList) {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (const pass of upcoming) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "sat-pred-row";
|
||||||
|
const elClass = pass.max_elevation_deg >= 45
|
||||||
|
? "sat-pred-el-high"
|
||||||
|
: pass.max_elevation_deg >= 10
|
||||||
|
? "sat-pred-el-mid"
|
||||||
|
: "sat-pred-el-low";
|
||||||
|
const dir = `${azToCardinal(pass.azimuth_aos_deg)} → ${azToCardinal(pass.azimuth_los_deg)}`;
|
||||||
|
row.innerHTML = [
|
||||||
|
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
||||||
|
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
||||||
|
`<span class="sat-pred-col-el ${elClass}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
|
||||||
|
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
|
||||||
|
`<span class="sat-pred-col-dir">${dir}</span>`,
|
||||||
|
].join("");
|
||||||
|
frag.appendChild(row);
|
||||||
|
}
|
||||||
|
upcomingList.replaceChildren(frag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status ──
|
||||||
if (status) {
|
if (status) {
|
||||||
let text = `${passes.length} pass${passes.length === 1 ? "" : "es"} in the next 24 h · times in UTC`;
|
const totalAll = getFilteredPredictions().length;
|
||||||
|
let text = `${current.length} active · ${upcoming.length} upcoming · times in UTC`;
|
||||||
if (satPredSatCount > 0) text += ` · ${satPredSatCount} satellites tracked`;
|
if (satPredSatCount > 0) text += ` · ${satPredSatCount} satellites tracked`;
|
||||||
status.textContent = text;
|
status.textContent = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Countdown timer: update "time left" every second ──
|
||||||
|
if (current.length > 0) {
|
||||||
|
satPredCountdownTimer = setInterval(() => {
|
||||||
|
const n = Date.now();
|
||||||
|
const els = document.querySelectorAll("#sat-pred-current-list .sat-pred-col-countdown");
|
||||||
|
let anyActive = false;
|
||||||
|
for (const el of els) {
|
||||||
|
const los = parseInt(el.dataset.los, 10);
|
||||||
|
const rem = los - n;
|
||||||
|
if (rem > 0) {
|
||||||
|
el.textContent = formatCountdown(rem);
|
||||||
|
anyActive = true;
|
||||||
|
} else {
|
||||||
|
el.textContent = "0:00";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!anyActive) {
|
||||||
|
// All current passes ended — re-render to move them out
|
||||||
|
clearInterval(satPredCountdownTimer);
|
||||||
|
satPredCountdownTimer = null;
|
||||||
|
renderSatPredictions(getFilteredPredictions());
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSatPredictions() {
|
async function loadSatPredictions() {
|
||||||
const status = document.getElementById("sat-pred-status");
|
const status = document.getElementById("sat-pred-status");
|
||||||
const list = document.getElementById("sat-pred-list");
|
const currentList = document.getElementById("sat-pred-current-list");
|
||||||
|
const upcomingList = document.getElementById("sat-pred-list");
|
||||||
if (status) status.textContent = "Loading predictions\u2026";
|
if (status) status.textContent = "Loading predictions\u2026";
|
||||||
if (list) list.innerHTML = "";
|
if (currentList) currentList.innerHTML = "";
|
||||||
|
if (upcomingList) upcomingList.innerHTML = "";
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("/sat_passes");
|
const resp = await fetch("/sat_passes");
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
|||||||
@@ -4572,9 +4572,16 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
|||||||
.sat-pred-el-high { color: var(--accent-green); font-weight: 600; }
|
.sat-pred-el-high { color: var(--accent-green); font-weight: 600; }
|
||||||
.sat-pred-el-mid { color: #f0a020; }
|
.sat-pred-el-mid { color: #f0a020; }
|
||||||
.sat-pred-el-low { color: var(--text-muted); }
|
.sat-pred-el-low { color: var(--text-muted); }
|
||||||
|
.sat-pred-section-title { font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); padding: 0.5rem 0.4rem 0.15rem; }
|
||||||
|
.sat-pred-header-current { grid-template-columns: 1fr 4.5rem 6rem 6rem 5.5rem 6rem; }
|
||||||
|
.sat-pred-row-current { display: grid; grid-template-columns: 1fr 4.5rem 6rem 6rem 5.5rem 6rem; gap: 0.25rem; padding: 0.35rem 0.4rem; font-size: 0.82rem; border-bottom: 1px solid var(--border-faint, rgba(255,255,255,0.04)); }
|
||||||
|
.sat-pred-row-current:hover { background: var(--bg-hover, rgba(255,255,255,0.02)); }
|
||||||
|
.sat-pred-col-countdown { font-variant-numeric: tabular-nums; font-weight: 600; color: var(--accent-green); }
|
||||||
|
.sat-pred-current-empty { padding: 0.5rem 0.4rem; font-size: 0.8rem; color: var(--text-muted); }
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.sat-live-grid { grid-template-columns: 1fr; }
|
.sat-live-grid { grid-template-columns: 1fr; }
|
||||||
.sat-history-header, .sat-history-row { grid-template-columns: 5rem 4rem 6rem 4rem 3.5rem 1fr; font-size: 0.75rem; }
|
.sat-history-header, .sat-history-row { grid-template-columns: 5rem 4rem 6rem 4rem 3.5rem 1fr; font-size: 0.75rem; }
|
||||||
.sat-pred-header, .sat-pred-row { grid-template-columns: 5.5rem 1fr 4rem 4.5rem; font-size: 0.75rem; }
|
.sat-pred-header, .sat-pred-row { grid-template-columns: 5.5rem 1fr 4rem 4.5rem; font-size: 0.75rem; }
|
||||||
|
.sat-pred-header-current, .sat-pred-row-current { grid-template-columns: 1fr 4rem 5rem 5rem 4.5rem; font-size: 0.75rem; }
|
||||||
.sat-pred-col-dir { display: none; }
|
.sat-pred-col-dir { display: none; }
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-15
@@ -15,7 +15,7 @@ use std::sync::RwLock;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
/// Result of computing upcoming passes, including metadata about TLE source.
|
/// Result of computing upcoming passes, including metadata about TLE source.
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct PassPredictionResult {
|
pub struct PassPredictionResult {
|
||||||
/// Predicted passes sorted by AOS time.
|
/// Predicted passes sorted by AOS time.
|
||||||
pub passes: Vec<PassPrediction>,
|
pub passes: Vec<PassPrediction>,
|
||||||
@@ -26,7 +26,7 @@ pub struct PassPredictionResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Indicates the origin of the TLE data used for predictions.
|
/// Indicates the origin of the TLE data used for predictions.
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum TleSource {
|
pub enum TleSource {
|
||||||
/// Live TLE data fetched from CelesTrak.
|
/// Live TLE data fetched from CelesTrak.
|
||||||
@@ -52,12 +52,22 @@ const CELESTRAK_HAM_URL: &str =
|
|||||||
/// How often to refresh TLEs after the initial fetch (24 hours).
|
/// How often to refresh TLEs after the initial fetch (24 hours).
|
||||||
const TLE_REFRESH_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
|
const TLE_REFRESH_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
|
||||||
|
|
||||||
|
/// Satellite category based on TLE source group.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SatCategory {
|
||||||
|
Weather,
|
||||||
|
Amateur,
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
/// A single TLE entry: satellite name + two-line element set.
|
/// A single TLE entry: satellite name + two-line element set.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct TleEntry {
|
pub struct TleEntry {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub line1: String,
|
pub line1: String,
|
||||||
pub line2: String,
|
pub line2: String,
|
||||||
|
pub category: SatCategory,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Global store for dynamically-fetched TLE data.
|
/// Global store for dynamically-fetched TLE data.
|
||||||
@@ -81,12 +91,14 @@ pub struct PassGeo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A predicted satellite pass over the observer's location.
|
/// A predicted satellite pass over the observer's location.
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct PassPrediction {
|
pub struct PassPrediction {
|
||||||
/// Satellite display name.
|
/// Satellite display name.
|
||||||
pub satellite: String,
|
pub satellite: String,
|
||||||
/// NORAD catalog number.
|
/// NORAD catalog number.
|
||||||
pub norad_id: u32,
|
pub norad_id: u32,
|
||||||
|
/// Satellite category (weather, amateur, other).
|
||||||
|
pub category: SatCategory,
|
||||||
/// Acquisition of Signal: UTC timestamp in milliseconds.
|
/// Acquisition of Signal: UTC timestamp in milliseconds.
|
||||||
pub aos_ms: i64,
|
pub aos_ms: i64,
|
||||||
/// Loss of Signal: UTC timestamp in milliseconds.
|
/// Loss of Signal: UTC timestamp in milliseconds.
|
||||||
@@ -178,7 +190,7 @@ fn tle_for_satellite(name: &str) -> Option<(String, String)> {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Parse a CelesTrak 3-line TLE response into a map of NORAD ID → TleEntry.
|
/// Parse a CelesTrak 3-line TLE response into a map of NORAD ID → TleEntry.
|
||||||
fn parse_tle_response(body: &str) -> HashMap<u32, TleEntry> {
|
fn parse_tle_response(body: &str, category: SatCategory) -> HashMap<u32, TleEntry> {
|
||||||
let mut result = HashMap::new();
|
let mut result = HashMap::new();
|
||||||
let lines: Vec<&str> = body.lines().map(|l| l.trim_end()).collect();
|
let lines: Vec<&str> = body.lines().map(|l| l.trim_end()).collect();
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
@@ -196,6 +208,7 @@ fn parse_tle_response(body: &str) -> HashMap<u32, TleEntry> {
|
|||||||
name: name_line.to_string(),
|
name: name_line.to_string(),
|
||||||
line1: line1.to_string(),
|
line1: line1.to_string(),
|
||||||
line2: line2.to_string(),
|
line2: line2.to_string(),
|
||||||
|
category,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -206,7 +219,7 @@ fn parse_tle_response(body: &str) -> HashMap<u32, TleEntry> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch TLEs from a CelesTrak URL and merge them into the global store.
|
/// Fetch TLEs from a CelesTrak URL and merge them into the global store.
|
||||||
async fn fetch_and_merge_tles(url: &str) -> Result<usize, String> {
|
async fn fetch_and_merge_tles(url: &str, category: SatCategory) -> Result<usize, String> {
|
||||||
let response = reqwest::Client::builder()
|
let response = reqwest::Client::builder()
|
||||||
.timeout(Duration::from_secs(30))
|
.timeout(Duration::from_secs(30))
|
||||||
.build()
|
.build()
|
||||||
@@ -225,7 +238,7 @@ async fn fetch_and_merge_tles(url: &str) -> Result<usize, String> {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to read CelesTrak response: {e}"))?;
|
.map_err(|e| format!("Failed to read CelesTrak response: {e}"))?;
|
||||||
|
|
||||||
let tles = parse_tle_response(&body);
|
let tles = parse_tle_response(&body, category);
|
||||||
let count = tles.len();
|
let count = tles.len();
|
||||||
|
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
@@ -257,7 +270,7 @@ async fn fetch_and_merge_tles(url: &str) -> Result<usize, String> {
|
|||||||
///
|
///
|
||||||
/// Returns the number of TLEs loaded, or an error description.
|
/// Returns the number of TLEs loaded, or an error description.
|
||||||
pub async fn refresh_tles_from_celestrak() -> Result<usize, String> {
|
pub async fn refresh_tles_from_celestrak() -> Result<usize, String> {
|
||||||
fetch_and_merge_tles(CELESTRAK_WEATHER_URL).await
|
fetch_and_merge_tles(CELESTRAK_WEATHER_URL, SatCategory::Weather).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn a background task that fetches TLEs from CelesTrak on start and
|
/// Spawn a background task that fetches TLEs from CelesTrak on start and
|
||||||
@@ -268,7 +281,7 @@ pub async fn refresh_tles_from_celestrak() -> Result<usize, String> {
|
|||||||
pub fn spawn_tle_refresh_task() {
|
pub fn spawn_tle_refresh_task() {
|
||||||
tokio::spawn(async {
|
tokio::spawn(async {
|
||||||
// Initial fetch at startup: weather + amateur satellites.
|
// Initial fetch at startup: weather + amateur satellites.
|
||||||
match fetch_and_merge_tles(CELESTRAK_WEATHER_URL).await {
|
match fetch_and_merge_tles(CELESTRAK_WEATHER_URL, SatCategory::Weather).await {
|
||||||
Ok(n) => {
|
Ok(n) => {
|
||||||
tracing::info!("TLE refresh: loaded {n} weather satellite TLEs from CelesTrak")
|
tracing::info!("TLE refresh: loaded {n} weather satellite TLEs from CelesTrak")
|
||||||
}
|
}
|
||||||
@@ -276,7 +289,7 @@ pub fn spawn_tle_refresh_task() {
|
|||||||
tracing::warn!("TLE refresh: weather fetch failed ({e}), using hardcoded TLEs")
|
tracing::warn!("TLE refresh: weather fetch failed ({e}), using hardcoded TLEs")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
match fetch_and_merge_tles(CELESTRAK_HAM_URL).await {
|
match fetch_and_merge_tles(CELESTRAK_HAM_URL, SatCategory::Amateur).await {
|
||||||
Ok(n) => {
|
Ok(n) => {
|
||||||
tracing::info!("TLE refresh: loaded {n} amateur satellite TLEs from CelesTrak")
|
tracing::info!("TLE refresh: loaded {n} amateur satellite TLEs from CelesTrak")
|
||||||
}
|
}
|
||||||
@@ -290,7 +303,7 @@ pub fn spawn_tle_refresh_task() {
|
|||||||
|
|
||||||
loop {
|
loop {
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
match fetch_and_merge_tles(CELESTRAK_WEATHER_URL).await {
|
match fetch_and_merge_tles(CELESTRAK_WEATHER_URL, SatCategory::Weather).await {
|
||||||
Ok(n) => {
|
Ok(n) => {
|
||||||
tracing::info!("TLE refresh: updated {n} weather satellite TLEs from CelesTrak")
|
tracing::info!("TLE refresh: updated {n} weather satellite TLEs from CelesTrak")
|
||||||
}
|
}
|
||||||
@@ -298,7 +311,7 @@ pub fn spawn_tle_refresh_task() {
|
|||||||
tracing::warn!("TLE refresh: weather fetch failed ({e}), keeping previous TLEs")
|
tracing::warn!("TLE refresh: weather fetch failed ({e}), keeping previous TLEs")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
match fetch_and_merge_tles(CELESTRAK_HAM_URL).await {
|
match fetch_and_merge_tles(CELESTRAK_HAM_URL, SatCategory::Amateur).await {
|
||||||
Ok(n) => {
|
Ok(n) => {
|
||||||
tracing::info!("TLE refresh: updated {n} amateur satellite TLEs from CelesTrak")
|
tracing::info!("TLE refresh: updated {n} amateur satellite TLEs from CelesTrak")
|
||||||
}
|
}
|
||||||
@@ -372,6 +385,7 @@ fn compute_az_el(
|
|||||||
fn find_passes_for_sat(
|
fn find_passes_for_sat(
|
||||||
name: &str,
|
name: &str,
|
||||||
norad_id: u32,
|
norad_id: u32,
|
||||||
|
category: SatCategory,
|
||||||
line1: &str,
|
line1: &str,
|
||||||
line2: &str,
|
line2: &str,
|
||||||
obs_lat: f64,
|
obs_lat: f64,
|
||||||
@@ -432,6 +446,7 @@ fn find_passes_for_sat(
|
|||||||
passes.push(PassPrediction {
|
passes.push(PassPrediction {
|
||||||
satellite: name.to_string(),
|
satellite: name.to_string(),
|
||||||
norad_id,
|
norad_id,
|
||||||
|
category,
|
||||||
aos_ms,
|
aos_ms,
|
||||||
los_ms: t_ms,
|
los_ms: t_ms,
|
||||||
max_elevation_deg: (max_el * 10.0).round() / 10.0,
|
max_elevation_deg: (max_el * 10.0).round() / 10.0,
|
||||||
@@ -450,6 +465,7 @@ fn find_passes_for_sat(
|
|||||||
passes.push(PassPrediction {
|
passes.push(PassPrediction {
|
||||||
satellite: name.to_string(),
|
satellite: name.to_string(),
|
||||||
norad_id,
|
norad_id,
|
||||||
|
category,
|
||||||
aos_ms,
|
aos_ms,
|
||||||
los_ms: start_ms + window_ms,
|
los_ms: start_ms + window_ms,
|
||||||
max_elevation_deg: (max_el * 10.0).round() / 10.0,
|
max_elevation_deg: (max_el * 10.0).round() / 10.0,
|
||||||
@@ -488,6 +504,7 @@ pub fn compute_upcoming_passes(
|
|||||||
let passes = find_passes_for_sat(
|
let passes = find_passes_for_sat(
|
||||||
&entry.name,
|
&entry.name,
|
||||||
norad_id,
|
norad_id,
|
||||||
|
entry.category,
|
||||||
&entry.line1,
|
&entry.line1,
|
||||||
&entry.line2,
|
&entry.line2,
|
||||||
station_lat,
|
station_lat,
|
||||||
@@ -768,20 +785,21 @@ NOAA 19
|
|||||||
1 33591U 09005A 26085.50000000 .00000028 00000-0 20000-4 0 9997
|
1 33591U 09005A 26085.50000000 .00000028 00000-0 20000-4 0 9997
|
||||||
2 33591 99.1700 050.5000 0014000 100.0000 260.0000 14.12300000 8003
|
2 33591 99.1700 050.5000 0014000 100.0000 260.0000 14.12300000 8003
|
||||||
";
|
";
|
||||||
let tles = parse_tle_response(body);
|
let tles = parse_tle_response(body, SatCategory::Weather);
|
||||||
assert_eq!(tles.len(), 2);
|
assert_eq!(tles.len(), 2);
|
||||||
assert!(tles.contains_key(&25338));
|
assert!(tles.contains_key(&25338));
|
||||||
assert!(tles.contains_key(&33591));
|
assert!(tles.contains_key(&33591));
|
||||||
assert_eq!(tles[&25338].name, "NOAA 15");
|
assert_eq!(tles[&25338].name, "NOAA 15");
|
||||||
assert!(tles[&25338].line1.starts_with("1 25338"));
|
assert!(tles[&25338].line1.starts_with("1 25338"));
|
||||||
|
assert_eq!(tles[&25338].category, SatCategory::Weather);
|
||||||
assert_eq!(tles[&33591].name, "NOAA 19");
|
assert_eq!(tles[&33591].name, "NOAA 19");
|
||||||
assert!(tles[&33591].line2.starts_with("2 33591"));
|
assert!(tles[&33591].line2.starts_with("2 33591"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_tle_response_empty() {
|
fn test_parse_tle_response_empty() {
|
||||||
assert!(parse_tle_response("").is_empty());
|
assert!(parse_tle_response("", SatCategory::Other).is_empty());
|
||||||
assert!(parse_tle_response("not a tle\n").is_empty());
|
assert!(parse_tle_response("not a tle\n", SatCategory::Other).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -850,7 +868,17 @@ NOAA 19
|
|||||||
let start = 1774800000000_i64; // 2026-03-28
|
let start = 1774800000000_i64; // 2026-03-28
|
||||||
let window = 24 * 60 * 60 * 1000_i64;
|
let window = 24 * 60 * 60 * 1000_i64;
|
||||||
let (l1, l2) = hardcoded_tle(33591).unwrap();
|
let (l1, l2) = hardcoded_tle(33591).unwrap();
|
||||||
let passes = find_passes_for_sat("NOAA 19", 33591, l1, l2, 48.0, 11.0, start, window);
|
let passes = find_passes_for_sat(
|
||||||
|
"NOAA 19",
|
||||||
|
33591,
|
||||||
|
SatCategory::Weather,
|
||||||
|
l1,
|
||||||
|
l2,
|
||||||
|
48.0,
|
||||||
|
11.0,
|
||||||
|
start,
|
||||||
|
window,
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
passes.len() >= 2 && passes.len() <= 10,
|
passes.len() >= 2 && passes.len() <= 10,
|
||||||
"Expected 2-10 passes for NOAA-19 in 24h, got {}",
|
"Expected 2-10 passes for NOAA-19 in 24h, got {}",
|
||||||
|
|||||||
Reference in New Issue
Block a user