From 5f9f43b5bb55c6c70350d45a716258e19acbc735 Mon Sep 17 00:00:00 2001 From: Michael R Wheeley Date: Tue, 19 May 2026 15:28:29 -0700 Subject: [PATCH 01/26] squash merge satellite OMM data download --- .env.example | 10 + package-lock.json | 119 +++- package.json | 3 + server/config.js | 17 + server/routes/satellites-tracked.js | 133 ++-- server/routes/satellites.js | 845 ++++++++++++++++-------- server/utils/statemachine.js | 3 +- src/hooks/useSatellites.js | 86 ++- src/plugins/layers/useSatelliteLayer.js | 27 +- src/utils/orbit.js | 5 +- 10 files changed, 833 insertions(+), 415 deletions(-) diff --git a/.env.example b/.env.example index 2a4cfba4..8e4eae68 100644 --- a/.env.example +++ b/.env.example @@ -163,6 +163,16 @@ LAYOUT=modern # QRZ_USERNAME=your_callsign # QRZ_PASSWORD=your_qrz_password +# Satellite data sources (effect server only) +# CelesTrak.org data source is enabled by default, no username or password is needed. +# CelesTrak can be disabled by setting CELESTRAK_ENABLED=false, but it is recommended to keep it enabled as a backup source. +CELESTRAK_ENABLED=true +# Space-Track.org is disabled by default, but can be enabled by providing a valid username and password. +# To enable both username and password must be fields must be active. +# When enabled Space-Track.org becomes primary data source for satellite data with CelesTrak as backup. +#SPACE_TRACK_USERNAME=your_username +#SPACE_TRACK_PASSWORD=your_password + # =========================================== # FEATURE TOGGLES # =========================================== diff --git a/package-lock.json b/package-lock.json index 1e7667c8..192e0ce9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,9 @@ "license": "MIT", "dependencies": { "axios": "^1.6.2", + "axios-cookiejar-support": "^6.0.5", "compression": "^1.7.4", + "convert-csv-to-json": "^4.38.0", "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.21.2", @@ -26,6 +28,7 @@ "react-i18next": "^16.5.4", "rss-parser": "^3.13.0", "satellite.js": "^6.0.0", + "tough-cookie": "^6.0.1", "ws": "^8.14.2" }, "devDependencies": { @@ -2936,6 +2939,25 @@ "proxy-from-env": "^2.1.0" } }, + "node_modules/axios-cookiejar-support": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/axios-cookiejar-support/-/axios-cookiejar-support-6.0.5.tgz", + "integrity": "sha512-ldPOQCJWB0ipugkTNVB8QRl/5L2UgfmVNVQtS9en1JQJ1wW588PqAmymnwmmgc12HLDzDtsJ28xE2ppj4rD4ng==", + "license": "MIT", + "dependencies": { + "http-cookie-agent": "^7.0.3" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/3846masa" + }, + "peerDependencies": { + "axios": ">=0.20.0", + "tough-cookie": ">=4.0.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3818,6 +3840,12 @@ "node": ">= 0.6" } }, + "node_modules/convert-csv-to-json": { + "version": "4.39.0", + "resolved": "https://registry.npmjs.org/convert-csv-to-json/-/convert-csv-to-json-4.39.0.tgz", + "integrity": "sha512-OgRRwS9XTB+sV0NrzsVNWtiL1BA1RlwqfjwxvKa1qRPFMBK5+AlaZQNliX/2m/mUEuv5LHh9hqUJ9C+NgvZhXA==", + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -5639,6 +5667,39 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/http-cookie-agent": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/http-cookie-agent/-/http-cookie-agent-7.0.4.tgz", + "integrity": "sha512-BF9iTnice7+LI4tHB8Bm0nFnp+J+bAY5Cll53uEa9NIFQCM7noUyWbImqJhB+j0drmJHvE/Wr6o+8FXq7IVYeA==", + "license": "MIT", + "dependencies": { + "agent-base": "^9.0.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/3846masa" + }, + "peerDependencies": { + "tough-cookie": "^4.0.0 || ^5.0.0 || ^6.0.0", + "undici": "^7.0.0" + }, + "peerDependenciesMeta": { + "undici": { + "optional": true + } + } + }, + "node_modules/http-cookie-agent/node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -6114,6 +6175,39 @@ } } }, + "node_modules/jsdom/node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/jsdom/node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom/node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -9215,23 +9309,21 @@ } }, "node_modules/tldts": { - "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", - "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", - "dev": true, + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", + "integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==", "license": "MIT", "dependencies": { - "tldts-core": "^6.1.86" + "tldts-core": "^7.0.30" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", - "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", - "dev": true, + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz", + "integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==", "license": "MIT" }, "node_modules/tmp": { @@ -9287,13 +9379,12 @@ } }, "node_modules/tough-cookie": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", - "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", - "dev": true, + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", "license": "BSD-3-Clause", "dependencies": { - "tldts": "^6.1.32" + "tldts": "^7.0.5" }, "engines": { "node": ">=16" diff --git a/package.json b/package.json index 72035f78..440000e6 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,9 @@ }, "dependencies": { "axios": "^1.6.2", + "axios-cookiejar-support": "^6.0.5", "compression": "^1.7.4", + "convert-csv-to-json": "^4.38.0", "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.21.2", @@ -40,6 +42,7 @@ "react-i18next": "^16.5.4", "rss-parser": "^3.13.0", "satellite.js": "^6.0.0", + "tough-cookie": "^6.0.1", "ws": "^8.14.2" }, "devDependencies": { diff --git a/server/config.js b/server/config.js index ea0b350e..9a7685db 100644 --- a/server/config.js +++ b/server/config.js @@ -118,6 +118,23 @@ const CONFIG = { ? parseFloat(process.env.DX_LONGITUDE) : (jsonConfig.defaultDX?.lon ?? -0.1278), + // Satellites configuration + satellites: { + celestrak: { + get enabled() { + return !(process.env.CELESTRAK_ENABLED && process.env.CELESTRAK_ENABLED === 'false'); + }, + }, + spaceTrack: { + // (do not expose usernames/passwords to frontend) + _username: process.env.SPACE_TRACK_USERNAME || '', + _password: process.env.SPACE_TRACK_PASSWORD || '', + get enabled() { + return this._username.length > 0 && this._password.length > 0; + }, + }, + }, + // Feature toggles showSatellites: process.env.SHOW_SATELLITES !== 'false' && jsonConfig.features?.showSatellites !== false, showPota: process.env.SHOW_POTA !== 'false' && jsonConfig.features?.showPOTA !== false, diff --git a/server/routes/satellites-tracked.js b/server/routes/satellites-tracked.js index a2d4a951..b0d011a6 100644 --- a/server/routes/satellites-tracked.js +++ b/server/routes/satellites-tracked.js @@ -1,4 +1,26 @@ // Curated list of active ham radio and amateur-accessible satellites +// +// ========= +// CelesTrak +// ========= +// +// If the data_source is set to one of the following then an attempt will be made to refresh using a CelesTrak group query. +// celestrak_amateur +// celestrak_weather +// Note that other group names are not supported since it is not efficient to download a large group only to utilize a few entries. +// +// Subsequently for satellites that remain data stale but have data_source of form celestrak* then individual download will be attempted. +// +// If the data_source field is missing or is not of the form celestrak* then no attempt will be made to assess for refresh using CelesTrak, +// however if that satellite appears coincidentally in another CelesTrak group search then it still may get updated. +// +// =========== +// Space-Track +// =========== +// It is not necessary to set data_source with Space-Track, if the feature is enabled (see .env) then all satellites with stale data +// will be checked for updates. +// +// // Last audited: May 9, 2026 // // REMOVED (dead/decayed/not ham): @@ -43,11 +65,17 @@ // removed TEVEL satellites, added TEVEL2 satellites // remove (deactivated) GOES-13 // remove (decayed) SO-124, CAS-4A, CAS-4B, EO-88, XW-2A, XW-2B, XW-2C, XW-2F +// +// May 18, 2026 +// added #41866 GOES-16 celestrak_weather +// added #55506 ELEKTRO-L4 celestrak_active +// added #67756 ELEKTRO-L5 celestrak_active +// removed #53106(payload) = #53109(rocket body) = IO-117(Greencube), satellite decommissioned const HAM_SATELLITES = { // ── High Priority — Popular FM Satellites ────────────────────── ISS: { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 25544, name: 'ISS (ZARYA)', color: '#00ffff', @@ -58,7 +86,7 @@ const HAM_SATELLITES = { tone: '67.0 Hz', }, 'SO-50': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 27607, name: 'SO-50', color: '#00ff00', @@ -70,7 +98,7 @@ const HAM_SATELLITES = { armTone: '74.4 Hz', }, 'AO-91': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 43017, name: 'AO-91 (Fox-1B)', color: '#ff6600', @@ -81,7 +109,7 @@ const HAM_SATELLITES = { tone: '67.0 Hz', }, 'AO-123': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 61781, name: 'AO-123 (ASRTU-1)', color: '#ff3399', @@ -92,7 +120,7 @@ const HAM_SATELLITES = { tone: '67.0 Hz', }, 'SO-125': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 63492, name: 'SO-125 (HADES-ICM)', color: '#ff55bb', @@ -103,7 +131,7 @@ const HAM_SATELLITES = { tone: '67.0 Hz', }, 'QMR-KWT-2': { - // CelesTrak group: active + data_source: 'celestrak_active', norad: 67291, name: 'QMR-KWT-2', color: '#ff88dd', @@ -114,7 +142,7 @@ const HAM_SATELLITES = { tone: '67.0 Hz', }, 'PO-101': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 43678, name: 'PO-101 (DIWATA-2B)', color: '#cc66ff', @@ -126,8 +154,15 @@ const HAM_SATELLITES = { }, // ── Weather Satellites — GOES & METEOR ───────────────────────── + 'GOES-16': { + data_source: 'celestrak_weather', + norad: 41866, + name: 'GOES-16', + color: '#66ff66', + priority: 1, + }, 'GOES-18': { - // CelesTrak group: weather + data_source: 'celestrak_weather', norad: 51850, name: 'GOES-18', color: '#66ff66', @@ -137,7 +172,7 @@ const HAM_SATELLITES = { grbFrequency: '1686.600 MHz', }, 'GOES-19': { - // CelesTrak group: weather + data_source: 'celestrak_weather', norad: 60133, name: 'GOES-19', color: '#33cc33', @@ -147,7 +182,7 @@ const HAM_SATELLITES = { grbFrequency: '1686.600 MHz', }, 'METOP-B': { - // CelesTrak group: weather + data_source: 'celestrak_weather', norad: 38771, name: 'MetOp-B', color: '#FF6600', @@ -156,7 +191,7 @@ const HAM_SATELLITES = { hrptFrequency: '1701.300 MHz', }, 'METOP-C': { - // CelesTrak group: weather + data_source: 'celestrak_weather', norad: 43689, name: 'MetOp-C', color: '#FF8800', @@ -165,7 +200,7 @@ const HAM_SATELLITES = { hrptFrequency: '1701.300 MHz', }, 'METEOR-M2-3': { - // CelesTrak group: weather + data_source: 'celestrak_weather', norad: 57166, name: 'METEOR M2-3', color: '#FF0000', @@ -175,7 +210,7 @@ const HAM_SATELLITES = { hrptFrequency: '1700.000 MHz', }, 'METEOR-M2-4': { - // CelesTrak group: weather + data_source: 'celestrak_weather', norad: 59051, name: 'METEOR M2-4', color: '#FF0000', @@ -187,7 +222,7 @@ const HAM_SATELLITES = { // ── Weather Satellites — Geostationary (non-GOES) ───────────── 'EWS-G2': { - // CelesTrak group: weather + data_source: 'celestrak_weather', norad: 36411, name: 'EWS-G2 (GOES-15)', color: '#0044cc', @@ -197,7 +232,7 @@ const HAM_SATELLITES = { sdFrequency: '1676.000 MHz', }, 'ELEKTRO-L2': { - // CelesTrak group: weather + data_source: 'celestrak_weather', norad: 41105, name: 'ELEKTRO-L2', color: '#ffcc00', @@ -206,7 +241,7 @@ const HAM_SATELLITES = { frequency: '1691.000 MHz', }, 'ELEKTRO-L3': { - // CelesTrak group: active + data_source: 'celestrak_active', norad: 44903, name: 'ELEKTRO-L3', color: '#ff9900', @@ -214,8 +249,22 @@ const HAM_SATELLITES = { mode: 'HRIT/LRIT', frequency: '1691.000 MHz', }, + 'ELEKTRO-L4': { + data_source: 'celestrak_active', + norad: 55506, + name: 'ELEKTRO-L4', + color: '#ff9900', + priority: 2, + }, + 'ELEKTRO-L5': { + data_source: 'celestrak_active', + norad: 67756, + name: 'ELEKTRO-L5', + color: '#ff9900', + priority: 2, + }, 'GK-2A': { - // CelesTrak group: weather + data_source: 'celestrak_weather', norad: 43823, name: 'GK-2A', color: '#ff33cc', @@ -224,7 +273,7 @@ const HAM_SATELLITES = { frequency: '1692.140 MHz', }, 'HIMAWARI-9': { - // CelesTrak group: weather + data_source: 'celestrak_weather', norad: 41836, name: 'HIMAWARI-9', color: '#9900cc', @@ -235,7 +284,7 @@ const HAM_SATELLITES = { // ── Weather Satellites — Polar (X-Band) ─────────────────────── 'NOAA-20': { - // CelesTrak group: weather + data_source: 'celestrak_weather', norad: 43013, name: 'NOAA-20', color: '#00ccff', @@ -244,7 +293,7 @@ const HAM_SATELLITES = { frequency: '7812.000 MHz', }, 'NOAA-21': { - // CelesTrak group: weather + data_source: 'celestrak_weather', norad: 54234, name: 'NOAA-21', color: '#0099ff', @@ -255,7 +304,7 @@ const HAM_SATELLITES = { // ── Linear Transponder Satellites ────────────────────────────── 'RS-44': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 44909, name: 'RS-44 (DOSAAF)', color: '#ff0066', @@ -265,7 +314,7 @@ const HAM_SATELLITES = { uplink: '145.935 - 145.995 MHz', }, 'QO-100': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 43700, name: "QO-100 (Es'hail-2)", color: '#ffff00', @@ -275,7 +324,7 @@ const HAM_SATELLITES = { uplink: '2400.050 - 2400.300 MHz', }, 'AO-7': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 7530, name: 'AO-7', color: '#ffcc00', @@ -285,7 +334,7 @@ const HAM_SATELLITES = { uplink: '432.125 - 432.175 MHz', }, 'FO-29': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 24278, name: 'FO-29 (JAS-2)', color: '#ff6699', @@ -295,7 +344,7 @@ const HAM_SATELLITES = { uplink: '145.900 - 146.000 MHz', }, 'JO-97': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 43803, name: 'JO-97 (JY1Sat)', color: '#cc99ff', @@ -305,7 +354,7 @@ const HAM_SATELLITES = { uplink: '435.100 - 435.120 MHz', }, 'AO-73': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 39444, name: 'AO-73 (FUNcube-1)', color: '#ffcc66', @@ -317,7 +366,7 @@ const HAM_SATELLITES = { // ── CAS (Chinese Amateur Satellites) ─────────────────────────── 'CAS-6': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 44881, name: 'CAS-6 (TO-108)', color: '#cc66ff', @@ -329,7 +378,7 @@ const HAM_SATELLITES = { // ── Digipeaters ──────────────────────────────────────────────── 'IO-86': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 40931, name: 'IO-86 (LAPAN-A2/ORARI)', color: '#33ccaa', @@ -338,76 +387,66 @@ const HAM_SATELLITES = { downlink: '145.825 MHz', uplink: '145.825 MHz', }, - 'IO-117': { - // CelesTrak group: satnogs - norad: 53106, - name: 'IO-117 (GreenCube)', - color: '#00ff99', - priority: 2, - mode: 'Digipeater', - downlink: '435.310 MHz', - uplink: '435.310 MHz', - }, // ── TEVEL2 Constellation — activated periodically ─────────────── 'TEVEL2-1': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 63217, name: 'TEVEL2-1', color: '#66ccff', priority: 3, }, 'TEVEL2-2': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 63219, name: 'TEVEL2-2', color: '#66ccff', priority: 3, }, 'TEVEL2-3': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 63218, name: 'TEVEL2-3', color: '#66ccff', priority: 3, }, 'TEVEL2-4': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 63213, name: 'TEVEL2-4', color: '#66ccff', priority: 3, }, 'TEVEL2-5': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 63214, name: 'TEVEL2-5', color: '#66ccff', priority: 3, }, 'TEVEL2-6': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 63215, name: 'TEVEL2-6', color: '#66ccff', priority: 3, }, 'TEVEL2-7': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 63238, name: 'TEVEL2-7', color: '#66ccff', priority: 3, }, 'TEVEL2-8': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 63239, name: 'TEVEL2-8', color: '#66ccff', priority: 3, }, 'TEVEL2-9': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 63237, name: 'TEVEL2-9', color: '#66ccff', diff --git a/server/routes/satellites.js b/server/routes/satellites.js index c764d773..07cbd5d9 100644 --- a/server/routes/satellites.js +++ b/server/routes/satellites.js @@ -1,22 +1,28 @@ /** - * Satellite TLE tracking routes. - * Lines ~7624-8178 of original server.js + * Satellite TLE / OMM tracking routes. */ const fs = require('fs'); const path = require('path'); const satellitesTracked = require('./satellites-tracked'); +const { normalizeJsonTree } = require('../utils/normalize'); +const { MutexCounter } = require('../utils/mutex'); +const { StateMachine } = require('../utils/statemachine'); +const csvToJson = require('convert-csv-to-json'); +const wrapper = require('axios-cookiejar-support'); +const CookieJar = require('tough-cookie'); +const axios = require('axios'); module.exports = function (app, ctx) { - const { fetch, logDebug, logInfo, logWarn, logErrorOnce, APP_VERSION, ROOT_DIR } = ctx; + const { fetch, CONFIG, logDebug, logInfo, logWarn, logError, logErrorOnce, APP_VERSION, ROOT_DIR } = ctx; // ============================================ // SATELLITE TRACKING API // ============================================ // Load satellite database from satellites.json (editable by contributors) - // Falls back to hardcoded list if file not found - function loadSatellitesJson() { + // Note: later will fall back to hardcoded list if JSON file not found + const loadSatellitesJson = () => { const jsonPaths = [ path.join(ROOT_DIR, 'public', 'data', 'satellites.json'), path.join(ROOT_DIR, 'data', 'satellites.json'), @@ -30,33 +36,29 @@ module.exports = function (app, ctx) { return data.satellites; } } - } catch (e) { - logWarn(`[Satellites] Failed to load ${p}: ${e.message}`); + } catch (ex) { + logWarn(`[Satellites] Failed to load ${p}: ${ex.message}`); } } return null; - } - - // Try JSON file first, fall back to hardcoded - const jsonSatellites = loadSatellitesJson(); + }; // retrieve list of tracked satellites from separate file satellites-tracked.js const HAM_SATELLITES = satellitesTracked.HAM_SATELLITES; - // Use satellites.json data if available, merging radio metadata into hardcoded entries - // JSON file is the source of truth for radio data (downlink, uplink, tone, notes) - // Hardcoded entries are the fallback for NORAD IDs and basic info + // Load satellite database from satellites.json if it exists, and then merge it with the hard-coded HAM_SATELLITES allowing JSON to take precedence for any overlapping entries. + // Maintainers can override data while still relying on hard-coded defaults. + const jsonSatellites = loadSatellitesJson(); if (jsonSatellites) { for (const [key, jsonSat] of Object.entries(jsonSatellites)) { if (HAM_SATELLITES[key]) { - // Merge: JSON radio metadata into existing entry Object.assign(HAM_SATELLITES[key], { + // for each key field, use JSON value if present, otherwise keep existing HAM_SATELLITES value downlink: jsonSat.downlink || HAM_SATELLITES[key].downlink || '', uplink: jsonSat.uplink || HAM_SATELLITES[key].uplink || '', tone: jsonSat.tone || HAM_SATELLITES[key].tone || '', beacon: jsonSat.beacon || HAM_SATELLITES[key].beacon || '', notes: jsonSat.notes || HAM_SATELLITES[key].notes || '', - // Allow JSON to override these too name: jsonSat.name || HAM_SATELLITES[key].name, mode: jsonSat.mode || HAM_SATELLITES[key].mode, color: jsonSat.color || HAM_SATELLITES[key].color, @@ -64,336 +66,597 @@ module.exports = function (app, ctx) { norad: jsonSat.norad || HAM_SATELLITES[key].norad, }); } else { - // New satellite only in JSON — add it + // New satellite only in JSON — add it to HAM_SATELLITES HAM_SATELLITES[key] = jsonSat; } } logInfo(`[Satellites] Merged radio metadata — ${Object.keys(HAM_SATELLITES).length} satellites in registry`); } - let tleCache = { data: null, timestamp: 0 }; - const TLE_CACHE_DURATION = 12 * 60 * 60 * 1000; // 12 hours — TLEs don't change that fast - const TLE_STALE_SERVE_LIMIT = 48 * 60 * 60 * 1000; // Serve stale cache up to 48h while retrying - let tleNegativeCache = 0; // Timestamp of last total failure - const TLE_NEGATIVE_TTL = 30 * 60 * 1000; // 30 min backoff after all sources fail - - // TLE data sources in priority order — automatic failover - const TLE_SOURCES = { - celestrak: { - name: 'CelesTrak', - fetchGroups: async (groups, signal) => { - const tleData = {}; - for (const group of groups) { - try { - const res = await fetch(`https://celestrak.org/NORAD/elements/gp.php?GROUP=${group}&FORMAT=tle`, { - headers: { 'User-Agent': `OpenHamClock/${APP_VERSION}` }, - signal, - }); - if (res.ok) parseTleText(await res.text(), tleData, group); - else if (res.status === 429 || res.status === 403) - throw new Error(`CelesTrak returned ${res.status} (rate limited or banned)`); - } catch (e) { - if (e.message?.includes('rate limited') || e.message?.includes('banned')) throw e; // Bubble up to trigger failover - logDebug(`[Satellites] CelesTrak group ${group} failed: ${e.message}`); - } - } - return tleData; - }, - }, - celestrak_legacy: { - name: 'CelesTrak (legacy)', - fetchGroups: async (groups, signal) => { - const tleData = {}; - // Legacy domain uses different URL format - const legacyMap = { amateur: 'amateur', weather: 'weather', goes: 'goes' }; - for (const group of groups) { - try { - const res = await fetch(`https://celestrak.com/NORAD/elements/${legacyMap[group] || group}.txt`, { - headers: { 'User-Agent': `OpenHamClock/${APP_VERSION}` }, - signal, - }); - if (res.ok) parseTleText(await res.text(), tleData, group); - } catch (e) { - logDebug(`[Satellites] CelesTrak legacy group ${group} failed: ${e.message}`); - } - } - return tleData; - }, - }, - amsat: { - name: 'AMSAT', - fetchGroups: async (_groups, signal) => { - // AMSAT provides a single combined file for amateur satellites - const tleData = {}; - try { - const res = await fetch('https://www.amsat.org/tle/current/nasabare.txt', { - headers: { 'User-Agent': `OpenHamClock/${APP_VERSION}` }, - signal, - }); - if (res.ok) parseTleText(await res.text(), tleData, 'amateur'); - } catch (e) { - logDebug(`[Satellites] AMSAT TLE failed: ${e.message}`); - } - return tleData; - }, - }, - }; + const fetchOmmFromCelesTrakGroups = async (group) => { + let httpStatusCode = 0; + let ommJson = {}; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 20000); // 20s hard timeout + try { + const res = await fetch(`https://celestrak.org/NORAD/elements/gp.php?GROUP=${group}&FORMAT=csv`, { + headers: { 'User-Agent': `OpenHamClock/${APP_VERSION}` }, + signal: controller.signal, + }); + + httpStatusCode = res.status; - // Configurable source order via env var: TLE_SOURCES=celestrak,amsat,celestrak_legacy - const TLE_SOURCE_ORDER = (process.env.TLE_SOURCES || 'celestrak,celestrak_legacy,amsat') - .split(',') - .map((s) => s.trim()) - .filter((s) => TLE_SOURCES[s]); + if (res.ok) { + const text = await res.text(); + ommJson = await parseCsvText(text); + logDebug(`[Satellites] CelesTrak OMM fetch for ${group} group successful`); + } else if (res.status >= 400 && res.status <= 499) { + const body = await res.text().catch(() => ''); + logWarn(`[Satellites] CelesTrak OMM fetch failed for ${group} group: ${res.status} ${body}`); + } + } catch (ex) { + // timeout occurred, will return with httpStatusCode = 0 + if (ex.name === 'AbortError') { + logErrorOnce(`[Satellites] CelesTrak OMM fetch for ${group} group timed out after 20s`); + } + } finally { + clearTimeout(timeout); + } - function parseTleText(text, tleData, group) { - // Build NORAD lookup set for fast matching - const knownNorads = new Set(Object.values(HAM_SATELLITES).map((s) => s.norad)); + return { httpStatusCode, ommJson }; + }; - const lines = text.trim().split('\n'); - for (let i = 0; i < lines.length - 2; i += 3) { - const name = lines[i]?.trim(); - const line1 = lines[i + 1]?.trim(); - const line2 = lines[i + 2]?.trim(); - if (name && line1 && line1.startsWith('1 ')) { - const noradId = parseInt(line1.substring(2, 7)); + const fetchOmmFromCelesTrakIndividual = async (noradId) => { + let httpStatusCode = 0; + let ommJson = {}; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 20000); // 20s hard timeout - // Only include satellites we've curated in HAM_SATELLITES - if (!knownNorads.has(noradId)) continue; + try { + const res = await fetch(`https://celestrak.org/NORAD/elements/gp.php?CATNR=${noradId}&FORMAT=json`, { + headers: { 'User-Agent': `OpenHamClock/${APP_VERSION}` }, + signal: controller.signal, + }); - const alreadyExists = Object.values(tleData).some((sat) => sat.norad === noradId); - if (alreadyExists) continue; + httpStatusCode = res.status; - const hamSat = Object.values(HAM_SATELLITES).find((s) => s.norad === noradId); - if (hamSat) { - const key = name.replace(/[^A-Z0-9\-]/g, '_').toUpperCase(); - tleData[key] = { ...hamSat, tle1: line1, tle2: line2 }; - } + if (res.ok) { + ommJson = await res.text(); + ommJson = JSON.parse(ommJson); + normalizeJsonTree(ommJson); + logDebug(`[Satellites] CelesTrak OMM fetch for NORAD ID ${noradId} successful`); + } else if (res.status >= 400 && res.status <= 499) { + const body = await res.text().catch(() => ''); + logWarn(`[Satellites] CelesTrak OMM fetch failed for NORAD ID ${noradId}: ${res.status} ${body}`); } + } catch (ex) { + // timeout occurred, will return with httpStatusCode = 0 + if (ex.name === 'AbortError') { + logErrorOnce(`[Satellites] CelesTrak OMM fetch for NORAD ID ${noradId} timed out after 20s`); + } + } finally { + clearTimeout(timeout); } - } - // Single in-flight refresh promise. Concurrent /tle requests share it instead of each - // kicking off their own refresh — otherwise N parallel requests during a cold start fan - // out to N parallel CelesTrak hammering attempts and almost guarantee throttling. - let refreshInFlight = null; + return { httpStatusCode, ommJson }; + }; - async function refreshTleCacheInternal() { - const now = Date.now(); + const fetchOmmFromSpaceTrack = async (norad, username, password) => { + let httpStatusCode = 0; + let ommJson = {}; + + // Create cookie jar + axios instance + const jar = new CookieJar.CookieJar(); + const client = wrapper.wrapper( + axios.create({ + jar, + withCredentials: true, + headers: { + 'User-Agent': 'Mozilla/5.0', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }), + ); + + // 1. Login to space-track, saves cookie const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 20000); + let timeout = setTimeout(() => controller.abort(), 20000); // 20s hard timeout + try { + const loginResponse = await client.post( + 'https://www.space-track.org/ajaxauth/login', + new URLSearchParams({ + identity: username, + password, + }), + controller.signal, + ); + } catch (ex) { + logError(`[Satellites] Space-Track login could not establish connection: ${ex.message}`); + return { httpStatusCode, ommJson }; // return with httpStatusCode = 0 to indicate connection failure + } finally { + clearTimeout(timeout); + } - const groups = ['amateur', 'weather', 'goes']; - let tleData = {}; - let sourceUsed = null; + // 2. Check whoami, JSON returned indicates whether logged in or not + const isLoggedIn = (whoami) => { + jsonBytes = new Uint8Array(whoami); + if (!jsonBytes || jsonBytes.length === 0) return false; + const json = new TextDecoder('utf-8').decode(jsonBytes); - for (const sourceKey of TLE_SOURCE_ORDER) { - const source = TLE_SOURCES[sourceKey]; + let obj; try { - tleData = await source.fetchGroups(groups, controller.signal); - if (Object.keys(tleData).length >= 5) { - sourceUsed = source.name; + obj = JSON.parse(json); + } catch { + return false; + } + + return obj.logged_in === true; + }; + + try { + timeout = setTimeout(() => controller.abort(), 20000); // 20s hard timeout + const whoamiResp = await client.get( + 'https://www.space-track.org/app/data/whoami', + { responseType: 'arraybuffer' }, + controller.signal, + ); + + const whoami = whoamiResp.data; + const loggedIn = isLoggedIn(whoami); + + logDebug('[Satellites] Space-Track loggedIn = ' + loggedIn); + if (!loggedIn) { + httpStatusCode = 401; + return { httpStatusCode, ommJson }; // return with httpStatusCode = 401 to indicate auth failure + } + } catch (ex) { + logError(`[Satellites] Space-Track OMM fetch login failed: ${ex.message}`); + httpStatusCode = 401; + return { httpStatusCode, ommJson }; // return with httpStatusCode = 401 to indicate auth failure + } finally { + clearTimeout(timeout); + } + + // 3. Get TLE data + const fetchUrl = + 'https://www.space-track.org/basicspacedata/query/class/gp/norad_cat_id/' + norad.join(',') + '/format/csv'; + + timeout = setTimeout(() => controller.abort(), 20000); // 20s hard timeout + try { + const res = await client.get(fetchUrl, { + headers: { + 'User-Agent': `OpenHamClock/${APP_VERSION}`, + }, + responseType: 'arraybuffer', // binary data + signal: controller.signal, + }); + + httpStatusCode = res.status; + + // if OK then decode CSV from binary response and parse, otherwise log error for 4xx responses + if (httpStatusCode == 200) { + const bytes = new Uint8Array(await res.data); + const textDecoder = new TextDecoder('utf-8'); + const text = await textDecoder.decode(bytes); + + ommJson = await parseCsvText(text); + const count = Object.keys(ommJson).length; + logDebug(`[Satellites] Space-Track OMM fetch successful`); + } else if (res.status >= 400 && res.status <= 499) { + logWarn(`[Satellites] Space-Track OMM fetch failed for NORAD ID '${norad}': ${res.status}`); + } + } catch (ex) { + // probable timeout occurred in which case will return with httpStatusCode = 0 + if (ex.name === 'AbortError') { + logErrorOnce(`[Satellites] Space-Track OMM fetch for NORAD ID '${norad}' timed out after 20s`); + } + return { httpStatusCode, ommJson }; + } finally { + clearTimeout(timeout); + } + + return { httpStatusCode, ommJson }; + }; + + /** + * Parse CSV text into JSON, normalizing numeric values including those in scientific notation or with leading dots. + * + * @async + * @param {*} csvText + * @returns {unknown} + */ + const parseCsvText = async (csvText) => { + let json = null; + try { + csvToJson.supportQuotedField(true); + json = await csvToJson.csvStringToJson(csvText); + } catch (err) { + logError('Error reading CSV:', err); + } + + normalizeJsonTree(json); + return json; + }; + + let ommCache = {}; + let ommUnusedCache = {}; // record of satellites whose data is known but are not being tracked + const OMM_CACHE_DURATION = 12 * 60 * 60 * 1000; // 12 hours, period after which OMM data considered stale + const SPACE_TRACK_BACKOFF = 120 * 60 * 1000; // 2 hour, any satellite not allowed to repeat query to Space-Track within this period + const CELESTRAK_BACKOFF = 120 * 60 * 1000; // 2 hour, any satellite not allowed to repeat query to CelesTrak within this period + const CELESTRAK_GROUP_MIN_DOWNLOAD_SIZE = 3; // minimum number of satellites to trigger group download as, if fewer, then more efficient to perform individual download + + const isStale = (timestamp) => { + if (typeof timestamp !== 'number' || Number.isNaN(timestamp)) { + return true; // returns true if timestamp missing, null, undefined, or NaN + } + + return Date.now() >= timestamp + OMM_CACHE_DURATION; + }; + + // list of external OMM providers with logic to switch between them + const OMM_PROVIDERS = ['SPACE_TRACK', 'CELESTRAK']; + let ommProviderIndex = -1; // on the first fetch we want the first in the list + const nextOmmProvider = () => { + ommProviderIndex = (ommProviderIndex + 1) % OMM_PROVIDERS.length; + return OMM_PROVIDERS[ommProviderIndex]; + }; + + const spaceTrackEnabled = CONFIG.satellites.spaceTrack?.enabled || false; // default false if undefined + const celestrakEnabled = CONFIG.satellites.celestrak?.enabled ?? true; // default true if undefined + logDebug('[Satellites] Space-Track enabled: ' + spaceTrackEnabled); + logDebug('[Satellites] CelesTrak enabled: ' + celestrakEnabled); + + let blockCelesTrakUntil = Date.now() - 1; // Timestamp until which CelesTrak fetches are blocked due to rate limiting or ban + let blockSpaceTrackUntil = Date.now() - 1; // Timestamp until which Space-Track fetches are blocked due to rate limiting or ban + let celestrakNumSatNeedDownload = 0; + let noradsToDownload = []; + + // state-machine states + const smStates = [ + 'START', + 'SPACE_TRACK_INIT', + 'SPACE_TRACK_FETCH', + 'CELESTRAK_AMATEUR_GROUP_INIT', + 'CELESTRAK_AMATEUR_GROUP_FETCH', + 'CELESTRAK_WEATHER_GROUP_INIT', + 'CELESTRAK_WEATHER_GROUP_FETCH', + 'CELESTRAK_INDIVIDUAL_INIT', + 'CELESTRAK_INDIVIDUAL_FETCH', + ]; + + // state-machine handlers + const handlers = { + START: () => { + // toggle between OMM providers + switch (nextOmmProvider()) { + case 'SPACE_TRACK': + return spaceTrackEnabled ? 'SPACE_TRACK_INIT' : 'START'; // return next state break; + case 'CELESTRAK': { + const now = Date.now(); + celestrakNumSatNeedDownload = celestrakSatsToDownload(now).length; // record how many satellites with a CelesTrak datasource need data + return celestrakNumSatNeedDownload > 0 ? 'CELESTRAK_AMATEUR_GROUP_INIT' : 'START'; // return next state } - logDebug( - `[Satellites] ${source.name} returned only ${Object.keys(tleData).length} satellites, trying next source...`, - ); - } catch (e) { - logWarn(`[Satellites] ${source.name} failed: ${e.message}`); + default: + break; } - } - clearTimeout(timeout); - - // Per-NORAD fill for sats not in group files. CelesTrak rate-limits parallel CATNR - // hammering by returning HTTP 200 with an empty body — keep parallelism low (2), - // delay between batches, and DON'T retry on the hot path (retries dominate worst-case - // latency and push the whole refresh past Cloudflare's 100s edge timeout). If - // CelesTrak throttles a sat, we fall through to SatNOGS once and move on. - const foundNorads = new Set(Object.values(tleData).map((s) => s.norad)); - const missingSats = Object.entries(HAM_SATELLITES).filter(([, s]) => !foundNorads.has(s.norad)); - if (missingSats.length > 0 && (Object.keys(tleData).length === 0 || missingSats.length <= 30)) { - logDebug( - `[Satellites] ${missingSats.length} sats missing from group files: ${missingSats.map(([k]) => k).join(', ')}`, + }, + + CELESTRAK_AMATEUR_GROUP_INIT: async () => { + if (blockCelesTrakUntil && Date.now() < blockCelesTrakUntil) { + logDebug('[Satellites] Skipping CelesTrak fetch due to active backoff'); + return 'START'; // return next state + } + + const now = Date.now(); + const satsNeedDownload = Object.values(HAM_SATELLITES).filter( + (s) => + s.data_source === 'celestrak_amateur' && + isStale(s.ommTimestamp) && + !(s.backoffCelestrakUntil && now < s.backoffCelestrakUntil), ); - const PER_NORAD_BATCH_SIZE = 2; - const PER_NORAD_BATCH_DELAY_MS = 400; - - for (let i = 0; i < missingSats.length; i += PER_NORAD_BATCH_SIZE) { - const batch = missingSats.slice(i, i + PER_NORAD_BATCH_SIZE); - const results = await Promise.allSettled( - batch.map(async ([key, sat]) => { - try { - const catRes = await fetch(`https://celestrak.org/NORAD/elements/gp.php?CATNR=${sat.norad}&FORMAT=tle`, { - headers: { 'User-Agent': `OpenHamClock/${APP_VERSION}` }, - signal: AbortSignal.timeout(4000), - }); - if (catRes.ok) { - const catText = await catRes.text(); - const catLines = catText.trim().split('\n'); - if (catLines.length >= 3 && catLines[1].trim().startsWith('1 ')) { - const tleKey = key.replace(/[^A-Z0-9\-]/g, '_').toUpperCase(); - tleData[tleKey] = { ...sat, tle1: catLines[1].trim(), tle2: catLines[2].trim() }; - logDebug(`[Satellites] Filled ${key} (NORAD ${sat.norad}) from CelesTrak CATNR`); - return key; - } - logDebug(`[Satellites] CelesTrak CATNR ${sat.norad} unexpected (${catLines.length} lines)`); - } - } catch (e) { - logDebug(`[Satellites] CelesTrak CATNR ${sat.norad} failed: ${e.message}`); - } - - try { - const satnogsRes = await fetch(`https://db.satnogs.org/api/tle/?norad_cat_id=${sat.norad}&format=json`, { - headers: { 'User-Agent': `OpenHamClock/${APP_VERSION}` }, - signal: AbortSignal.timeout(4000), - }); - if (satnogsRes.ok) { - const satnogsData = await satnogsRes.json(); - const entry = Array.isArray(satnogsData) ? satnogsData[0] : satnogsData; - if (entry?.tle1 && entry?.tle2) { - const tleKey = key.replace(/[^A-Z0-9\-]/g, '_').toUpperCase(); - tleData[tleKey] = { ...sat, tle1: entry.tle1.trim(), tle2: entry.tle2.trim() }; - logDebug(`[Satellites] Filled ${key} (NORAD ${sat.norad}) from SatNOGS`); - return key; - } - } - } catch (e) { - logDebug(`[Satellites] SatNOGS ${sat.norad} failed: ${e.message}`); - } - - logDebug(`[Satellites] Could not resolve TLE for ${key} (NORAD ${sat.norad}) from any source`); - return null; - }), - ); - const filled = results.filter((r) => r.status === 'fulfilled' && r.value).map((r) => r.value); - if (filled.length > 0) logDebug(`[Satellites] Batch filled: ${filled.join(', ')}`); - if (i + PER_NORAD_BATCH_SIZE < missingSats.length) - await new Promise((r) => setTimeout(r, PER_NORAD_BATCH_DELAY_MS)); + // if number of satellites to download to too few it is more efficient to use individual downloads than to use a group + if (satsNeedDownload.length < CELESTRAK_GROUP_MIN_DOWNLOAD_SIZE) return 'CELESTRAK_WEATHER_GROUP_INIT'; // return next state + + // assume data for this satellite is about to be attempted, + // set backoff so that it cannot be repeated for this satellite until CELESTRAK_BACKOFF has elapsed + satsNeedDownload.forEach((s) => { + s.backoffCelestrakUntil = now + CELESTRAK_BACKOFF; + }); + + return 'CELESTRAK_AMATEUR_GROUP_FETCH'; // return next state + }, + + CELESTRAK_AMATEUR_GROUP_FETCH: async () => { + // fetch new CelesTrak OMM data for the 'amateur' group to refresh cache in the background + try { + const { httpStatusCode, ommJson } = await fetchOmmFromCelesTrakGroups('amateur'); + if (httpStatusCode === 200) { + if (ommJson && Object.keys(ommJson).length > 0) appendDataToOmmCache(ommJson); + } else if (httpStatusCode === 301 || httpStatusCode === 403) { + logWarn('[Satellites] Detected CelesTrak rate limit or ban, blocking fetches for 120mins'); + blockCelesTrakUntil = Date.now() + 120 * 60 * 1000; // Block CelesTrak fetches for 120mins + } else if (httpStatusCode === 404) { + logDebug("[Satellites] detected 404 'not found' on a group query, may need investigation"); + } else if (httpStatusCode === 0) { + logErrorOnce(`[Satellites] CelesTrak OMM fetch failed with no response (possible timeout)`); + } + } finally { + return 'CELESTRAK_WEATHER_GROUP_INIT'; // return next state + } + }, + + CELESTRAK_WEATHER_GROUP_INIT: async () => { + if (blockCelesTrakUntil && Date.now() < blockCelesTrakUntil) { + logDebug('[Satellites] Skipping CelesTrak fetch due to active backoff'); + return 'START'; // return next state } - logDebug(`[Satellites] After fill: ${Object.keys(tleData).length} total satellites resolved`); - } - // ISS fallback — try CelesTrak direct if ISS not found - if (!Object.values(tleData).some((sat) => sat.norad === 25544)) { + const now = Date.now(); + const satsNeedDownload = Object.values(HAM_SATELLITES).filter( + (s) => + s.data_source === 'celestrak_weather' && + isStale(s.ommTimestamp) && + !(s.backoffCelestrakUntil && now < s.backoffCelestrakUntil), + ); + + // if number of satellites to download to too few it is more efficient to use individual downloads than to use a group + if (satsNeedDownload.length < CELESTRAK_GROUP_MIN_DOWNLOAD_SIZE) return 'CELESTRAK_INDIVIDUAL_INIT'; // return next state + + // assume data for this satellite is about to be attempted, + // set backoff so that it cannot be repeated for this satellite until CELESTRAK_BACKOFF has elapsed + satsNeedDownload.forEach((s) => { + s.backoffCelestrakUntil = now + CELESTRAK_BACKOFF; + }); + + return 'CELESTRAK_WEATHER_GROUP_FETCH'; // return next state + }, + + CELESTRAK_WEATHER_GROUP_FETCH: async () => { + // fetch new CelesTrak OMM data for the 'weather' group to refresh cache in the background try { - const issRes = await fetch('https://celestrak.org/NORAD/elements/gp.php?CATNR=25544&FORMAT=tle', { - signal: AbortSignal.timeout(4000), - }); - if (issRes.ok) { - const issLines = (await issRes.text()).trim().split('\n'); - if (issLines.length >= 3) { - tleData['ISS'] = { ...HAM_SATELLITES['ISS'], tle1: issLines[1].trim(), tle2: issLines[2].trim() }; - } + const { httpStatusCode, ommJson } = await fetchOmmFromCelesTrakGroups('weather'); + if (httpStatusCode === 200) { + if (ommJson && Object.keys(ommJson).length > 0) appendDataToOmmCache(ommJson); + } else if (httpStatusCode === 301 || httpStatusCode === 403) { + logWarn('[Satellites] Detected CelesTrak rate limit or ban, blocking fetches for 120mins'); + blockCelesTrakUntil = Date.now() + 120 * 60 * 1000; // Block CelesTrak fetches for 120mins + } else if (httpStatusCode === 404) { + logDebug("[Satellites] detected 404 'not found' on a group query, may need investigation"); + } else if (httpStatusCode === 0) { + logErrorOnce(`[Satellites] CelesTrak OMM fetch failed with no response (possible timeout)`); } - } catch (e) { - logDebug('[Satellites] ISS fallback failed'); + } finally { + return 'CELESTRAK_INDIVIDUAL_INIT'; // return next state } - } + }, + + CELESTRAK_INDIVIDUAL_INIT: async () => { + if (blockCelesTrakUntil && Date.now() < blockCelesTrakUntil) { + logDebug('[Satellites] Skipping CelesTrak fetch due to active backoff'); + return 'START'; // return next state + } + + // loop until every eligible satellite has been attempted for download using CELESTRAK_INDIVIDUAL_FETCH state, + // OR until the number of satellites needing data has INCREASED which means there has been recent timestamp expirations and + // it is prudent to reassess whether groups download is more appropriate. This is to mitigate potential mass timestamp + // expirations coincide with when the INDIVIDUAL state is active in which case the individual state would unnecessarily + // have the download burden. + const now = Date.now(); + const satsToDownload = celestrakSatsToDownload(now); + if (satsToDownload.length <= celestrakNumSatNeedDownload && satsToDownload.length > 0) { + celestrakNumSatNeedDownload = satsToDownload.length; // update record + + // assume data for this satellite is about to be attempted, + // set backoff so that it cannot be repeated for this satellite until CELESTRAK_BACKOFF has elapsed + const sat = satsToDownload[0]; + sat.backoffCelestrakUntil = now + CELESTRAK_BACKOFF; + + noradsToDownload = sat.norad; + return 'CELESTRAK_INDIVIDUAL_FETCH'; // return next state + } + + return 'START'; // return next state + }, + + CELESTRAK_INDIVIDUAL_FETCH: async () => { + // fetch new CelesTrak OMM data for individual satellites to refresh cache in the background + try { + if (Array.isArray(noradsToDownload)) + throw `[Satellites] CELESTRAK_INDIVIDUAL_FETCH, argument noradsToDownload should not be array`; + const { httpStatusCode, ommJson } = await fetchOmmFromCelesTrakIndividual(noradsToDownload); + if (httpStatusCode === 200) { + if (ommJson && Object.keys(ommJson).length > 0) appendDataToOmmCache(ommJson); + } else if (httpStatusCode === 301 || httpStatusCode === 403) { + logWarn('[Satellites] Detected CelesTrak rate limit or ban, blocking fetches for 120mins'); + blockCelesTrakUntil = Date.now() + 120 * 60 * 1000; // Block CelesTrak fetches for 120mins + } else if (httpStatusCode === 404) { + logDebug( + `[Satellites] NORAD ID ${noradsToDownload}, detected 404 \'not found\', may need to manually check status of this satellite via CelesTrak website`, + ); + } else if (httpStatusCode === 0) { + logErrorOnce(`[Satellites] CelesTrak OMM fetch failed with no response (possible timeout)`); + } + } finally { + return 'CELESTRAK_INDIVIDUAL_INIT'; // return next state, back to INIT + } + }, + + SPACE_TRACK_INIT: async () => { + if (blockSpaceTrackUntil && Date.now() < blockSpaceTrackUntil) { + logDebug('[Satellites] Skipping Space-Track fetch due to active backoff'); + return 'START'; // return next state + } + + const now = Date.now(); + noradsToDownload = Object.values(HAM_SATELLITES) + .filter((s) => { + if (isStale(s.ommTimestamp) && !(s.backoffSpaceTrackUntil && now < s.backoffSpaceTrackUntil)) { + // assume data for this satellite is about to be attempted, + // set backoff so that it cannot be repeated for this satellite until SPACE_TRACK_BACKOFF has elapsed + s.backoffSpaceTrackUntil = now + SPACE_TRACK_BACKOFF; + return true; // add to filter + } else return false; // exclude from filter + }) + .map((s) => s.norad) + .sort((a, b) => a - b); + + return noradsToDownload.length > 0 ? 'SPACE_TRACK_FETCH' : 'START'; // return next state + }, - if (Object.keys(tleData).length > 0) { - // Refuse to overwrite a healthier cache with a materially worse one — prevents one - // bad refresh from stranding clients for 12h. - const prevCount = tleCache.data ? Object.keys(tleCache.data).length : 0; - const stillMissing = Object.entries(HAM_SATELLITES).filter( - ([, s]) => !Object.values(tleData).some((t) => t.norad === s.norad), - ).length; - if ( - stillMissing >= 5 && - prevCount > Object.keys(tleData).length && - now - tleCache.timestamp < TLE_STALE_SERVE_LIMIT - ) { - logWarn( - `[Satellites] Refresh degraded (got ${Object.keys(tleData).length}, prev had ${prevCount}); keeping previous cache`, + SPACE_TRACK_FETCH: async () => { + // fetch new OMM data from Space-Track to refresh cache in the background + try { + const { httpStatusCode, ommJson } = await fetchOmmFromSpaceTrack( + noradsToDownload, + CONFIG.satellites.spaceTrack._username || '', // if spaceTrackEnabled === true then logic is that username and password are defined in .env + CONFIG.satellites.spaceTrack._password || '', ); - tleNegativeCache = now; - return tleCache.data; + if (httpStatusCode === 200) { + if (ommJson && Object.keys(ommJson).length > 0) appendDataToOmmCache(ommJson); + } else if (httpStatusCode === 401) { + logWarn('[Satellites] Space-Track authentication failed, check credentials, blocking for 60min'); + blockSpaceTrackUntil = Date.now() + 60 * 60 * 1000; // Block Space-Track fetches for 60mins + } else if (httpStatusCode === 403 || httpStatusCode === 404) { + logWarn('[Satellites] Detected Space-Track rate limit or ban, blocking fetches for 60min'); + blockSpaceTrackUntil = Date.now() + 60 * 60 * 1000; // Block Space-Track fetches for 60mins + } + } finally { + return 'START'; // return next state } - tleCache = { data: tleData, timestamp: now }; - if (sourceUsed) logInfo(`[Satellites] Loaded ${Object.keys(tleData).length} satellites from ${sourceUsed}`); - return tleData; - } + }, + }; - // All sources failed - tleNegativeCache = now; - logWarn('[Satellites] All TLE sources failed, backing off for 30 min'); - return tleCache.data || {}; - } + // Initialize state-machine with periodic advance every 15s + const sm = new StateMachine(smStates, handlers); + setInterval(async () => { + sm.run(); + }, 15 * 1000); + + // satellites with a CelesTrak datasource that need data + const celestrakSatsToDownload = (now) => { + return Object.values(HAM_SATELLITES).filter( + (s) => + s.data_source.startsWith('celestrak') && + isStale(s.ommTimestamp) && + !(s.backoffCelestrakUntil && now < s.backoffCelestrakUntil), + ); + }; - function refreshTleCache() { - if (refreshInFlight) return refreshInFlight; - refreshInFlight = refreshTleCacheInternal() - .catch((e) => { - logWarn(`[Satellites] Refresh threw: ${e.message}`); - return tleCache.data || {}; - }) - .finally(() => { - refreshInFlight = null; - }); - return refreshInFlight; - } + // append OMM JSON data to cache + const appendDataToOmmCache = async (ommJson) => { + if (!ommJson || typeof ommJson !== 'object') return; + + // Build NORAD_ID value lookup set for fast matching + const knownNoradIds = new Set(Object.values(HAM_SATELLITES).map((s) => s.norad)); + + let countUsed = 0, + countUnused = 0; + const now = Date.now(); + ommJson.forEach((omm) => { + const noradId = omm.NORAD_CAT_ID; + const objectName = omm.OBJECT_NAME; + match = knownNoradIds.has(noradId); + const key = objectName.replace(/[^A-Z0-9\-]/g, '_').toUpperCase(); + if (match) { + countUsed++; + const hamSat = Object.values(HAM_SATELLITES).find((s) => s.norad === noradId); + hamSat.ommTimestamp = now; // record timestamp + + if (hamSat) { + ommCache[key] = { ...hamSat, omm: omm, timestamp: now }; + } + } else { + // keep a separate record of satellites with unused data + countUnused++; + ommUnusedCache[key] = { norad: noradId, name: objectName }; + } + }); - app.get('/api/satellites/tle', async (req, res) => { - // Don't let CDN pin an empty payload — when all sources fail we want the next request - // after backoff to hit the origin, not the edge cache. - const sendTle = (payload, stale) => { + logInfo(`[Satellites] OMM cache updated, ${countUsed} used, ${countUnused} unused records`); + }; + + app.get('/api/satellites/data', async (req, res) => { + // Don't let Fastly/CDN pin an empty payload — when all sources fail we want + // the next request after backoff to hit the origin, not the edge cache. + const sendSatelliteData = (payload) => { if (!payload || Object.keys(payload).length === 0) { res.set('Cache-Control', 'no-store'); } + + const newestTimestamp = Object.values(ommCache) + .map((entry) => entry.timestamp) + .filter((ts) => typeof ts === 'number') + .reduce((max, ts) => Math.max(max, ts), 0); + + const stale = newestTimestamp === 0 || Date.now() - newestTimestamp > OMM_CACHE_DURATION; if (stale) res.set('X-TLE-Stale', 'true'); + return res.json(payload); }; - const now = Date.now(); + sendSatelliteData(ommCache || {}); + }); - // Fresh cache hit - if (tleCache.data && now - tleCache.timestamp < TLE_CACHE_DURATION) { - return res.json(tleCache.data); - } + // Satellite debug endpoint — shows which are missing, which are resolved, and which have un-utilized downloaded data + app.get('/api/satellites/debug', (req, res) => { + const cached = ommCache || {}; + const resolvedNorads = new Set(Object.values(cached).map((s) => s.norad)); - // Recent total failure — don't retry yet - if (now - tleNegativeCache < TLE_NEGATIVE_TTL) { - if (tleCache.data && now - tleCache.timestamp < TLE_STALE_SERVE_LIMIT) { - return sendTle(tleCache.data, true); + const findCachedTimestampByNorad = (cached, norad) => { + const entry = Object.values(cached).find((e) => e.norad === norad); + return entry?.timestamp ?? null; + }; + + const formatSimpleAge = (ms) => { + if (!ms || ms < 0) return 'n/a'; + const seconds = Math.floor(ms / 1000); + if (seconds <= 300) { + return `${seconds} s`; } - return sendTle(tleCache.data || {}); - } + const minutes = Math.floor(seconds / 60); + if (minutes <= 120) { + return `${minutes} min`; + } + const hours = Math.floor(minutes / 60); + if (hours <= 48) { + return `over ${hours} hr`; + } + const days = Math.floor(hours / 24); + return `over ${days} days`; + }; - // Stale-while-revalidate: if we have any cached data, serve it immediately and refresh - // in the background. Only block when there is truly nothing to return — otherwise a - // slow upstream refresh (potentially >100s) will trip Cloudflare's edge timeout. - if (tleCache.data && Object.keys(tleCache.data).length > 0) { - refreshTleCache(); // fire and forget - return sendTle(tleCache.data, true); - } + const all = Object.entries(HAM_SATELLITES).map(([key, sat]) => { + const ts = findCachedTimestampByNorad(cached, sat.norad); + + return { + key, + norad: sat.norad, + name: sat.name, + resolved: resolvedNorads.has(sat.norad), + ...(resolvedNorads.has(sat.norad) && + ts && { + cacheAge: formatSimpleAge(Date.now() - ts), + }), + }; + }); - // Cold start — must wait. The dedup in refreshTleCache ensures concurrent requests - // share one upstream refresh. - try { - const data = await refreshTleCache(); - sendTle(data || {}); - } catch (e) { - sendTle(tleCache.data || {}); - } - }); + const allUnused = Object.values(ommUnusedCache) + .map((sat) => ({ + norad: sat.norad, + name: sat.name, + })) + .sort((a, b) => a.norad - b.norad); - // Satellite debug endpoint — shows which sats resolved and which are missing - app.get('/api/satellites/debug', (req, res) => { - const cached = tleCache.data || {}; - const resolvedNorads = new Set(Object.values(cached).map((s) => s.norad)); - const all = Object.entries(HAM_SATELLITES).map(([key, sat]) => ({ - key, - norad: sat.norad, - name: sat.name, - resolved: resolvedNorads.has(sat.norad), - tleKey: Object.keys(cached).find((k) => cached[k].norad === sat.norad) || null, - })); res.json({ - cacheAge: tleCache.timestamp ? `${Math.round((Date.now() - tleCache.timestamp) / 1000)}s ago` : 'empty', totalInRegistry: Object.keys(HAM_SATELLITES).length, totalResolved: Object.keys(cached).length, totalMissing: all.filter((s) => !s.resolved).length, + totalNotUtilized: allUnused.length, missing: all.filter((s) => !s.resolved), resolved: all.filter((s) => s.resolved), + notUtilized: allUnused, }); }); }; diff --git a/server/utils/statemachine.js b/server/utils/statemachine.js index b595f53c..04575af7 100644 --- a/server/utils/statemachine.js +++ b/server/utils/statemachine.js @@ -1,5 +1,4 @@ -const { Mutex, MutexValue, MutexCounter } = require('../utils/mutex'); - +const { Mutex, MutexValue, MutexCounter } = require('./mutex'); /** * A simple state machine implementation with safety checks to prevent invalid states and handlers. * The StateMachine class takes an array of states and a corresponding handlers object. Each handler should return the name of the next state. diff --git a/src/hooks/useSatellites.js b/src/hooks/useSatellites.js index e2d410ad..3e3ae4a0 100644 --- a/src/hooks/useSatellites.js +++ b/src/hooks/useSatellites.js @@ -1,6 +1,6 @@ /** * useSatellites Hook - * Tracks amateur radio satellites using TLE data and satellite.js + * Tracks amateur radio satellites using API data source provided by satellite.js server-side service. * Includes orbit track prediction */ import { useState, useEffect, useCallback } from 'react'; @@ -18,30 +18,31 @@ export const useSatellites = (observerLocation, satelliteConfig) => { const [nextPassData, setNextPassData] = useState([]); const [loading, setLoading] = useState(true); const [loadingNextPass, setLoadingNextPass] = useState(true); - const [tleData, setTleData] = useState({}); + const [satelliteData, setSatelliteData] = useState({}); - // Fetch TLE data + // Fetch satellite data useEffect(() => { - const fetchTLE = async () => { + async function fetchSatelliteData() { try { - const response = await fetch('/api/satellites/tle'); + const response = await fetch('/api/satellites/data'); if (response.ok) { - const tle = await response.json(); - setTleData(tle); + const satData = await response.json(); + setSatelliteData(satData); } } catch (err) { - console.error('TLE fetch error:', err); + console.error('Satellite data fetch error:', err); } - }; + } + + fetchSatelliteData(); + const interval = setInterval(fetchSatelliteData, 6 * 60 * 60 * 1000); // 6 hours - fetchTLE(); - const interval = setInterval(fetchTLE, 6 * 60 * 60 * 1000); // 6 hours return () => clearInterval(interval); }, []); // Calculate satellite positions and orbits const calculatePositions = useCallback(() => { - if (!observerLocation || Object.keys(tleData).length === 0) { + if (!observerLocation || Object.keys(satelliteData).length === 0) { setLoading(false); return; } @@ -58,19 +59,16 @@ export const useSatellites = (observerLocation, satelliteConfig) => { height: (observerLocation.stationAlt ?? 100) / 1000, // above sea level [km], stationAlt is [m]), defaults to 100m }; - Object.entries(tleData).forEach(([name, tle]) => { - // Handle both line1/line2 and tle1/tle2 formats - const line1 = tle.line1 || tle.tle1; - const line2 = tle.line2 || tle.tle2; - if (!line1 || !line2) return; - + Object.entries(satelliteData).forEach(([name, satData]) => { // Find corresponding next pass data for this satellite - const nextPass = nextPassData.find((pass) => pass.name === (tle.name || name)); + const nextPass = nextPassData.find( + (pass) => pass.keyName === (satData.name || '') || pass.keyName === (name || ''), + ); const startTimes = nextPass?.startTimes || []; const endTimes = nextPass?.endTimes || []; try { - const satrec = satellite.twoline2satrec(line1, line2); + const satrec = satellite.json2satrec(satData.omm); const positionAndVelocity = satellite.propagate(satrec, now); const positionEci = positionAndVelocity.position; const velocityEci = positionAndVelocity.velocity; @@ -136,9 +134,8 @@ export const useSatellites = (observerLocation, satelliteConfig) => { const footprintRadius = earthRadius * Math.acos(earthRadius / (earthRadius + alt)); positions.push({ - name: tle.name || name, - tle1: line1, - tle2: line2, + name: satData.name || name, + omm: satData.omm, lat, lon, alt: round(alt, 1), @@ -151,20 +148,20 @@ export const useSatellites = (observerLocation, satelliteConfig) => { isVisible, // visible if above minimum elevation nextPassStartTimes: startTimes, nextPassEndTimes: endTimes, - isPopular: tle.priority <= 2, + isPopular: satData.priority <= 2, track, footprintRadius: Math.round(footprintRadius), - mode: tle.mode || 'Unknown', - color: tle.color || '#00ffff', + mode: satData.mode || 'Unknown', + color: satData.color || '#00ffff', // Radio metadata from satellites.json - downlink: tle.downlink || '', - uplink: tle.uplink || '', - tone: tle.tone || '', - beacon: tle.beacon || '', - notes: tle.notes || '', + downlink: satData.downlink || '', + uplink: satData.uplink || '', + tone: satData.tone || '', + beacon: satData.beacon || '', + notes: satData.notes || '', }); } catch (e) { - // Skip satellites with invalid TLE + // Skip satellites with invalid data, continue processing others } }); @@ -177,13 +174,13 @@ export const useSatellites = (observerLocation, satelliteConfig) => { console.error('Satellite calculation error:', err); setLoading(false); } - }, [observerLocation, tleData, nextPassData]); + }, [observerLocation, satelliteData, nextPassData]); // Calculate satellite next passes, finds the start/end times of the next 2 passes for each satellite that are above the minimum elevation // Loops every hour since passes don't change often // When consumed check that the first pass hasn't already ended const calculateNextPasses = useCallback(() => { - if (!observerLocation || Object.keys(tleData).length === 0) { + if (!observerLocation || Object.keys(satelliteData).length === 0) { setLoadingNextPass(false); return; } @@ -211,14 +208,9 @@ export const useSatellites = (observerLocation, satelliteConfig) => { } const nextPasses = []; - Object.entries(tleData).forEach(([name, tle]) => { + Object.entries(satelliteData).forEach(([keyName, satData]) => { try { - // Handle both line1/line2 and tle1/tle2 formats - const line1 = tle.line1 || tle.tle1; - const line2 = tle.line2 || tle.tle2; - if (!line1 || !line2) return; - - const orbit = new Orbit(name, `${name}\n${line1}\n${line2}`); + const orbit = new Orbit(keyName, satData.omm); if (orbit.error) console.warn('Satellite orbit error:', orbit.error); const passes = orbit.computePassesElevation(groundStation, startDate, endDate, minElevation, maxPasses); @@ -232,22 +224,22 @@ export const useSatellites = (observerLocation, satelliteConfig) => { }); nextPasses.push({ - name: tle.name || name, + keyName, startTimes, endTimes, }); } catch (e) { - // Skip satellite with invalid TLE, continue processing others + // Skip satellite with invalid data, continue processing others } }); // Sort alphabetically by name for a consistent, static list - nextPasses.sort((a, b) => a.name.localeCompare(b.name)); + nextPasses.sort((a, b) => a.keyName.localeCompare(b.keyName)); if (logLevel === 'debug') { const formatDate = (date) => new Date(date).toISOString().slice(0, 19).replace('T', ' '); - nextPasses.forEach(({ name, startTimes, endTimes }) => { - let logStr = `[Satellite] Next passes for ${name}: `; + nextPasses.forEach(({ keyName, startTimes, endTimes }) => { + let logStr = `[Satellite] Next passes for keyname \'${keyName}\': `; if (startTimes.length === 0) { logStr += '\n None.'; } else { @@ -263,7 +255,7 @@ export const useSatellites = (observerLocation, satelliteConfig) => { setNextPassData(nextPasses); setLoadingNextPass(false); - }, [observerLocation, tleData, satelliteConfig]); + }, [observerLocation, satelliteData, satelliteConfig]); // Update positions every 5 seconds useEffect(() => { diff --git a/src/plugins/layers/useSatelliteLayer.js b/src/plugins/layers/useSatelliteLayer.js index 4ecfb763..e85b637e 100644 --- a/src/plugins/layers/useSatelliteLayer.js +++ b/src/plugins/layers/useSatelliteLayer.js @@ -62,7 +62,7 @@ export const useLayer = ({ map, enabled, satellites, setSatellites, opacity, con const fetchSatellites = async () => { try { - const response = await fetch('/api/satellites/tle'); + const response = await fetch('/api/satellites/data'); const data = await response.json(); const satArray = Object.keys(data).map((name) => { @@ -181,7 +181,7 @@ export const useLayer = ({ map, enabled, satellites, setSatellites, opacity, con }; // Prevent map from capturing events on the window - win.addEventListener('wheel', handleWheelPropagation); + win.addEventListener('wheel', handleWheelPropagation, { passive: true }); win.addEventListener('mousedown', handleMouseDownPropagation); win.addEventListener('mousemove', handleMouseMovePropagation); win.addEventListener('mouseup', handleMouseUpPropagation); @@ -399,8 +399,7 @@ export const useLayer = ({ map, enabled, satellites, setSatellites, opacity, con class="sat-open-predict" data-action="open-predict" data-sat-name="${attrEscape(sat.name)}" - data-tle1="${attrEscape(sat.tle1)}" - data-tle2="${attrEscape(sat.tle2)}" + data-omm="${attrEscape(sat.omm ? JSON.stringify(sat.omm) : '')}" style=" width: 100%; padding: 2px 0; @@ -588,10 +587,16 @@ export const useLayer = ({ map, enabled, satellites, setSatellites, opacity, con e.stopPropagation(); e.preventDefault(); const name = actionEl.dataset.satName; - const tle1 = actionEl.dataset.tle1; - const tle2 = actionEl.dataset.tle2; - if (name && tle1 && tle2 && window.openSatellitePredict) { - window.openSatellitePredict(name, tle1, tle2); + let omm = null; + if (actionEl.dataset.omm) { + try { + omm = JSON.parse(actionEl.dataset.omm); + } catch (err) { + console.warn('Failed to parse satellite OMM data:', err); + } + } + if (name && omm && window.openSatellitePredict) { + window.openSatellitePredict(name, omm); } return; } @@ -619,7 +624,7 @@ export const useLayer = ({ map, enabled, satellites, setSatellites, opacity, con // Expose satellite prediction panel function useEffect(() => { - const openSatellitePredict = (satName, tle1, tle2) => { + const openSatellitePredict = (satName, omm) => { if (!satName || !satellites) return; // Find the satellite data @@ -629,7 +634,7 @@ export const useLayer = ({ map, enabled, satellites, setSatellites, opacity, con return; } - const orbit = new Orbit(sat.name, `${sat.name}\n${tle1}\n${tle2}`); + const orbit = new Orbit(sat.name, omm); orbit.error && console.warn('Satellite orbit error:', orbit.error); const groundStation = { @@ -808,7 +813,7 @@ export const useLayer = ({ map, enabled, satellites, setSatellites, opacity, con ); // update modal every second, satellite data currentPasses is not updated unless modal is reopened, - // or if satellite layer is updated for instance if TLE data changes + // or if satellite layer is updated for instance if satellite data changes const updatePasses = () => { content.innerHTML = generateModalContent(currentPasses); }; diff --git a/src/utils/orbit.js b/src/utils/orbit.js index 8da01f5f..fc36d09b 100644 --- a/src/utils/orbit.js +++ b/src/utils/orbit.js @@ -30,10 +30,9 @@ const deg2rad = Math.PI / 180; const rad2deg = 180 / Math.PI; export default class Orbit { - constructor(name, tle) { + constructor(name, omm) { this.name = name; - this.tle = tle.split('\n'); - this.satrec = satellitejs.twoline2satrec(this.tle[1], this.tle[2]); + this.satrec = satellitejs.json2satrec(omm); } get satnum() { From e7826d9b81b5965492a359508a92c3748a07272e Mon Sep 17 00:00:00 2001 From: accius Date: Tue, 19 May 2026 22:09:51 -0400 Subject: [PATCH 02/26] fix(dxspider-proxy): stop quiet-band connection churn + fix auth detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The socket idle timeout (60s) was the shortest of all failure timers, so any 60s gap between spots — normal on quiet bands overnight — tore down a healthy connection. Keepalive (120s) was too slow to prevent it and the 180s activity watchdog (the graceful node-failover) never got to run. - socket timeout 60s -> 300s, as a last-resort TCP backstop; the 180s activity watchdog now owns failover as designed - keepalive 120s -> 60s so the connection stays warm - destroy the socket in the 'timeout' handler — Node does not auto-close on timeout, leaving a zombie socket that kept feeding data and spuriously reset the failover counter after [RECONNECT] - mark authenticated on first spot; the DXSpider prompt has no trailing newline so prompt-based detection never matched, and sh/dx output lines ("...de Helmut") false-matched the prompt regex Co-Authored-By: Claude Opus 4.7 (1M context) --- dxspider-proxy/server.js | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/dxspider-proxy/server.js b/dxspider-proxy/server.js index f09894c6..18d18de6 100644 --- a/dxspider-proxy/server.js +++ b/dxspider-proxy/server.js @@ -30,9 +30,13 @@ const CONFIG = { reconnectDelayMs: 10000, // 10 seconds between reconnect attempts maxReconnectAttempts: 3, cleanupIntervalMs: 60000, // 1 minute - keepAliveIntervalMs: 120000, // 2 minutes - send keepalive + keepAliveIntervalMs: 60000, // 1 minute - send keepalive (must stay < socketTimeoutMs) activityTimeoutMs: 180000, // 3 minutes - if no spots, assume dead and failover authTimeoutMs: 30000, // 30 seconds - if no prompt after login, try next node + // Last-resort TCP backstop. Must be LONGER than activityTimeoutMs so the + // graceful node-failover watchdog acts first. A 60s value here used to + // preempt it and tear down healthy connections during quiet-band gaps. + socketTimeoutMs: 300000, // 5 minutes }; // State @@ -242,7 +246,7 @@ const connect = () => { log('CONNECT', `Attempting connection to ${node.name} (${node.host}:${node.port})`); client = new net.Socket(); - client.setTimeout(60000); // 60 second timeout + client.setTimeout(CONFIG.socketTimeoutMs); client.connect(node.port, node.host, () => { connected = true; @@ -313,6 +317,13 @@ const connect = () => { if (spot) { addSpot(spot); resetActivityWatchdog(); // Got a spot, connection is healthy + // A flowing spot is definitive proof login succeeded. The DXSpider + // prompt has no trailing newline so it never arrives as a complete + // line — prompt-based detection alone is unreliable. + if (!authenticated) { + authenticated = true; + log('AUTH', 'Login confirmed (spot stream active)'); + } // Only reset failover counter if connection has been stable for 60s+ // A few spots before a timeout isn't truly healthy — it traps us // on a flaky node that connects briefly then drops @@ -328,8 +339,10 @@ const connect = () => { continue; } - // Detect auth completion - DX Spider sends "callsign de NODE >" prompt - if (!authenticated && /\sde\s+\S+\s*>/.test(trimmed)) { + // Detect auth completion - DX Spider sends "callsign de NODE >" prompt. + // Exclude lines containing '<' — sh/dx output (e.g. "...de Helmut") + // otherwise false-matches this pattern. + if (!authenticated && !trimmed.includes('<') && /\sde\s+\S+\s*>/.test(trimmed)) { authenticated = true; log('AUTH', `Login confirmed: ${trimmed.substring(0, 80)}`); resetActivityWatchdog(); // Auth done, start watching for spots @@ -344,6 +357,16 @@ const connect = () => { client.on('timeout', () => { log('TIMEOUT', 'Connection timed out'); connecting = false; + // Node does NOT auto-close a socket on timeout. Without this teardown the + // old socket keeps emitting 'data' until the next connect(), spuriously + // logging "Connection stable" and resetting the failover counter. + if (client) { + try { + client.removeAllListeners(); + client.destroy(); + } catch (e) {} + client = null; + } handleDisconnect(); }); From 78fc367b4abc7fefa6a61f0d99e91629b385909c Mon Sep 17 00:00:00 2001 From: accius Date: Wed, 20 May 2026 06:47:56 -0400 Subject: [PATCH 03/26] fix(dxcluster): per-fetch AbortController so HamQTH fallback can run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/dxcluster/paths shared one AbortController across the proxy fetch and the HamQTH fallback fetch. When the dxspider-proxy hangs to the 10s limit, controller.abort() fires — and the fallback fetch then receives an already-aborted signal, rejecting instantly with AbortError before HamQTH is ever contacted. newSpots stays empty and the endpoint returns the stale path cache (empty on a fresh server start). Give each upstream its own AbortController + timeout, cleared in a finally block. The HamQTH fallback now actually runs when the proxy is down. Co-Authored-By: Claude Opus 4.7 (1M context) --- server/routes/dxcluster.js | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/server/routes/dxcluster.js b/server/routes/dxcluster.js index a3ed3eb9..95e2c8e7 100644 --- a/server/routes/dxcluster.js +++ b/server/routes/dxcluster.js @@ -1303,8 +1303,6 @@ module.exports = function (app, ctx) { } try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 10000); const now = Date.now(); // Try proxy first for better real-time data @@ -1383,10 +1381,15 @@ module.exports = function (app, ctx) { // Try proxy if not using custom or custom failed if (newSpots.length === 0 && source !== 'custom' && source !== 'udp') { + // Each upstream gets its OWN AbortController. A shared controller meant a + // proxy timeout left the signal pre-aborted, so the HamQTH fallback below + // rejected instantly with AbortError and never actually ran. + const proxyController = new AbortController(); + const proxyTimeout = setTimeout(() => proxyController.abort(), 10000); try { const proxyResponse = await fetch(`${DXSPIDER_PROXY_URL}/api/spots?limit=100`, { headers: { 'User-Agent': 'OpenHamClock/3.14.11' }, - signal: controller.signal, + signal: proxyController.signal, }); if (proxyResponse.ok) { @@ -1409,15 +1412,19 @@ module.exports = function (app, ctx) { } } catch (proxyErr) { logDebug('[DX Paths] Proxy failed, trying HamQTH'); + } finally { + clearTimeout(proxyTimeout); } } // Fallback to HamQTH if proxy failed (never for explicit custom source) if (newSpots.length === 0 && source !== 'custom' && source !== 'udp') { + const hamqthController = new AbortController(); + const hamqthTimeout = setTimeout(() => hamqthController.abort(), 10000); try { const response = await fetch('https://www.hamqth.com/dxc_csv.php?limit=50', { headers: { 'User-Agent': 'OpenHamClock/3.13.1' }, - signal: controller.signal, + signal: hamqthController.signal, }); if (response.ok) { @@ -1469,11 +1476,11 @@ module.exports = function (app, ctx) { } } catch (hamqthErr) { logDebug('[DX Paths] HamQTH also failed'); + } finally { + clearTimeout(hamqthTimeout); } } - clearTimeout(timeout); - if (newSpots.length === 0) { // Return existing paths if fetch failed const validPaths = pathsCache.allPaths.filter((p) => now - p.timestamp < DXPATHS_RETENTION); From bda89ecad3f49304c968cf4c0c08f85072a4a959 Mon Sep 17 00:00:00 2001 From: Michael R Wheeley Date: Thu, 21 May 2026 10:39:39 -0700 Subject: [PATCH 04/26] [satellites] update satellites tracked (#1004) * update satellites tracked // added #41866 GOES-16 celestrak_weather // added #55506 ELEKTRO-L4 celestrak_active // added #67756 ELEKTRO-L5 celestrak_active // removed #53106(payload) = #53109(rocket body) = IO-117(Greencube), satellite decommissioned for all entries generated data_source field showing celestrak group data source * minor name change correction --- server/routes/satellites-tracked.js | 111 ++++++++++++++++------------ 1 file changed, 64 insertions(+), 47 deletions(-) diff --git a/server/routes/satellites-tracked.js b/server/routes/satellites-tracked.js index a2d4a951..432e7e30 100644 --- a/server/routes/satellites-tracked.js +++ b/server/routes/satellites-tracked.js @@ -43,11 +43,17 @@ // removed TEVEL satellites, added TEVEL2 satellites // remove (deactivated) GOES-13 // remove (decayed) SO-124, CAS-4A, CAS-4B, EO-88, XW-2A, XW-2B, XW-2C, XW-2F +// +// May 18, 2026 +// added #41866 GOES-16 celestrak_weather +// added #55506 ELEKTRO-L4 celestrak_active +// added #67756 ELEKTRO-L5 celestrak_active +// removed #53106(payload) = #53109(rocket body) = IO-117(Greencube), satellite decommissioned const HAM_SATELLITES = { // ── High Priority — Popular FM Satellites ────────────────────── ISS: { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 25544, name: 'ISS (ZARYA)', color: '#00ffff', @@ -58,7 +64,7 @@ const HAM_SATELLITES = { tone: '67.0 Hz', }, 'SO-50': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 27607, name: 'SO-50', color: '#00ff00', @@ -70,7 +76,7 @@ const HAM_SATELLITES = { armTone: '74.4 Hz', }, 'AO-91': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 43017, name: 'AO-91 (Fox-1B)', color: '#ff6600', @@ -81,7 +87,7 @@ const HAM_SATELLITES = { tone: '67.0 Hz', }, 'AO-123': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 61781, name: 'AO-123 (ASRTU-1)', color: '#ff3399', @@ -92,7 +98,7 @@ const HAM_SATELLITES = { tone: '67.0 Hz', }, 'SO-125': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 63492, name: 'SO-125 (HADES-ICM)', color: '#ff55bb', @@ -103,7 +109,7 @@ const HAM_SATELLITES = { tone: '67.0 Hz', }, 'QMR-KWT-2': { - // CelesTrak group: active + data_source: 'celestrak_active', norad: 67291, name: 'QMR-KWT-2', color: '#ff88dd', @@ -114,7 +120,7 @@ const HAM_SATELLITES = { tone: '67.0 Hz', }, 'PO-101': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 43678, name: 'PO-101 (DIWATA-2B)', color: '#cc66ff', @@ -126,8 +132,15 @@ const HAM_SATELLITES = { }, // ── Weather Satellites — GOES & METEOR ───────────────────────── + 'GOES-16': { + data_source: 'celestrak_weather', + norad: 41866, + name: 'GOES-16', + color: '#66ff66', + priority: 1, + }, 'GOES-18': { - // CelesTrak group: weather + data_source: 'celestrak_weather', norad: 51850, name: 'GOES-18', color: '#66ff66', @@ -137,7 +150,7 @@ const HAM_SATELLITES = { grbFrequency: '1686.600 MHz', }, 'GOES-19': { - // CelesTrak group: weather + data_source: 'celestrak_weather', norad: 60133, name: 'GOES-19', color: '#33cc33', @@ -147,7 +160,7 @@ const HAM_SATELLITES = { grbFrequency: '1686.600 MHz', }, 'METOP-B': { - // CelesTrak group: weather + data_source: 'celestrak_weather', norad: 38771, name: 'MetOp-B', color: '#FF6600', @@ -156,7 +169,7 @@ const HAM_SATELLITES = { hrptFrequency: '1701.300 MHz', }, 'METOP-C': { - // CelesTrak group: weather + data_source: 'celestrak_weather', norad: 43689, name: 'MetOp-C', color: '#FF8800', @@ -165,7 +178,7 @@ const HAM_SATELLITES = { hrptFrequency: '1701.300 MHz', }, 'METEOR-M2-3': { - // CelesTrak group: weather + data_source: 'celestrak_weather', norad: 57166, name: 'METEOR M2-3', color: '#FF0000', @@ -175,7 +188,7 @@ const HAM_SATELLITES = { hrptFrequency: '1700.000 MHz', }, 'METEOR-M2-4': { - // CelesTrak group: weather + data_source: 'celestrak_weather', norad: 59051, name: 'METEOR M2-4', color: '#FF0000', @@ -187,7 +200,7 @@ const HAM_SATELLITES = { // ── Weather Satellites — Geostationary (non-GOES) ───────────── 'EWS-G2': { - // CelesTrak group: weather + data_source: 'celestrak_weather', norad: 36411, name: 'EWS-G2 (GOES-15)', color: '#0044cc', @@ -197,7 +210,7 @@ const HAM_SATELLITES = { sdFrequency: '1676.000 MHz', }, 'ELEKTRO-L2': { - // CelesTrak group: weather + data_source: 'celestrak_weather', norad: 41105, name: 'ELEKTRO-L2', color: '#ffcc00', @@ -206,7 +219,7 @@ const HAM_SATELLITES = { frequency: '1691.000 MHz', }, 'ELEKTRO-L3': { - // CelesTrak group: active + data_source: 'celestrak_active', norad: 44903, name: 'ELEKTRO-L3', color: '#ff9900', @@ -214,8 +227,22 @@ const HAM_SATELLITES = { mode: 'HRIT/LRIT', frequency: '1691.000 MHz', }, + 'ELEKTRO-L4': { + data_source: 'celestrak_active', + norad: 55506, + name: 'ELEKTRO-L4', + color: '#ff9900', + priority: 2, + }, + 'ELEKTRO-L5': { + data_source: 'celestrak_active', + norad: 67756, + name: 'ELEKTRO-L5', + color: '#ff9900', + priority: 2, + }, 'GK-2A': { - // CelesTrak group: weather + data_source: 'celestrak_weather', norad: 43823, name: 'GK-2A', color: '#ff33cc', @@ -224,7 +251,7 @@ const HAM_SATELLITES = { frequency: '1692.140 MHz', }, 'HIMAWARI-9': { - // CelesTrak group: weather + data_source: 'celestrak_weather', norad: 41836, name: 'HIMAWARI-9', color: '#9900cc', @@ -235,7 +262,7 @@ const HAM_SATELLITES = { // ── Weather Satellites — Polar (X-Band) ─────────────────────── 'NOAA-20': { - // CelesTrak group: weather + data_source: 'celestrak_weather', norad: 43013, name: 'NOAA-20', color: '#00ccff', @@ -244,7 +271,7 @@ const HAM_SATELLITES = { frequency: '7812.000 MHz', }, 'NOAA-21': { - // CelesTrak group: weather + data_source: 'celestrak_weather', norad: 54234, name: 'NOAA-21', color: '#0099ff', @@ -255,7 +282,7 @@ const HAM_SATELLITES = { // ── Linear Transponder Satellites ────────────────────────────── 'RS-44': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 44909, name: 'RS-44 (DOSAAF)', color: '#ff0066', @@ -265,7 +292,7 @@ const HAM_SATELLITES = { uplink: '145.935 - 145.995 MHz', }, 'QO-100': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 43700, name: "QO-100 (Es'hail-2)", color: '#ffff00', @@ -275,7 +302,7 @@ const HAM_SATELLITES = { uplink: '2400.050 - 2400.300 MHz', }, 'AO-7': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 7530, name: 'AO-7', color: '#ffcc00', @@ -285,7 +312,7 @@ const HAM_SATELLITES = { uplink: '432.125 - 432.175 MHz', }, 'FO-29': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 24278, name: 'FO-29 (JAS-2)', color: '#ff6699', @@ -295,7 +322,7 @@ const HAM_SATELLITES = { uplink: '145.900 - 146.000 MHz', }, 'JO-97': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 43803, name: 'JO-97 (JY1Sat)', color: '#cc99ff', @@ -305,7 +332,7 @@ const HAM_SATELLITES = { uplink: '435.100 - 435.120 MHz', }, 'AO-73': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 39444, name: 'AO-73 (FUNcube-1)', color: '#ffcc66', @@ -317,7 +344,7 @@ const HAM_SATELLITES = { // ── CAS (Chinese Amateur Satellites) ─────────────────────────── 'CAS-6': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 44881, name: 'CAS-6 (TO-108)', color: '#cc66ff', @@ -329,7 +356,7 @@ const HAM_SATELLITES = { // ── Digipeaters ──────────────────────────────────────────────── 'IO-86': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 40931, name: 'IO-86 (LAPAN-A2/ORARI)', color: '#33ccaa', @@ -338,76 +365,66 @@ const HAM_SATELLITES = { downlink: '145.825 MHz', uplink: '145.825 MHz', }, - 'IO-117': { - // CelesTrak group: satnogs - norad: 53106, - name: 'IO-117 (GreenCube)', - color: '#00ff99', - priority: 2, - mode: 'Digipeater', - downlink: '435.310 MHz', - uplink: '435.310 MHz', - }, // ── TEVEL2 Constellation — activated periodically ─────────────── 'TEVEL2-1': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 63217, name: 'TEVEL2-1', color: '#66ccff', priority: 3, }, 'TEVEL2-2': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 63219, name: 'TEVEL2-2', color: '#66ccff', priority: 3, }, 'TEVEL2-3': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 63218, name: 'TEVEL2-3', color: '#66ccff', priority: 3, }, 'TEVEL2-4': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 63213, name: 'TEVEL2-4', color: '#66ccff', priority: 3, }, 'TEVEL2-5': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 63214, name: 'TEVEL2-5', color: '#66ccff', priority: 3, }, 'TEVEL2-6': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 63215, name: 'TEVEL2-6', color: '#66ccff', priority: 3, }, 'TEVEL2-7': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 63238, name: 'TEVEL2-7', color: '#66ccff', priority: 3, }, 'TEVEL2-8': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 63239, name: 'TEVEL2-8', color: '#66ccff', priority: 3, }, 'TEVEL2-9': { - // CelesTrak group: amateur + data_source: 'celestrak_amateur', norad: 63237, name: 'TEVEL2-9', color: '#66ccff', From b5c1414d4248a558c13c93dd0e5c904201f8d4a6 Mon Sep 17 00:00:00 2001 From: Michael R Wheeley Date: Thu, 21 May 2026 10:39:44 -0700 Subject: [PATCH 05/26] useLightning remove double space (#1005) --- src/plugins/layers/useLightning.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/layers/useLightning.js b/src/plugins/layers/useLightning.js index 17515731..0ab01787 100644 --- a/src/plugins/layers/useLightning.js +++ b/src/plugins/layers/useLightning.js @@ -697,7 +697,7 @@ export function useLayer({ enabled = false, opacity = 0.9, map = null, lowMemory // Unfortunately, to fit both km and miles in the header we need to override the font size let distStr = isMetric ? ` (${PROXIMITY_RADIUS_KM} km)` : ` (${PROXIMITY_RADIUS_MILES.toFixed(1)} miles)`; - div.innerHTML = `
📍 ${t('plugins.layers.lightning.nearbyStrikes')} ${distStr}
No recent strikes
`; + div.innerHTML = `
📍 ${t('plugins.layers.lightning.nearbyStrikes')}${distStr}
No recent strikes
`; // Prevent map interaction L.DomEvent.disableClickPropagation(div); From 0fcdf06a3080432692345764295378e4e5431c77 Mon Sep 17 00:00:00 2001 From: Michael R Wheeley Date: Thu, 21 May 2026 10:39:49 -0700 Subject: [PATCH 06/26] Newutils cleanup (#1006) * fix require path * - fix state machine unit test - if invalid state is provided then state should gracefully reset - duplicate of same removed --- server/utils/statemachine.js | 2 +- server/utils/statemachine.test.js | 17 +++++------------ 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/server/utils/statemachine.js b/server/utils/statemachine.js index b595f53c..93a8f8e5 100644 --- a/server/utils/statemachine.js +++ b/server/utils/statemachine.js @@ -1,4 +1,4 @@ -const { Mutex, MutexValue, MutexCounter } = require('../utils/mutex'); +const { Mutex, MutexValue, MutexCounter } = require('./mutex'); /** * A simple state machine implementation with safety checks to prevent invalid states and handlers. diff --git a/server/utils/statemachine.test.js b/server/utils/statemachine.test.js index 1a0d488a..4277c70a 100644 --- a/server/utils/statemachine.test.js +++ b/server/utils/statemachine.test.js @@ -44,11 +44,10 @@ describe('StateMachine with invalid inputs', () => { expect(() => new StateMachine(['A'], {})).toThrow(); }); - it('should throw if invalid state index is provided', () => { - expect(() => { - new StateMachine(['A'], { A: () => 'B' }); - sm.run(); // will try to run handler for state 'A' which returns 'B' (invalid) - }).toThrow(); + it('should gracefully reset if invalid state index is provided', async () => { + const sm = new StateMachine(['A'], { A: () => 'B' }); + sm.run(); + expect(await sm.currentState()).toBe('A'); // should reset to 'A' }); { @@ -66,7 +65,7 @@ describe('StateMachine with invalid inputs', () => { expect(await sm.currentState()).toBe('A'); }); - it('should reset if invalid index is provided', async () => { + it('should gracefully reset if invalid index is provided', async () => { const sm = new StateMachine(['A', 'B'], { A: () => 'B', B: () => 'B' }); await sm.run(); expect(await sm.currentState()).toBe('B'); @@ -84,11 +83,5 @@ describe('StateMachine with invalid inputs', () => { expect(await sm.currentState()).toBe('A'); // should reset to 'A' }); } - - it('should reset if next state is invalid', async () => { - const sm = new StateMachine(states, { A: () => 'X', B: () => 'C', C: () => 'C' }); // A returns invalid state 'X' - await sm.run(); // will try to run handler for state 'A' which returns 'X' (invalid) - expect(await sm.currentState()).toBe('A'); // should reset to 'A' - }); } }); From e3dcbbcc7854d2bf7418968d4ea9a905cb385beb Mon Sep 17 00:00:00 2001 From: accius Date: Thu, 21 May 2026 14:04:35 -0400 Subject: [PATCH 07/26] feat(n3fjp): live entry-preview lines + preview color (#979) Adds real-time "as you type" preview support to the N3FJP Logged QSOs layer, based on a working prototype by Ben, KC1UEK. - Server: /api/n3fjp/qso accepts status log|preview|clear; trusts bridge-supplied coords first, falls back to grid then HamQTH; stale previews self-expire after 5 min. - Layer: previews render in their own colour with a dashed arc, bypass the display-window filter, and show a "(preview)" popup. - DX target: layer emits a dedicated ohc-n3fjp-dx-target event; App.jsx moves the DX crosshair via handleDXChange (no WSJT-X channel reuse). - Settings: new Preview color picker in Integrations. Co-Authored-By: Claude Opus 4.7 (1M context) --- server/routes/wsjtx.js | 53 +++++++++++++++--- src/App.jsx | 15 +++++ src/components/SettingsPanel.jsx | 47 ++++++++++++++++ src/plugins/layers/useN3FJPLoggedQSOs.js | 71 ++++++++++++++++++++---- 4 files changed, 169 insertions(+), 17 deletions(-) diff --git a/server/routes/wsjtx.js b/server/routes/wsjtx.js index cf501026..d3ecc889 100644 --- a/server/routes/wsjtx.js +++ b/server/routes/wsjtx.js @@ -812,11 +812,18 @@ module.exports = function (app, ctx) { const N3FJP_QSO_RETENTION_MINUTES = parseInt(process.env.N3FJP_QSO_RETENTION_MINUTES || '1440', 10); let n3fjpQsos = []; + // A transient "as you type" preview self-expires quickly, so a bridge that + // dies mid-entry without sending an explicit clear never leaves a stuck dot. + const N3FJP_PREVIEW_TTL_MS = 5 * 60 * 1000; + function pruneN3fjpQsos() { - const cutoff = Date.now() - N3FJP_QSO_RETENTION_MINUTES * 60 * 1000; + const now = Date.now(); + const cutoff = now - N3FJP_QSO_RETENTION_MINUTES * 60 * 1000; n3fjpQsos = n3fjpQsos.filter((q) => { const t = Date.parse(q.ts_utc || q.timestamp || ''); - return !Number.isNaN(t) && t >= cutoff; + if (Number.isNaN(t)) return false; + if (q.status === 'preview') return now - t < N3FJP_PREVIEW_TTL_MS; + return t >= cutoff; }); } @@ -849,11 +856,27 @@ module.exports = function (app, ctx) { return null; } - // POST one QSO from a bridge (your Python script) + // POST one QSO from the local N3FJP→OHC bridge. + // + // An optional `status` field lets the bridge stream three kinds of update: + // 'log' — a completed, logged QSO. The default when omitted, so older + // bridges that only send logged QSOs keep working unchanged. + // 'preview' — a transient "as you type" entry shown while the operator is + // still filling in the call field. At most one is kept at a time. + // 'clear' — the operator cleared the call field; drop any pending preview. app.post('/api/n3fjp/qso', writeLimiter, requireWriteAuth, async (req, res) => { const qso = req.body || {}; + const status = qso.status === 'preview' || qso.status === 'clear' ? qso.status : 'log'; + + // A 'clear' just drops the pending preview — no callsign needed. + if (status === 'clear') { + n3fjpQsos = n3fjpQsos.filter((q) => q.status !== 'preview'); + return res.json({ ok: true }); + } + if (!qso.dx_call) return res.status(400).json({ ok: false, error: 'dx_call required' }); + qso.status = status; if (!qso.ts_utc) qso.ts_utc = new Date().toISOString(); if (!qso.source) qso.source = 'n3fjp_to_timemapper_udp'; @@ -864,12 +887,26 @@ module.exports = function (app, ctx) { setImmediate(async () => { try { // - // Enrich DX location: GRID → (preferred) → HamQTH fallback + // Enrich DX location: bridge-supplied coords → operating grid → HamQTH. // let locSource = ''; - // 1) Prefer exact operating grid (N3FJP “Grid Rec” field) - if (qso.dx_grid) { + // 1) Trust coordinates the bridge already resolved. This keeps previews + // instant and respects a bridge that does its own QRZ/grid lookup. + // A 0,0 pair is the bridge's "could not resolve" sentinel — ignore it. + if ( + typeof qso.lat === 'number' && + typeof qso.lon === 'number' && + Number.isFinite(qso.lat) && + Number.isFinite(qso.lon) && + !(qso.lat === 0 && qso.lon === 0) + ) { + qso.loc_source = 'bridge'; + locSource = 'bridge'; + } + + // 2) Otherwise prefer the exact operating grid (N3FJP “Grid Rec” field). + if (!locSource && qso.dx_grid) { const loc = maidenheadToLatLon(qso.dx_grid); if (loc) { qso.lat = loc.lat; @@ -879,7 +916,7 @@ module.exports = function (app, ctx) { } } - // 2) If no grid provided, fall back to HamQTH/home QTH lookup + // 3) Last resort: HamQTH / home-QTH lookup by callsign. if (!locSource) { const dx = await lookupCallLatLon(qso.dx_call); if (dx) { @@ -892,6 +929,8 @@ module.exports = function (app, ctx) { } } + // A new preview replaces any earlier one; a logged QSO ends the preview. + n3fjpQsos = n3fjpQsos.filter((q) => q.status !== 'preview'); n3fjpQsos.unshift(qso); pruneN3fjpQsos(); diff --git a/src/App.jsx b/src/App.jsx index 63a3f8a0..637895bb 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -381,6 +381,21 @@ const App = () => { } }, [wsjtx.dxTarget, handleDXChange]); + // ── N3FJP → DX Target ── + // The N3FJP Logged QSOs layer emits this on its own channel when the operator + // types a callsign in the logger, so propagation + beam heading follow the + // previewed station. handleDXChange honours the DX Lock toggle. + useEffect(() => { + const handler = (e) => { + const { lat, lon } = e.detail || {}; + if (lat != null && lon != null) { + handleDXChange({ lat, lon }); + } + }; + window.addEventListener('ohc-n3fjp-dx-target', handler); + return () => window.removeEventListener('ohc-n3fjp-dx-target', handler); + }, [handleDXChange]); + const { satelliteFilters, setSatelliteFilters, filteredSatellites } = useSatellitesFilters(satellites.data); const { diff --git a/src/components/SettingsPanel.jsx b/src/components/SettingsPanel.jsx index 3295c080..3c55ce42 100644 --- a/src/components/SettingsPanel.jsx +++ b/src/components/SettingsPanel.jsx @@ -146,6 +146,13 @@ export const SettingsPanel = ({ return '#3388ff'; } }); + const [n3fjpPreviewLineColor, setN3fjpPreviewLineColor] = useState(() => { + try { + return localStorage.getItem('n3fjp_preview_line_color') || '#ffaa00'; + } catch { + return '#ffaa00'; + } + }); // Monospace font for panels/data displays (#923 — 0/8 readability) const [monoFont, setMonoFont] = useState(() => { @@ -257,10 +264,12 @@ export const SettingsPanel = ({ const v = parseInt(localStorage.getItem('n3fjp_display_minutes') || '15', 10); setN3fjpDisplayMinutes(Number.isFinite(v) ? v : 15); setN3fjpLineColor(localStorage.getItem('n3fjp_line_color') || '#3388ff'); + setN3fjpPreviewLineColor(localStorage.getItem('n3fjp_preview_line_color') || '#ffaa00'); } catch { setN3fjpEnabled(false); setN3fjpDisplayMinutes(15); setN3fjpLineColor('#3388ff'); + setN3fjpPreviewLineColor('#ffaa00'); } }, [isOpen]); @@ -2737,6 +2746,44 @@ export const SettingsPanel = ({ }} /> + +
+ + { + const next = e.target.value || '#ffaa00'; + setN3fjpPreviewLineColor(next); + try { + localStorage.setItem('n3fjp_preview_line_color', next); + } catch {} + try { + window.dispatchEvent(new Event('ohc-n3fjp-config-changed')); + } catch {} + }} + style={{ + width: '100%', + height: 40, + padding: 0, + border: '1px solid var(--border-color)', + borderRadius: 6, + background: 'transparent', + }} + /> +
diff --git a/src/plugins/layers/useN3FJPLoggedQSOs.js b/src/plugins/layers/useN3FJPLoggedQSOs.js index 2472ed39..843f9185 100644 --- a/src/plugins/layers/useN3FJPLoggedQSOs.js +++ b/src/plugins/layers/useN3FJPLoggedQSOs.js @@ -7,13 +7,13 @@ import { getGreatCirclePoints, replicatePath, maidenheadToLatLon } from '../../u export const metadata = { id: 'n3fjp_logged_qsos', name: 'Logged QSOs (N3FJP)', - description: 'Shows recently logged QSOs sent from the N3FJP bridge.', + description: 'Shows recently logged QSOs (and live entry previews) from the N3FJP bridge.', icon: '🗺️', category: 'overlay', localOnly: true, defaultEnabled: false, defaultOpacity: 0.9, - version: '0.2.0', + version: '0.3.0', }; const POLL_MS = 2000; @@ -21,9 +21,10 @@ const POLL_MS = 2000; // --- User settings (persisted) --- const STORAGE_MINUTES_KEY = 'n3fjp_display_minutes'; const STORAGE_COLOR_KEY = 'n3fjp_line_color'; +const STORAGE_PREVIEW_COLOR_KEY = 'n3fjp_preview_line_color'; // Sanitize CSS color values from localStorage to prevent innerHTML injection -const sanitizeColor = (c) => (/^(#[0-9a-f]{3,8}|[a-z]{3,20})$/i.test(c) ? c : '#3388ff'); +const sanitizeColor = (c, fallback = '#3388ff') => (/^(#[0-9a-f]{3,8}|[a-z]{3,20})$/i.test(c) ? c : fallback); export function useLayer({ enabled = false, opacity = 0.9, map = null }) { const [layersRef, setLayersRef] = useState([]); @@ -33,6 +34,9 @@ export function useLayer({ enabled = false, opacity = 0.9, map = null }) { const lastOpenDxCallRef = useRef(null); const suppressReopenRef = useRef(false); + // Tracks the last previewed call pushed to the DX target, so the crosshair is + // nudged only when the typed call actually changes — not on every 2 s poll. + const lastPreviewCallRef = useRef(null); const [displayMinutes, setDisplayMinutes] = useState(() => { const v = parseInt(localStorage.getItem(STORAGE_MINUTES_KEY) || '15', 10); @@ -43,6 +47,10 @@ export function useLayer({ enabled = false, opacity = 0.9, map = null }) { return sanitizeColor(localStorage.getItem(STORAGE_COLOR_KEY) || '#3388ff'); }); + const [previewLineColor, setPreviewLineColor] = useState(() => { + return sanitizeColor(localStorage.getItem(STORAGE_PREVIEW_COLOR_KEY) || '#ffaa00', '#ffaa00'); + }); + // Poll the server for QSOs useEffect(() => { if (!enabled) return; @@ -168,6 +176,10 @@ export function useLayer({ enabled = false, opacity = 0.9, map = null }) { const c = sanitizeColor(localStorage.getItem(STORAGE_COLOR_KEY) || '#3388ff'); setLineColor(c); } catch {} + try { + const pc = sanitizeColor(localStorage.getItem(STORAGE_PREVIEW_COLOR_KEY) || '#ffaa00', '#ffaa00'); + setPreviewLineColor(pc); + } catch {} }; sync(); @@ -193,13 +205,41 @@ export function useLayer({ enabled = false, opacity = 0.9, map = null }) { if (!enabled || !qsos.length) return; - // ---- CLIENT-SIDE FILTER: Show only QSOs newer than X minutes ---- + // ---- CLIENT-SIDE FILTER: recent logged QSOs + any live preview ---- + // Previews are transient "as you type" entries — always shown, never aged out. const cutoff = Date.now() - displayMinutes * 60 * 1000; const recent = qsos.filter((q) => { + if (q.status === 'preview') return true; const t = Date.parse(q.ts_utc || q.ts || ''); return !Number.isNaN(t) && t >= cutoff; }); + // ---- DX target coupling ---- + // While the operator is typing a call in N3FJP, nudge the app's DX target to + // the previewed station so propagation + beam heading follow along. Emitted + // on a dedicated channel; App.jsx listens and honours the DX Lock toggle. + const preview = recent.find((q) => q.status === 'preview'); + if (preview && typeof preview.lat === 'number' && typeof preview.lon === 'number') { + const previewCall = (preview.dx_call || '').trim().toUpperCase(); + if (previewCall && previewCall !== lastPreviewCallRef.current) { + lastPreviewCallRef.current = previewCall; + try { + window.dispatchEvent( + new CustomEvent('ohc-n3fjp-dx-target', { + detail: { + call: previewCall, + grid: preview.dx_grid || '', + lat: preview.lat, + lon: preview.lon, + }, + }), + ); + } catch {} + } + } else if (!preview) { + lastPreviewCallRef.current = null; + } + // If nothing recent, we're done if (!recent.length) return; @@ -254,6 +294,8 @@ export function useLayer({ enabled = false, opacity = 0.9, map = null }) { const dxCall = (q.dx_call || '').trim() || '(unknown)'; const mode = q.mode || ''; + const isPreview = q.status === 'preview'; + const color = isPreview ? previewLineColor : lineColor; // Convert integer kHz (e.g. 14230) to MHz string (e.g. 14.230) let freqMhz = ''; if (typeof q.freq_khz === 'number' && Number.isFinite(q.freq_khz) && q.freq_khz > 0) { @@ -262,7 +304,9 @@ export function useLayer({ enabled = false, opacity = 0.9, map = null }) { const ts = q.ts_utc || ''; const dxMarker = L.circleMarker([lat, lon], { - radius: 6, + radius: isPreview ? 7 : 6, + color, + fillColor: color, opacity, fillOpacity: Math.min(1, opacity * 0.8), }).addTo(map); @@ -289,10 +333,10 @@ export function useLayer({ enabled = false, opacity = 0.9, map = null }) { dxMarker.bindPopup( `
- ${esc(dxCall)}
+ ${esc(dxCall)}${isPreview ? ' (preview)' : ''}
${mode ? `Mode: ${esc(mode)}
` : ''} ${freqMhz ? `Freq: ${esc(freqMhz)} MHz
` : ''} - ${ts ? `Time: ${esc(ts)}
` : ''} + ${isPreview ? 'Typing in N3FJP…
' : ts ? `Time: ${esc(ts)}
` : ''} ${q.dx_country ? `Country: ${esc(q.dx_country)}
` : ''} ${q.loc_source ? `Loc: ${esc(q.loc_source)}
` : ''} ${q.dx_grid ? `Grid: ${esc(q.dx_grid)}
` : ''} @@ -311,13 +355,20 @@ export function useLayer({ enabled = false, opacity = 0.9, map = null }) { }, 0); } - // Draw great circle arc from station -> DX if we have station coords + // Draw great circle arc from station -> DX if we have station coords. + // Preview arcs are dashed so a tentative contact reads differently from a + // logged one at a glance, on top of the colour difference. if (station) { const arcPoints = getGreatCirclePoints(station.lat, station.lon, lat, lon, 64); const segments = replicatePath(arcPoints); segments.forEach((seg) => { if (seg.length < 2) return; - const line = L.polyline(seg, { opacity, color: lineColor, weight: 2 }).addTo(map); + const line = L.polyline(seg, { + opacity, + color, + weight: 2, + dashArray: isPreview ? '6 6' : null, + }).addTo(map); newLayers.push(line); }); } @@ -333,7 +384,7 @@ export function useLayer({ enabled = false, opacity = 0.9, map = null }) { } catch {} }); }; - }, [enabled, qsos, map, opacity, retentionMinutes, displayMinutes, lineColor]); + }, [enabled, qsos, map, opacity, retentionMinutes, displayMinutes, lineColor, previewLineColor]); return { qsoCount: qsos.length, From 68588932d5b3ef3f8c4f9d5d62af7dd68a06881f Mon Sep 17 00:00:00 2001 From: accius Date: Thu, 21 May 2026 14:19:49 -0400 Subject: [PATCH 08/26] fix(satellites) #987: hide satellite status box with the Hide UI toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The satellite status box is appended to the map container rather than .leaflet-control-container, so the Hide UI style block never matched it. Add .sat-data-window to that selector list — same mechanism every other floating map panel already uses. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/WorldMap.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/WorldMap.jsx b/src/components/WorldMap.jsx index 9306f214..a998cec8 100644 --- a/src/components/WorldMap.jsx +++ b/src/components/WorldMap.jsx @@ -2838,7 +2838,7 @@ export const WorldMap = ({
)}