From 033e34a971bf07eb1005d17f6c11869b74af6560 Mon Sep 17 00:00:00 2001 From: ztimson Date: Fri, 11 Sep 2026 11:08:38 -0400 Subject: [PATCH] new mqtt server for tinygs --- server/package-lock.json | 267 +++++++++++++++++++++++++++++++++++++++ server/package.json | 1 + server/src/config.mjs | 3 + server/src/sats.mjs | 115 ++++++++++------- server/src/server.mjs | 3 +- 5 files changed, 339 insertions(+), 50 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index c79de50..d13cdf4 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -11,6 +11,7 @@ "@scalar/express-api-reference": "^0.10.4", "@ztimson/utils": "^0.29.5", "adm-zip": "^0.5.17", + "aedes": "^1.1.2", "better-sqlite3": "^12.11.1", "cheerio": "^1.2.0", "dotenv": "^16.3.1", @@ -90,6 +91,24 @@ "node": ">=20" } }, + "node_modules/@types/node": { + "version": "22.20.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz", + "integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/readable-stream": { + "version": "4.0.24", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.24.tgz", + "integrity": "sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@ztimson/utils": { "version": "0.29.5", "resolved": "https://registry.npmjs.org/@ztimson/utils/-/utils-0.29.5.tgz", @@ -99,6 +118,18 @@ "var-persist": "^1.0.1" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -121,6 +152,66 @@ "node": ">=12.0" } }, + "node_modules/aedes": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aedes/-/aedes-1.1.2.tgz", + "integrity": "sha512-prT8Zr4JkqpJMb5rMlLrW54oRyKPNgpRgqrF6PWQedL4ju+1YIwzkraxr16Rm4QyEkpqmZUdOEkfGp7zaDd5Lw==", + "license": "MIT", + "dependencies": { + "aedes-packet": "^4.0.1", + "aedes-persistence": "^10.3.1", + "mqemitter": "^7.1.0", + "mqtt-packet": "^9.0.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/aedes" + }, + "peerDependencies": { + "aedes-persistence-level": "^9.1.2", + "aedes-persistence-mongodb": "^9.3.1", + "aedes-persistence-redis": "^11.2.1" + }, + "peerDependenciesMeta": { + "aedes-persistence-level": { + "optional": true + }, + "aedes-persistence-mongodb": { + "optional": true + }, + "aedes-persistence-redis": { + "optional": true + } + } + }, + "node_modules/aedes-packet": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/aedes-packet/-/aedes-packet-4.0.1.tgz", + "integrity": "sha512-PLjKJElXEYB66RzHhJCw0SrRLfSmGRbbg2mtO5t/6FAbFp8vC0cG5ErhvPvfa9iKBrKZwoluWLgn8doBU8sRAw==", + "license": "MIT", + "dependencies": { + "mqtt-packet": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/aedes-persistence": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/aedes-persistence/-/aedes-persistence-10.3.1.tgz", + "integrity": "sha512-C/z0zLEXY9PkUH+7pz31H5gGzlQZxqhFONK1lt5VON0hiHXQq6tr4cSIAtFUVQ1kyUdd7SsfkeKLAECp3gjjpw==", + "license": "MIT", + "dependencies": { + "aedes-packet": "^4.0.1", + "qlobber": "^8.0.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -633,6 +724,24 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -688,6 +797,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fastparallel": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz", + "integrity": "sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4", + "xtend": "^4.0.2" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -1024,6 +1143,105 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, + "node_modules/mqemitter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/mqemitter/-/mqemitter-7.1.0.tgz", + "integrity": "sha512-GnBDNz3lxmllW201ne0mrmdy5tPOTnc79jjVcsfUa2LG2pUGeyGWVeiae6ZysfC/64XrYOqCKRAQYrB7pGyBVQ==", + "license": "ISC", + "dependencies": { + "fastparallel": "^2.4.1", + "qlobber": "^8.0.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/mqtt-packet": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.2.tgz", + "integrity": "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==", + "license": "MIT", + "dependencies": { + "bl": "^6.0.8", + "debug": "^4.3.4", + "process-nextick-args": "^2.0.1" + } + }, + "node_modules/mqtt-packet/node_modules/bl": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz", + "integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==", + "license": "MIT", + "dependencies": { + "@types/readable-stream": "^4.0.0", + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^4.2.0" + } + }, + "node_modules/mqtt-packet/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/mqtt-packet/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/mqtt-packet/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mqtt-packet/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -1211,6 +1429,21 @@ "node": ">=10" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1234,6 +1467,15 @@ "once": "^1.3.1" } }, + "node_modules/qlobber": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/qlobber/-/qlobber-8.0.1.tgz", + "integrity": "sha512-O+Wd1chXj5YE1DwmD+ae0bXiSLehmnS3czlC1R9FL/Nt/3q8uMS1bIHmg2lJfCoiimCxClWM8AAuJrF0EvNiog==", + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/qs": { "version": "6.15.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", @@ -1303,6 +1545,16 @@ "node": ">= 6" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1634,6 +1886,12 @@ "node": ">=20.18.1" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -1713,6 +1971,15 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", diff --git a/server/package.json b/server/package.json index 58ad682..53c8175 100644 --- a/server/package.json +++ b/server/package.json @@ -10,6 +10,7 @@ "@scalar/express-api-reference": "^0.10.4", "@ztimson/utils": "^0.29.5", "adm-zip": "^0.5.17", + "aedes": "^1.1.2", "better-sqlite3": "^12.11.1", "cheerio": "^1.2.0", "dotenv": "^16.3.1", diff --git a/server/src/config.mjs b/server/src/config.mjs index 78d4a3b..f746c1b 100644 --- a/server/src/config.mjs +++ b/server/src/config.mjs @@ -12,6 +12,9 @@ export function cfg() { ADSB_URL: process.env.ADSB_URL || '', TINYGS_URL: process.env.TINYGS_URL || '', TINYGS_AUTH: process.env.TINYGS_AUTH || '', + TINYGS_MQTT_PORT: process.env.TINYGS_MQTT_PORT || 1883, + TINYGS_MQTT_USER: process.env.TINYGS_MQTT_USER || 'admin', + TINYGS_MQTT_PASS: process.env.TINYGS_MQTT_PASS || 'admin', DB_HOST: process.env.DB_HOST || 'http://localhost:8428', EMAIL: process.env.EMAIL, LATITUDE: parseFloat(process.env.LATITUDE || '0'), diff --git a/server/src/sats.mjs b/server/src/sats.mjs index 609f7bc..546b022 100644 --- a/server/src/sats.mjs +++ b/server/src/sats.mjs @@ -1,69 +1,86 @@ +import {Aedes} from 'aedes'; +import {createServer} from 'net'; import {cfg} from './config.mjs'; -import {isEqual} from '@ztimson/utils'; const HISTORY_LIMIT = 500; -const POLL_MS = 5_000; const TTL_MS = 60_000; -function parseWm(raw) { - const f = raw.replace(/<[^>]+>/g, '').split(',').map(s => s.trim()); - const [latitude, longitude] = f[15].split('/').map(s => parseFloat(s)); - const [az, el] = f[16].split('/').map(s => parseFloat(s)); - return { - timestamp: Date.now(), - freqMHz: parseFloat(f[3]), - satellite: f[14], - latitude, longitude, - az, el, - packetRssi: parseFloat(f[21]), - packetSnr: parseFloat(f[22]), - freqError: parseFloat(f[23]), - crcOk: !/CRC ERROR/i.test(f[24]), - } -} - const satellites = new Map(); function pruneStale() { const now = Date.now(); - for (const [name, bucket] of satellites) { - if (now - bucket.lastSeen > TTL_MS) satellites.delete(name); + for (const [key, bucket] of satellites) { + if (now - bucket.lastSeen > TTL_MS) satellites.delete(key); } } -export async function pollTinyGS() { - const { TINYGS_URL, TINYGS_AUTH } = cfg(); - try { - const raw = await fetch(TINYGS_URL + '/wm', { - headers: TINYGS_AUTH ? {Authorization: `Basic ${TINYGS_AUTH}`} : {} - }).then(r => r.text()); - const reading = parseWm(raw); - if (!reading.satellite || reading.satellite === '-') return; - - const key = reading.satellite + '-' + reading.freqMHz; - let bucket = satellites.get(key); - if (!bucket) { - bucket = { history: [], lastSeen: 0 }; - satellites.set(key, bucket); - } - - bucket.lastSeen = reading.timestamp; - if (!isEqual(bucket.history.at(-1), reading)) { - bucket.history.push(reading); - if (bucket.history.length > HISTORY_LIMIT) bucket.history.shift(); - } - - pruneStale(); - } catch (err) { - console.error('[tinygs] poll failed', err); - } +function safeJson(buf) { + try { return JSON.parse(buf.toString()); } catch { return null; } } -setInterval(pollTinyGS, POLL_MS); +/** + * Best-effort field mapping — adjust once real TinyGS payload shape is confirmed + * from the console logs. Unknown fields are kept under `raw` for inspection. + */ +function extractReading(topic, payload) { + const data = safeJson(payload) ?? {raw: payload.toString()}; + return { + timestamp: Date.now(), + topic, + satellite: data.satellite ?? data.name ?? data.sat ?? 'unknown', + norad: data.norad ?? data.noradId ?? data.NORAD ?? null, + freqMHz: data.freq ?? data.frequency ?? null, + packetRssi: data.rssi ?? null, + packetSnr: data.snr ?? null, + freqError: data.frequency_error ?? data.freqError ?? null, + crcOk: data.crc_error !== undefined ? !data.crc_error : null, + raw: data, + }; +} + +function handlePacket(topic, payload) { + const reading = extractReading(topic, payload); + const key = reading.norad ? `${reading.satellite}-${reading.norad}` : reading.satellite; + + let bucket = satellites.get(key); + if (!bucket) { + bucket = {history: [], lastSeen: 0}; + satellites.set(key, bucket); + } + bucket.lastSeen = reading.timestamp; + bucket.history.push(reading); + if (bucket.history.length > HISTORY_LIMIT) bucket.history.shift(); + + pruneStale(); +} + +export async function startTinyGSServer() { + const {TINYGS_MQTT_PORT = 1883, TINYGS_MQTT_USER, TINYGS_MQTT_PASS} = cfg(); + const broker = await Aedes.createBroker(); + const server = createServer(broker.handle); + + if (TINYGS_MQTT_USER) { + broker.authenticate = (client, username, password, cb) => { + const ok = username === TINYGS_MQTT_USER && password?.toString() === TINYGS_MQTT_PASS; + if (!ok) console.warn(`[tinygs] rejected auth from ${client.id}`); + cb(null, ok); + }; + } + + broker.on('publish', (packet, client) => { + if (!client || packet.topic.startsWith('$SYS')) return; + console.info(`[tinygs] ${packet.topic}`, packet.payload.toString().slice(0, 200)); + handlePacket(packet.topic, packet.payload); + }); + broker.on('client', client => console.info(`[tinygs] connected: ${client.id}`)); + broker.on('clientDisconnect', client => console.info(`[tinygs] disconnected: ${client.id}`)); + + server.listen(TINYGS_MQTT_PORT, () => console.info(`🛜 MQTT broker - http://localhost:${TINYGS_MQTT_PORT}`)); +} export const getTinyGSData = () => { pruneStale(); - return Array.from(satellites.entries()).map(([name, bucket]) => ({ + return Array.from(satellites.values()).map(bucket => ({ ...bucket.history.at(-1), history: bucket.history.slice(0, -1), })); diff --git a/server/src/server.mjs b/server/src/server.mjs index bac05ff..40d7e50 100644 --- a/server/src/server.mjs +++ b/server/src/server.mjs @@ -12,7 +12,7 @@ import {getADSBImage, getADSB, getADSBHistory, getADSBRange, initAircraftDb} fro import {fetchIcon} from './openweather.mjs'; import {getForecast, getWeatherCondition, forecastTTL} from './forecast.mjs'; import {dailyWeather, hourlyWeather} from './openmeteo.mjs'; -import {getTinyGSData} from './sats.mjs'; +import {getTinyGSData, startTinyGSServer} from './sats.mjs'; // ── Uncaught error handlers ─────────────────────────────────────────────────── @@ -209,6 +209,7 @@ const c = cfg(); setTimeout(getForecast, 1) setInterval(getForecast, forecastTTL) await initAircraftDb(); +await startTinyGSServer(); app.listen(c.PORT, () => { console.log(`⛅ Weather API — http://localhost:${c.PORT}`)