[feat](trx-core): add periodic TLE refresh from CelesTrak

Fetch fresh weather satellite TLEs from CelesTrak on startup and then
once every 24 hours. The dynamic TLE store is checked first in
tle_for_satellite(), falling back to the existing hardcoded TLEs when
the fetch has not yet completed or fails.

- Add global TLE_STORE (RwLock<HashMap<norad_id, (line1, line2)>>)
- Add parse_tle_response() to parse 3-line TLE format
- Add refresh_tles_from_celestrak() async fetch + store update
- Add spawn_tle_refresh_task() for startup + daily refresh loop
- Refactor tle_for_satellite() into norad_id lookup + store check
- Spawn refresh task in trx-server alongside wxsat decoder tasks
- Add reqwest (rustls-tls) dependency to trx-core

https://claude.ai/code/session_01RB19i93dnemDYLcfrhyhqc
Signed-off-by: Claude <noreply@anthropic.com>
This commit is contained in:
Claude
2026-03-28 11:34:44 +00:00
committed by Stan Grams
parent 929f1d3fab
commit 27117a8de5
4 changed files with 608 additions and 37 deletions
Generated
+397 -3
View File
@@ -40,7 +40,7 @@ dependencies = [
"foldhash",
"futures-core",
"h2",
"http",
"http 0.2.12",
"httparse",
"httpdate",
"itoa",
@@ -76,7 +76,7 @@ checksum = "13d324164c51f63867b57e73ba5936ea151b8a41a1d23d1031eeb9f70d0236f8"
dependencies = [
"bytestring",
"cfg-if",
"http",
"http 0.2.12",
"regex",
"regex-lite",
"serde",
@@ -326,6 +326,12 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "audiopus_sys"
version = "0.2.2"
@@ -1011,8 +1017,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@@ -1022,9 +1030,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -1057,7 +1067,7 @@ dependencies = [
"futures-core",
"futures-sink",
"futures-util",
"http",
"http 0.2.12",
"indexmap",
"slab",
"tokio",
@@ -1109,6 +1119,39 @@ dependencies = [
"itoa",
]
[[package]]
name = "http"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "http-body"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
"http 1.4.0",
]
[[package]]
name = "http-body-util"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
"bytes",
"futures-core",
"http 1.4.0",
"http-body",
"pin-project-lite",
]
[[package]]
name = "httparse"
version = "1.10.1"
@@ -1121,6 +1164,67 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
dependencies = [
"atomic-waker",
"bytes",
"futures-channel",
"futures-core",
"http 1.4.0",
"http-body",
"httparse",
"itoa",
"pin-project-lite",
"pin-utils",
"smallvec",
"tokio",
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"http 1.4.0",
"hyper",
"hyper-util",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"base64",
"bytes",
"futures-channel",
"futures-util",
"http 1.4.0",
"http-body",
"hyper",
"ipnet",
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.6.1",
"tokio",
"tower-service",
"tracing",
]
[[package]]
name = "iana-time-zone"
version = "0.1.65"
@@ -1288,6 +1392,22 @@ dependencies = [
"mach2",
]
[[package]]
name = "ipnet"
version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "iri-string"
version = "0.7.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb"
dependencies = [
"memchr",
"serde",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@@ -1445,6 +1565,12 @@ version = "0.4.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mach2"
version = "0.4.3"
@@ -1831,6 +1957,61 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash 2.1.1",
"rustls",
"socket2 0.6.1",
"thiserror 2.0.17",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.2",
"ring",
"rustc-hash 2.1.1",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.17",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.6.1",
"tracing",
"windows-sys 0.60.2",
]
[[package]]
name = "quote"
version = "1.0.42"
@@ -1975,6 +2156,58 @@ version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64",
"bytes",
"futures-core",
"http 1.4.0",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -2023,6 +2256,41 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -2268,6 +2536,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.111"
@@ -2279,6 +2553,15 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
dependencies = [
"futures-core",
]
[[package]]
name = "synstructure"
version = "0.13.2"
@@ -2393,6 +2676,21 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.48.0"
@@ -2421,6 +2719,16 @@ dependencies = [
"syn",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-serial"
version = "5.4.5"
@@ -2531,6 +2839,51 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "tower"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
"pin-project-lite",
"sync_wrapper",
"tokio",
"tower-layer",
"tower-service",
]
[[package]]
name = "tower-http"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [
"bitflags 2.10.0",
"bytes",
"futures-util",
"http 1.4.0",
"http-body",
"iri-string",
"pin-project-lite",
"tower",
"tower-layer",
"tower-service",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
[[package]]
name = "tower-service"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tracing"
version = "0.1.43"
@@ -2718,6 +3071,7 @@ name = "trx-core"
version = "0.1.0"
dependencies = [
"flate2",
"reqwest",
"serde",
"serde_json",
"sgp4",
@@ -2897,6 +3251,12 @@ dependencies = [
"trx-core",
]
[[package]]
name = "try-lock"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typenum"
version = "1.19.0"
@@ -2930,6 +3290,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.7"
@@ -2988,6 +3354,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "want"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
dependencies = [
"try-lock",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@@ -3115,6 +3490,25 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webpki-roots"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "winapi"
version = "0.3.9"
+1
View File
@@ -15,3 +15,4 @@ tracing = { workspace = true }
flate2 = { workspace = true }
uuid = { workspace = true }
sgp4 = "2"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
+207 -34
View File
@@ -9,7 +9,10 @@
//! and receiver station coordinates.
use sgp4::{Constants, Elements, MinutesSinceEpoch};
use std::collections::HashMap;
use std::f64::consts::PI;
use std::sync::RwLock;
use std::time::Duration;
/// Half-swath width in km for NOAA APT / Meteor LRPT imagery.
const SWATH_HALF_WIDTH_KM: f64 = 1400.0;
@@ -17,6 +20,18 @@ const SWATH_HALF_WIDTH_KM: f64 = 1400.0;
/// Earth radius in km (WGS84 mean).
const EARTH_RADIUS_KM: f64 = 6371.0;
/// CelesTrak weather satellite TLE endpoint.
const CELESTRAK_WEATHER_URL: &str =
"https://celestrak.org/NORAD/elements/gp.php?GROUP=weather&FORMAT=tle";
/// How often to refresh TLEs after the initial fetch (24 hours).
const TLE_REFRESH_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
/// Global store for dynamically-fetched TLE data.
///
/// Keys are NORAD catalog numbers; values are `(line1, line2)` strings.
static TLE_STORE: RwLock<Option<HashMap<u32, (String, String)>>> = RwLock::new(None);
/// Geographic bounds for a satellite image overlay: `[south, west, north, east]`.
pub type GeoBounds = [f64; 4];
@@ -32,46 +47,171 @@ pub struct PassGeo {
pub ground_track: Vec<TrackPoint>,
}
/// Hardcoded TLE data for active weather satellites.
///
/// These are recent-epoch TLEs. SGP4 propagation from stale TLEs still
/// gives sub-degree accuracy for image overlay purposes (drift ~0.1 deg/week).
fn tle_for_satellite(name: &str) -> Option<(&str, &str)> {
/// Map satellite name patterns to NORAD catalog numbers.
fn norad_id_for_satellite(name: &str) -> Option<u32> {
let upper = name.to_uppercase();
// Match by common satellite names from the decoder telemetry output.
//
// TLE lines must be exactly 69 characters with valid mod-10 checksums.
// These are approximate recent-epoch elements for overlay purposes.
if upper.contains("NOAA") && upper.contains("15") {
Some((
"1 25338U 98030A 26084.50000000 .00000045 00000-0 36000-4 0 9998",
"2 25338 98.7285 114.5200 0010150 45.0000 315.1500 14.25955000 4001",
))
Some(25338)
} else if upper.contains("NOAA") && upper.contains("18") {
Some((
"1 28654U 05018A 26084.50000000 .00000036 00000-0 28000-4 0 9997",
"2 28654 99.0400 162.3000 0013800 290.0000 70.0000 14.12500000 1005",
))
Some(28654)
} else if upper.contains("NOAA") && upper.contains("19") {
Some((
"1 33591U 09005A 26084.50000000 .00000028 00000-0 20000-4 0 9996",
"2 33591 99.1700 050.5000 0014000 100.0000 260.0000 14.12300000 8002",
))
} else if upper.contains("METEOR") && (upper.contains("2-3") || upper.contains("N2-3") || upper.contains("2_3")) {
Some((
"1 57166U 23091A 26084.50000000 .00000020 00000-0 16000-4 0 9998",
"2 57166 98.7700 170.0000 0005000 90.0000 270.0000 14.23700000 1502",
))
} else if upper.contains("METEOR") && (upper.contains("2-4") || upper.contains("N2-4") || upper.contains("2_4")) {
Some((
"1 59051U 24044A 26084.50000000 .00000018 00000-0 14000-4 0 9997",
"2 59051 98.7700 200.0000 0005000 80.0000 280.0000 14.23700000 1006",
))
Some(33591)
} else if upper.contains("METEOR")
&& (upper.contains("2-3") || upper.contains("N2-3") || upper.contains("2_3"))
{
Some(57166)
} else if upper.contains("METEOR")
&& (upper.contains("2-4") || upper.contains("N2-4") || upper.contains("2_4"))
{
Some(59051)
} else {
None
}
}
/// Hardcoded fallback TLE data for active weather satellites.
///
/// These are recent-epoch TLEs. SGP4 propagation from stale TLEs still
/// gives sub-degree accuracy for image overlay purposes (drift ~0.1 deg/week).
fn hardcoded_tle(norad_id: u32) -> Option<(&'static str, &'static str)> {
match norad_id {
25338 => Some((
"1 25338U 98030A 26084.50000000 .00000045 00000-0 36000-4 0 9998",
"2 25338 98.7285 114.5200 0010150 45.0000 315.1500 14.25955000 4001",
)),
28654 => Some((
"1 28654U 05018A 26084.50000000 .00000036 00000-0 28000-4 0 9997",
"2 28654 99.0400 162.3000 0013800 290.0000 70.0000 14.12500000 1005",
)),
33591 => Some((
"1 33591U 09005A 26084.50000000 .00000028 00000-0 20000-4 0 9996",
"2 33591 99.1700 050.5000 0014000 100.0000 260.0000 14.12300000 8002",
)),
57166 => Some((
"1 57166U 23091A 26084.50000000 .00000020 00000-0 16000-4 0 9998",
"2 57166 98.7700 170.0000 0005000 90.0000 270.0000 14.23700000 1502",
)),
59051 => Some((
"1 59051U 24044A 26084.50000000 .00000018 00000-0 14000-4 0 9997",
"2 59051 98.7700 200.0000 0005000 80.0000 280.0000 14.23700000 1006",
)),
_ => None,
}
}
/// Look up TLE lines for a satellite by name.
///
/// Checks the dynamic [`TLE_STORE`] first (populated by [`spawn_tle_refresh_task`]),
/// falling back to hardcoded TLEs if no fresh data is available.
fn tle_for_satellite(name: &str) -> Option<(String, String)> {
let norad_id = norad_id_for_satellite(name)?;
// Try dynamic store first.
if let Ok(guard) = TLE_STORE.read() {
if let Some(store) = guard.as_ref() {
if let Some((l1, l2)) = store.get(&norad_id) {
return Some((l1.clone(), l2.clone()));
}
}
}
// Fall back to hardcoded.
hardcoded_tle(norad_id).map(|(l1, l2)| (l1.to_string(), l2.to_string()))
}
// ---------------------------------------------------------------------------
// CelesTrak TLE refresh
// ---------------------------------------------------------------------------
/// Parse a CelesTrak 3-line TLE response into a map of NORAD ID → (line1, line2).
fn parse_tle_response(body: &str) -> HashMap<u32, (String, String)> {
let mut result = HashMap::new();
let lines: Vec<&str> = body.lines().map(|l| l.trim_end()).collect();
let mut i = 0;
while i + 2 < lines.len() {
let line1 = lines[i + 1];
let line2 = lines[i + 2];
// Validate TLE line markers
if line1.starts_with("1 ") && line2.starts_with("2 ") {
// Extract NORAD catalog number from line 1 columns 2-6
if let Ok(norad_id) = line1[2..7].trim().parse::<u32>() {
result.insert(norad_id, (line1.to_string(), line2.to_string()));
}
}
i += 3;
}
result
}
/// Fetch fresh TLE data from CelesTrak and update the global store.
///
/// Returns the number of TLEs loaded, or an error description.
pub async fn refresh_tles_from_celestrak() -> Result<usize, String> {
let response = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.map_err(|e| format!("HTTP client error: {e}"))?
.get(CELESTRAK_WEATHER_URL)
.send()
.await
.map_err(|e| format!("CelesTrak fetch failed: {e}"))?;
if !response.status().is_success() {
return Err(format!("CelesTrak returned HTTP {}", response.status()));
}
let body = response
.text()
.await
.map_err(|e| format!("Failed to read CelesTrak response: {e}"))?;
let tles = parse_tle_response(&body);
let count = tles.len();
if count == 0 {
return Err("CelesTrak response contained no valid TLEs".to_string());
}
match TLE_STORE.write() {
Ok(mut guard) => *guard = Some(tles),
Err(e) => {
// Recover from poisoned lock
let mut guard = e.into_inner();
*guard = Some(parse_tle_response(&body));
}
}
Ok(count)
}
/// Spawn a background task that fetches TLEs from CelesTrak on start and
/// then refreshes once per day.
///
/// The task runs until the process exits. Fetch failures are logged but
/// do not stop the periodic refresh — hardcoded fallback TLEs remain usable.
pub fn spawn_tle_refresh_task() {
tokio::spawn(async {
// Initial fetch at startup.
match refresh_tles_from_celestrak().await {
Ok(n) => tracing::info!("TLE refresh: loaded {n} satellite TLEs from CelesTrak"),
Err(e) => tracing::warn!("TLE refresh: initial fetch failed ({e}), using hardcoded TLEs"),
}
// Periodic refresh every 24 hours.
let mut interval = tokio::time::interval(TLE_REFRESH_INTERVAL);
// The first tick fires immediately; skip it since we just fetched.
interval.tick().await;
loop {
interval.tick().await;
match refresh_tles_from_celestrak().await {
Ok(n) => tracing::info!("TLE refresh: updated {n} satellite TLEs from CelesTrak"),
Err(e) => tracing::warn!("TLE refresh: fetch failed ({e}), keeping previous TLEs"),
}
}
});
}
/// Compute geographic bounds and ground track for a satellite pass.
///
/// Returns `None` if the satellite is unknown or propagation fails.
@@ -88,8 +228,7 @@ pub fn compute_pass_geo(
Some(satellite.to_string()),
line1.as_bytes(),
line2.as_bytes(),
)
.ok()?;
).ok()?;
let constants = Constants::from_elements(&elements).ok()?;
@@ -300,6 +439,40 @@ mod tests {
assert!(tle_for_satellite("Unknown Sat").is_none());
}
#[test]
fn test_norad_id_mapping() {
assert_eq!(norad_id_for_satellite("NOAA-15"), Some(25338));
assert_eq!(norad_id_for_satellite("NOAA-18"), Some(28654));
assert_eq!(norad_id_for_satellite("NOAA-19"), Some(33591));
assert_eq!(norad_id_for_satellite("Meteor-M N2-3"), Some(57166));
assert_eq!(norad_id_for_satellite("Meteor-M N2-4"), Some(59051));
assert_eq!(norad_id_for_satellite("Unknown"), None);
}
#[test]
fn test_parse_tle_response() {
let body = "\
NOAA 15
1 25338U 98030A 26085.50000000 .00000045 00000-0 36000-4 0 9999
2 25338 98.7285 114.5200 0010150 45.0000 315.1500 14.25955000 4002
NOAA 19
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
";
let tles = parse_tle_response(body);
assert_eq!(tles.len(), 2);
assert!(tles.contains_key(&25338));
assert!(tles.contains_key(&33591));
assert!(tles[&25338].0.starts_with("1 25338"));
assert!(tles[&33591].1.starts_with("2 33591"));
}
#[test]
fn test_parse_tle_response_empty() {
assert!(parse_tle_response("").is_empty());
assert!(parse_tle_response("not a tle\n").is_empty());
}
#[test]
fn test_compute_pass_geo_noaa19() {
// Simulate a ~12 minute pass
@@ -335,7 +508,7 @@ mod tests {
#[test]
fn test_elements_epoch_ms() {
// Parse a TLE and verify the epoch converts to a reasonable timestamp
let (line1, line2) = tle_for_satellite("NOAA-19").unwrap();
let (line1, line2) = hardcoded_tle(33591).unwrap();
let elements = Elements::from_tle(
Some("NOAA-19".to_string()),
line1.as_bytes(),
+3
View File
@@ -794,6 +794,9 @@ fn spawn_rig_audio_stack(
}
}));
// Start periodic TLE refresh from CelesTrak (on start + once/day).
trx_core::geo::spawn_tle_refresh_task();
// Spawn weather satellite APT decoder task
let wxsat_pcm_rx = pcm_tx.subscribe();
let wxsat_state_rx = state_rx.clone();