From 5a078df1120eff9e2cf936103d8c8b8a78a51154 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20=C5=A0karvada?= Date: Wed, 15 Apr 2026 17:46:23 +0200 Subject: [PATCH] Added TOTA Get API key from info@rozhledny.eu. Fixes #21 --- config_clean.js | 10 ++++ hamutil.js | 2 + notify/email.js | 10 +++- notify/telnetconn.js | 3 ++ server.js | 49 +++++++++++++++++-- tools/updateWwtota.js | 45 ++++++++++++++++++ wwtotaspots.js | 107 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 222 insertions(+), 4 deletions(-) create mode 100644 tools/updateWwtota.js create mode 100644 wwtotaspots.js diff --git a/config_clean.js b/config_clean.js index b0d0e44..09acd8d 100644 --- a/config_clean.js +++ b/config_clean.js @@ -20,6 +20,14 @@ config.wwff = { listUrl: 'http://wwff.co/wwff-data/wwff_directory.csv' }; +config.wwtota = { + apiKey: '', + spotsUrl: 'https://wwtota.com/apidata/cluster.php', + listUrl: 'https://wwtota.com/apidata/tower.php', + refreshInterval: 60*1000, + spotMaxAge: 5*60*1000 +}; + config.mongodb = { url: 'mongodb://hamalert:@localhost:27017/hamalert', dbName: 'hamalert' @@ -292,6 +300,7 @@ config.matcher = { 'summitRegion', 'summitRef', 'wwffRef', + 'wwtotaRef', 'mode', 'band', 'spotter', @@ -327,6 +336,7 @@ config.matcher = { 'summitRegion', 'summitRef', 'wwffRef', + 'wwtotaRef', 'spotter', 'spotterPrefix', 'daysOfWeek', diff --git a/hamutil.js b/hamutil.js index f0c551d..f2bf691 100644 --- a/hamutil.js +++ b/hamutil.js @@ -57,6 +57,8 @@ exports.makeSpotParams = function(spot, comment, actions) { wwffRef: spot.wwffRef, wwffDivision: spot.wwffDivision, wwffName: spot.wwffName, + wwtotaRef: spot.wwtotaRef, + wwtotaName: spot.wwtotaName, iotaGroupRef: spot.iotaGroupRef, iotaGroupName: spot.iotaGroupName }; diff --git a/notify/email.js b/notify/email.js index 68fe82e..c04a206 100644 --- a/notify/email.js +++ b/notify/email.js @@ -125,7 +125,15 @@ class EmailNotifier extends Notifier { text += `Park name: ${spot.wwffName}\n`; } } - + + if (spot.wwtotaRef) { + text += "\n"; + text += `Tower ref: ${spot.wwtotaRef}\n`; + if (spot.wwtotaName) { + text += `Tower name: ${spot.wwtotaName}\n`; + } + } + if (spot.iotaGroupRef) { text += "\n"; text += `IOTA ref: ${spot.iotaGroupRef}\n`; diff --git a/notify/telnetconn.js b/notify/telnetconn.js index 494850e..74a9187 100644 --- a/notify/telnetconn.js +++ b/notify/telnetconn.js @@ -167,6 +167,9 @@ class TelnetConnection extends EventEmitter { if (spot.wwffRef) { commentElements.push(spot.wwffRef); } + if (spot.wwtotaRef) { + commentElements.push(spot.wwtotaRef); + } if (spot.iotaGroupRef) { commentElements.push(spot.iotaGroupRef); } diff --git a/server.js b/server.js index 94fc6f8..f35ddeb 100644 --- a/server.js +++ b/server.js @@ -1,6 +1,7 @@ const util = require('util'); const SotaSpotReceiver = require('./sotaspots'); const PotaSpotReceiver = require('./potaspots'); +const WwtotaSpotReceiver = require('./wwtotaspots'); const RbnReceiver = require('./rbn'); const PskReporterReceiver = require('./pskreporter'); const ClusterReceiver = require('./cluster'); @@ -30,6 +31,7 @@ const config = require('./config'); const summitRefRegex = /([a-zA-Z0-9]{1,8}\/[a-zA-Z]{2})\-?((?:[0-9][0-9][1-9])|(?:[0-9][1-9][0])|(?:[1-9][0-9][0]))/; const sotaRefRegex = /^(.+)\/(.+)\-(\d+)$/; const wwffRefRegex = /\b([a-z0-9]{1,5})-(\d{4})\b/i; +const wwtotaRefRegex = /\b([a-z]{3})-(\d{4})\b/i; const iotaRefRegex = /(?:^|\s)(AF|AN|AS|EU|NA|OC|SA)[ -]?(\d{3})\b/i; const potaLocationStateRegex = /^((US|CA)-..,?)+$/; @@ -85,7 +87,11 @@ function startReceivers() { let potaSpotReceiver = new PotaSpotReceiver(db); potaSpotReceiver.on('spot', notifySpot); potaSpotReceiver.start(); - + + let wwtotaSpotReceiver = new WwtotaSpotReceiver(db); + wwtotaSpotReceiver.on('spot', notifySpot); + wwtotaSpotReceiver.start(); + config.rbn.forEach(rbnConfig => { let rbnReceiver = new RbnReceiver(rbnConfig); rbnReceiver.on('spot', notifySpot); @@ -136,7 +142,7 @@ function runMatcher(spot) { // Find matching triggers using matcher via JSON-RPC let conditions = {}; - let fields = ['source', 'callsign', 'fullCallsign', 'summitAssociation', 'summitRegion', 'summitPoints', 'summitActivations', 'summitRef', 'wwffRef', 'iotaGroupRef', 'mode', 'time', 'spotter', 'state', 'spotterState', 'qsl', 'prefix', 'spotterPrefix', 'speed', 'snr']; + let fields = ['source', 'callsign', 'fullCallsign', 'summitAssociation', 'summitRegion', 'summitPoints', 'summitActivations', 'summitRef', 'wwffRef', 'wwtotaRef', 'iotaGroupRef', 'mode', 'time', 'spotter', 'state', 'spotterState', 'qsl', 'prefix', 'spotterPrefix', 'speed', 'snr']; for (let field of fields) { if (spot[field] !== undefined) { conditions[field] = spot[field]; @@ -163,7 +169,11 @@ function runMatcher(spot) { if (spot.wwffDivision) { conditions.wwffDivision = [spot.wwffDivision, "*"]; } - + + if (spot.wwtotaRef) { + conditions.wwtotaRef = [spot.wwtotaRef, "*"]; + } + if (spot.iotaGroupRef) { conditions.iotaGroupRef = [spot.iotaGroupRef, "*"]; } @@ -418,6 +428,11 @@ function normalizeSpot(spot, callback) { findIotaGroupRef(spot, callback); }, + // Find WWTOTA ref + (callback) => { + findWwtotaRef(spot, callback); + }, + // Find callsign info (callback) => { findCallsignInfo(spot, callback); @@ -532,6 +547,34 @@ function findIotaGroupRef(spot, callback) { } } +function findWwtotaRef(spot, callback) { + // Find a WWTOTA reference in the spot comment, and populate the WWTOTA fields if + // a valid reference has been found + if (spot.wwtotaRef) { + // Already have a WWTOTA reference + callback(); + return; + } + + let matches = wwtotaRefRegex.exec(spot.rawText); + if (matches) { + // Look up tower ref in database to be sure + let towerRef = matches[1].toUpperCase() + '-' + matches[2]; + db.collection('wwtotaTowers').findOne({Ref: towerRef}, {}, (err, tower) => { + if (tower) { + spot.wwtotaRef = towerRef; + spot.wwtotaName = tower.Name; + } else { + console.info(`Tower ${towerRef} not found in database`); + } + + callback(); + }); + } else { + callback(); + } +} + function findCallsignInfo(spot, callback) { db.collection('callsignInfo').findOne({callsign: spot.fullCallsign}, {}, (err, callsignInfo) => { if (callsignInfo) { diff --git a/tools/updateWwtota.js b/tools/updateWwtota.js new file mode 100644 index 0000000..5de0bfe --- /dev/null +++ b/tools/updateWwtota.js @@ -0,0 +1,45 @@ +const axios = require('axios'); +const MongoClient = require('mongodb').MongoClient; +const config = require('../config'); +const assert = require('assert'); + +let client = new MongoClient(config.mongodb.url) +client.connect(async (err) => { + assert.equal(null, err); + let db = client.db(config.mongodb.dbName); + + await processWwtotaList(db); + client.close(); +}); + +async function processWwtotaList(db) { + let response = await axios.get(config.wwtota.listUrl, { + headers: { + 'User-Agent': 'HamAlert/1.0 (+https://hamalert.org)' + }, + params: { + key: config.wwtota.apiKey, + } + }); + + // Loop through towers + let towers = []; + for (let towerInfo of response.data) { + let tower = { + 'Ref': towerInfo.ref, + 'Name': towerInfo.name, + 'Lat': towerInfo.lat, + 'Lon': towerInfo.lon, + }; + + towers.push(tower); + } + + if (towers.length < 2000) { + throw new Error("Bad number of WWTOTA towers, expecting more than 2000"); + } + + let collection = db.collection('wwtotaTowers'); + await collection.deleteMany({manual: {$not: {$eq: true}}}); + await collection.insertMany(towers); +} diff --git a/wwtotaspots.js b/wwtotaspots.js new file mode 100644 index 0000000..0e66cd9 --- /dev/null +++ b/wwtotaspots.js @@ -0,0 +1,107 @@ +const axios = require('axios'); +const config = require('./config'); +const EventEmitter = require('events'); +const util = require('util'); +const crypto = require('crypto'); +const sprintf = require('sprintf'); + +class WwtotaSpotReceiver extends EventEmitter { + constructor(db) { + super(); + this.db = db; + this.lastProcessedTime = null; + } + + start() { + setInterval(() => { + this.refreshSpots(); + }, config.wwtota.refreshInterval); + this.refreshSpots(); + } + + async refreshSpots() { + console.log("Refreshing WWTOTA JSON feed"); + + let response = await axios.get(config.wwtota.spotsUrl, { + headers: { + 'User-Agent': 'HamAlert/1.0 (+https://hamalert.org)' + }, + params: { + key: config.wwtota.apiKey, + } + }) + if (response.status !== 200) { + throw `Bad status code ${response.statusCode} from WWTOTA`; + } + + if (!Array.isArray(response.data.spots)) { + throw `Expected array from WWTOTA, but got something else`; + } + + // reverse to process oldest to newest + response.data.spots.reverse() + response.data.spots.forEach(spot => { + this.processJsonSpot(spot) + }); + if (response.data.spots.length > 0) { + this.lastProcessedTime = response.data.spots[response.data.spots.length - 1].time; // Previously reversed, so newest last + } + } + + processJsonSpot(jsonSpot) { + try { + jsonSpot.time = new Date(jsonSpot.time_utc); + + // Check if spot newer that last batch processed + // edited spot have new timestamp as well + if (this.lastProcessedTime && this.lastProcessedTime >= jsonSpot.time) { + return; + } + + // Ignore old spots + if ((new Date() - jsonSpot.spotTime) > config.wwtota.spotMaxAge) { + return; + } + + jsonSpot.callsign = jsonSpot.callsign.toUpperCase().replace(/\s/g, ''); + + // Clean up frequency + let frequency = sprintf("%.4f", jsonSpot.frequency / 1000); + let spot = { + source: 'wwtota', + time: jsonSpot.time.toISOString().substring(11, 16), + fullCallsign: jsonSpot.callsign, + wwtotaRef: jsonSpot.tower_ref, + wwtotaName: jsonSpot.tower_name, + frequency, + mode: jsonSpot.mode.toLowerCase().trim(), + comment: "", + spotter: jsonSpot.spotter.toUpperCase().trim().replace('-#', '') + }; + + spot.rawText = `${spot.time} ${spot.fullCallsign} in ${spot.wwtotaRef} (${spot.wwtotaName}) ${spot.frequency}`; + if (spot.mode) { + spot.rawText += ` ${spot.mode.toUpperCase()}`; + } + if (spot.comment) { + spot.rawText += `: ${spot.comment}`; + } + spot.title = `WWTOTA ${spot.fullCallsign} in ${spot.wwtotaRef} (${spot.frequency}`; + if (spot.mode) { + spot.title += " " + spot.mode.toUpperCase(); + } + spot.title += ")"; + + //console.log(spot); + this.emit("spot", spot); + + } catch (e) { + console.error("Exception while processing WWTOTA spot", e); + } + } +} + +let rec = new WwtotaSpotReceiver(); +rec.start(); + +module.exports = WwtotaSpotReceiver;