diff --git a/Cargo.lock b/Cargo.lock index 0274d6d..810f6ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/src/trx-core/Cargo.toml b/src/trx-core/Cargo.toml index e9ae45a..20408d9 100644 --- a/src/trx-core/Cargo.toml +++ b/src/trx-core/Cargo.toml @@ -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"] } diff --git a/src/trx-core/src/geo.rs b/src/trx-core/src/geo.rs index 06d5317..d3c53e8 100644 --- a/src/trx-core/src/geo.rs +++ b/src/trx-core/src/geo.rs @@ -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>> = 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, } -/// 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 { 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 { + 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::() { + 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 { + 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(), diff --git a/src/trx-server/src/main.rs b/src/trx-server/src/main.rs index 65ecae0..7d44a6b 100644 --- a/src/trx-server/src/main.rs +++ b/src/trx-server/src/main.rs @@ -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();