|
|
|
@ -207,7 +207,7 @@ app.use('/api', (req, res, next) => {
|
|
|
|
} else if (path.includes('/pota') || path.includes('/sota')) {
|
|
|
|
} else if (path.includes('/pota') || path.includes('/sota')) {
|
|
|
|
cacheDuration = 120; // 2 minutes
|
|
|
|
cacheDuration = 120; // 2 minutes
|
|
|
|
} else if (path.includes('/pskreporter')) {
|
|
|
|
} else if (path.includes('/pskreporter')) {
|
|
|
|
cacheDuration = 120; // 2 minutes (respect PSKReporter rate limits)
|
|
|
|
cacheDuration = 300; // 5 minutes (PSKReporter rate limits aggressively)
|
|
|
|
} else if (path.includes('/dxcluster') || path.includes('/myspots')) {
|
|
|
|
} else if (path.includes('/dxcluster') || path.includes('/myspots')) {
|
|
|
|
cacheDuration = 30; // 30 seconds (DX spots need to be relatively fresh)
|
|
|
|
cacheDuration = 30; // 30 seconds (DX spots need to be relatively fresh)
|
|
|
|
} else if (path.includes('/config')) {
|
|
|
|
} else if (path.includes('/config')) {
|
|
|
|
@ -1853,56 +1853,40 @@ app.get('/api/myspots/:callsign', async (req, res) => {
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================
|
|
|
|
// ============================================
|
|
|
|
// PSKREPORTER API
|
|
|
|
// PSKREPORTER API (MQTT-based for real-time)
|
|
|
|
// ============================================
|
|
|
|
// ============================================
|
|
|
|
|
|
|
|
|
|
|
|
// Cache for PSKReporter data (2-minute cache to respect their rate limits)
|
|
|
|
// PSKReporter MQTT feed at mqtt.pskreporter.info provides real-time spots
|
|
|
|
let pskReporterCache = {};
|
|
|
|
// WebSocket endpoints: 1885 (ws), 1886 (wss)
|
|
|
|
const PSK_CACHE_TTL = 2 * 60 * 1000; // 2 minutes
|
|
|
|
// Topic format: pskr/filter/v2/{band}/{mode}/{sendercall}/{receivercall}/{senderlocator}/{receiverlocator}/{sendercountry}/{receivercountry}
|
|
|
|
|
|
|
|
|
|
|
|
// Parse PSKReporter XML response
|
|
|
|
// Cache for PSKReporter data - stores recent spots from MQTT
|
|
|
|
function parsePSKReporterXML(xml) {
|
|
|
|
const pskReporterSpots = {
|
|
|
|
const reports = [];
|
|
|
|
tx: new Map(), // Map of callsign -> spots where they're being heard
|
|
|
|
|
|
|
|
rx: new Map(), // Map of callsign -> spots they're receiving
|
|
|
|
// Extract reception reports using regex (simple XML parsing)
|
|
|
|
maxAge: 60 * 60 * 1000 // Keep spots for 1 hour max
|
|
|
|
const reportRegex = /<receptionReport[^>]*>([\s\S]*?)<\/receptionReport>/g;
|
|
|
|
};
|
|
|
|
let match;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
while ((match = reportRegex.exec(xml)) !== null) {
|
|
|
|
|
|
|
|
const report = match[0];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Extract attributes
|
|
|
|
|
|
|
|
const getAttr = (name) => {
|
|
|
|
|
|
|
|
const attrMatch = report.match(new RegExp(`${name}="([^"]*)"`));
|
|
|
|
|
|
|
|
return attrMatch ? attrMatch[1] : null;
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const receiverCallsign = getAttr('receiverCallsign');
|
|
|
|
// Clean up old spots periodically
|
|
|
|
const receiverLocator = getAttr('receiverLocator');
|
|
|
|
setInterval(() => {
|
|
|
|
const senderCallsign = getAttr('senderCallsign');
|
|
|
|
const cutoff = Date.now() - pskReporterSpots.maxAge;
|
|
|
|
const senderLocator = getAttr('senderLocator');
|
|
|
|
for (const [call, spots] of pskReporterSpots.tx) {
|
|
|
|
const frequency = getAttr('frequency');
|
|
|
|
const filtered = spots.filter(s => s.timestamp > cutoff);
|
|
|
|
const mode = getAttr('mode');
|
|
|
|
if (filtered.length === 0) {
|
|
|
|
const flowStartSeconds = getAttr('flowStartSeconds');
|
|
|
|
pskReporterSpots.tx.delete(call);
|
|
|
|
const sNR = getAttr('sNR');
|
|
|
|
} else {
|
|
|
|
|
|
|
|
pskReporterSpots.tx.set(call, filtered);
|
|
|
|
if (receiverCallsign && senderCallsign) {
|
|
|
|
|
|
|
|
reports.push({
|
|
|
|
|
|
|
|
receiver: receiverCallsign,
|
|
|
|
|
|
|
|
receiverGrid: receiverLocator,
|
|
|
|
|
|
|
|
sender: senderCallsign,
|
|
|
|
|
|
|
|
senderGrid: senderLocator,
|
|
|
|
|
|
|
|
freq: frequency ? (parseInt(frequency) / 1000000).toFixed(6) : null,
|
|
|
|
|
|
|
|
freqMHz: frequency ? (parseInt(frequency) / 1000000).toFixed(3) : null,
|
|
|
|
|
|
|
|
mode: mode || 'Unknown',
|
|
|
|
|
|
|
|
timestamp: flowStartSeconds ? parseInt(flowStartSeconds) * 1000 : Date.now(),
|
|
|
|
|
|
|
|
snr: sNR ? parseInt(sNR) : null
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
for (const [call, spots] of pskReporterSpots.rx) {
|
|
|
|
return reports;
|
|
|
|
const filtered = spots.filter(s => s.timestamp > cutoff);
|
|
|
|
}
|
|
|
|
if (filtered.length === 0) {
|
|
|
|
|
|
|
|
pskReporterSpots.rx.delete(call);
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
pskReporterSpots.rx.set(call, filtered);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}, 5 * 60 * 1000); // Clean every 5 minutes
|
|
|
|
|
|
|
|
|
|
|
|
// Convert grid square to lat/lon
|
|
|
|
// Convert grid square to lat/lon
|
|
|
|
function gridToLatLonSimple(grid) {
|
|
|
|
function gridToLatLonSimple(grid) {
|
|
|
|
@ -1928,9 +1912,9 @@ function gridToLatLonSimple(grid) {
|
|
|
|
return { lat: finalLat, lon: finalLon };
|
|
|
|
return { lat: finalLat, lon: finalLon };
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Get band name from frequency in MHz
|
|
|
|
// Get band name from frequency in Hz
|
|
|
|
function getBandFromMHz(freqMHz) {
|
|
|
|
function getBandFromHz(freqHz) {
|
|
|
|
const freq = parseFloat(freqMHz);
|
|
|
|
const freq = freqHz / 1000000; // Convert to MHz
|
|
|
|
if (freq >= 1.8 && freq <= 2) return '160m';
|
|
|
|
if (freq >= 1.8 && freq <= 2) return '160m';
|
|
|
|
if (freq >= 3.5 && freq <= 4) return '80m';
|
|
|
|
if (freq >= 3.5 && freq <= 4) return '80m';
|
|
|
|
if (freq >= 5.3 && freq <= 5.4) return '60m';
|
|
|
|
if (freq >= 5.3 && freq <= 5.4) return '60m';
|
|
|
|
@ -1947,171 +1931,159 @@ function getBandFromMHz(freqMHz) {
|
|
|
|
return 'Unknown';
|
|
|
|
return 'Unknown';
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// PSKReporter - where is my signal being heard?
|
|
|
|
// PSKReporter endpoint - returns MQTT connection info for frontend
|
|
|
|
app.get('/api/pskreporter/tx/:callsign', async (req, res) => {
|
|
|
|
// The frontend connects directly to MQTT via WebSocket for real-time updates
|
|
|
|
|
|
|
|
app.get('/api/pskreporter/config', (req, res) => {
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
|
|
|
|
mqtt: {
|
|
|
|
|
|
|
|
host: 'mqtt.pskreporter.info',
|
|
|
|
|
|
|
|
wsPort: 1885, // WebSocket
|
|
|
|
|
|
|
|
wssPort: 1886, // WebSocket + TLS (recommended)
|
|
|
|
|
|
|
|
topicPrefix: 'pskr/filter/v2'
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
// Topic format: pskr/filter/v2/{band}/{mode}/{sendercall}/{receivercall}/{senderlocator}/{receiverlocator}/{sendercountry}/{receivercountry}
|
|
|
|
|
|
|
|
// Use + for single-level wildcard, # for multi-level
|
|
|
|
|
|
|
|
// Example for TX (being heard): pskr/filter/v2/+/+/{CALLSIGN}/#
|
|
|
|
|
|
|
|
// Example for RX (hearing): Subscribe and filter client-side
|
|
|
|
|
|
|
|
info: 'Connect via WebSocket to mqtt.pskreporter.info:1886 (wss) for real-time spots'
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Fallback HTTP endpoint for when MQTT isn't available
|
|
|
|
|
|
|
|
// Uses the traditional retrieve API with caching
|
|
|
|
|
|
|
|
let pskHttpCache = {};
|
|
|
|
|
|
|
|
const PSK_HTTP_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app.get('/api/pskreporter/http/:callsign', async (req, res) => {
|
|
|
|
const callsign = req.params.callsign.toUpperCase();
|
|
|
|
const callsign = req.params.callsign.toUpperCase();
|
|
|
|
const minutes = parseInt(req.query.minutes) || 15; // Default 15 minutes
|
|
|
|
const minutes = parseInt(req.query.minutes) || 15;
|
|
|
|
const flowStartSeconds = Math.floor(minutes * 60);
|
|
|
|
const direction = req.query.direction || 'tx'; // tx or rx
|
|
|
|
|
|
|
|
// flowStartSeconds must be NEGATIVE for "last N seconds"
|
|
|
|
|
|
|
|
const flowStartSeconds = -Math.abs(minutes * 60);
|
|
|
|
|
|
|
|
|
|
|
|
const cacheKey = `tx:${callsign}:${minutes}`;
|
|
|
|
const cacheKey = `${direction}:${callsign}:${minutes}`;
|
|
|
|
const now = Date.now();
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
|
|
|
|
|
|
// Check cache
|
|
|
|
// Check cache
|
|
|
|
if (pskReporterCache[cacheKey] && (now - pskReporterCache[cacheKey].timestamp) < PSK_CACHE_TTL) {
|
|
|
|
if (pskHttpCache[cacheKey] && (now - pskHttpCache[cacheKey].timestamp) < PSK_HTTP_CACHE_TTL) {
|
|
|
|
return res.json(pskReporterCache[cacheKey].data);
|
|
|
|
return res.json({ ...pskHttpCache[cacheKey].data, cached: true });
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
console.log(`[PSKReporter] Fetching TX reports for ${callsign} (last ${minutes} min)`);
|
|
|
|
const param = direction === 'tx' ? 'senderCallsign' : 'receiverCallsign';
|
|
|
|
|
|
|
|
// Add appcontact parameter as requested by PSKReporter developer docs
|
|
|
|
|
|
|
|
const url = `https://retrieve.pskreporter.info/query?${param}=${encodeURIComponent(callsign)}&flowStartSeconds=${flowStartSeconds}&rronly=1&appcontact=openhamclock`;
|
|
|
|
|
|
|
|
|
|
|
|
const url = `https://retrieve.pskreporter.info/query?senderCallsign=${encodeURIComponent(callsign)}&flowStartSeconds=${flowStartSeconds}&rronly=1&noactive=1&nolocator=1`;
|
|
|
|
console.log(`[PSKReporter HTTP] Fetching ${direction} for ${callsign} (last ${minutes} min)`);
|
|
|
|
|
|
|
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
const controller = new AbortController();
|
|
|
|
const timeout = setTimeout(() => controller.abort(), 15000);
|
|
|
|
const timeout = setTimeout(() => controller.abort(), 20000);
|
|
|
|
|
|
|
|
|
|
|
|
const response = await fetch(url, {
|
|
|
|
const response = await fetch(url, {
|
|
|
|
headers: {
|
|
|
|
headers: {
|
|
|
|
'User-Agent': 'OpenHamClock/3.10',
|
|
|
|
'User-Agent': 'OpenHamClock/3.11 (Amateur Radio Dashboard)',
|
|
|
|
'Accept': 'application/xml'
|
|
|
|
'Accept': '*/*'
|
|
|
|
},
|
|
|
|
},
|
|
|
|
signal: controller.signal
|
|
|
|
signal: controller.signal
|
|
|
|
});
|
|
|
|
});
|
|
|
|
clearTimeout(timeout);
|
|
|
|
clearTimeout(timeout);
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
if (!response.ok) {
|
|
|
|
throw new Error(`PSKReporter returned ${response.status}`);
|
|
|
|
throw new Error(`HTTP ${response.status}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const xml = await response.text();
|
|
|
|
const xml = await response.text();
|
|
|
|
const reports = parsePSKReporterXML(xml);
|
|
|
|
const reports = [];
|
|
|
|
|
|
|
|
|
|
|
|
// Add location data and band info
|
|
|
|
// Parse XML response
|
|
|
|
const enrichedReports = reports.map(r => {
|
|
|
|
const reportRegex = /<receptionReport[^>]*>/g;
|
|
|
|
const loc = r.receiverGrid ? gridToLatLonSimple(r.receiverGrid) : null;
|
|
|
|
let match;
|
|
|
|
return {
|
|
|
|
while ((match = reportRegex.exec(xml)) !== null) {
|
|
|
|
...r,
|
|
|
|
const report = match[0];
|
|
|
|
lat: loc?.lat,
|
|
|
|
const getAttr = (name) => {
|
|
|
|
lon: loc?.lon,
|
|
|
|
const m = report.match(new RegExp(`${name}="([^"]*)"`));
|
|
|
|
band: r.freqMHz ? getBandFromMHz(r.freqMHz) : 'Unknown',
|
|
|
|
return m ? m[1] : null;
|
|
|
|
age: Math.floor((Date.now() - r.timestamp) / 60000) // minutes ago
|
|
|
|
|
|
|
|
};
|
|
|
|
};
|
|
|
|
}).filter(r => r.lat && r.lon);
|
|
|
|
|
|
|
|
|
|
|
|
const receiverCallsign = getAttr('receiverCallsign');
|
|
|
|
|
|
|
|
const receiverLocator = getAttr('receiverLocator');
|
|
|
|
|
|
|
|
const senderCallsign = getAttr('senderCallsign');
|
|
|
|
|
|
|
|
const senderLocator = getAttr('senderLocator');
|
|
|
|
|
|
|
|
const frequency = getAttr('frequency');
|
|
|
|
|
|
|
|
const mode = getAttr('mode');
|
|
|
|
|
|
|
|
const flowStartSecs = getAttr('flowStartSeconds');
|
|
|
|
|
|
|
|
const sNR = getAttr('sNR');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (receiverCallsign && senderCallsign) {
|
|
|
|
|
|
|
|
const locator = direction === 'tx' ? receiverLocator : senderLocator;
|
|
|
|
|
|
|
|
const loc = locator ? gridToLatLonSimple(locator) : null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
reports.push({
|
|
|
|
|
|
|
|
sender: senderCallsign,
|
|
|
|
|
|
|
|
senderGrid: senderLocator,
|
|
|
|
|
|
|
|
receiver: receiverCallsign,
|
|
|
|
|
|
|
|
receiverGrid: receiverLocator,
|
|
|
|
|
|
|
|
freq: frequency ? parseInt(frequency) : null,
|
|
|
|
|
|
|
|
freqMHz: frequency ? (parseInt(frequency) / 1000000).toFixed(3) : null,
|
|
|
|
|
|
|
|
band: frequency ? getBandFromHz(parseInt(frequency)) : 'Unknown',
|
|
|
|
|
|
|
|
mode: mode || 'Unknown',
|
|
|
|
|
|
|
|
timestamp: flowStartSecs ? parseInt(flowStartSecs) * 1000 : Date.now(),
|
|
|
|
|
|
|
|
snr: sNR ? parseInt(sNR) : null,
|
|
|
|
|
|
|
|
lat: loc?.lat,
|
|
|
|
|
|
|
|
lon: loc?.lon,
|
|
|
|
|
|
|
|
age: flowStartSecs ? Math.floor((Date.now() / 1000 - parseInt(flowStartSecs)) / 60) : 0
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Sort by timestamp (newest first)
|
|
|
|
// Sort by timestamp (newest first)
|
|
|
|
enrichedReports.sort((a, b) => b.timestamp - a.timestamp);
|
|
|
|
reports.sort((a, b) => b.timestamp - a.timestamp);
|
|
|
|
|
|
|
|
|
|
|
|
const result = {
|
|
|
|
const result = {
|
|
|
|
callsign,
|
|
|
|
callsign,
|
|
|
|
direction: 'tx',
|
|
|
|
direction,
|
|
|
|
count: enrichedReports.length,
|
|
|
|
count: reports.length,
|
|
|
|
reports: enrichedReports.slice(0, 100), // Limit to 100 reports
|
|
|
|
reports: reports.slice(0, 100),
|
|
|
|
timestamp: new Date().toISOString()
|
|
|
|
timestamp: new Date().toISOString(),
|
|
|
|
|
|
|
|
source: 'http'
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Cache the result
|
|
|
|
// Cache it
|
|
|
|
pskReporterCache[cacheKey] = { data: result, timestamp: now };
|
|
|
|
pskHttpCache[cacheKey] = { data: result, timestamp: now };
|
|
|
|
|
|
|
|
|
|
|
|
console.log(`[PSKReporter] Found ${enrichedReports.length} stations hearing ${callsign}`);
|
|
|
|
console.log(`[PSKReporter HTTP] Found ${reports.length} ${direction} reports for ${callsign}`);
|
|
|
|
res.json(result);
|
|
|
|
res.json(result);
|
|
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
} catch (error) {
|
|
|
|
if (error.name !== 'AbortError') {
|
|
|
|
logErrorOnce('PSKReporter HTTP', error.message);
|
|
|
|
logErrorOnce('PSKReporter', `TX query error: ${error.message}`);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
// Return cached data if available
|
|
|
|
|
|
|
|
if (pskReporterCache[cacheKey]) {
|
|
|
|
|
|
|
|
return res.json(pskReporterCache[cacheKey].data);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
res.json({ callsign, direction: 'tx', count: 0, reports: [], error: error.message });
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// PSKReporter - what am I hearing?
|
|
|
|
// Return cached data if available
|
|
|
|
app.get('/api/pskreporter/rx/:callsign', async (req, res) => {
|
|
|
|
if (pskHttpCache[cacheKey]) {
|
|
|
|
const callsign = req.params.callsign.toUpperCase();
|
|
|
|
return res.json({ ...pskHttpCache[cacheKey].data, cached: true, stale: true });
|
|
|
|
const minutes = parseInt(req.query.minutes) || 15;
|
|
|
|
|
|
|
|
const flowStartSeconds = Math.floor(minutes * 60);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const cacheKey = `rx:${callsign}:${minutes}`;
|
|
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Check cache
|
|
|
|
|
|
|
|
if (pskReporterCache[cacheKey] && (now - pskReporterCache[cacheKey].timestamp) < PSK_CACHE_TTL) {
|
|
|
|
|
|
|
|
return res.json(pskReporterCache[cacheKey].data);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
console.log(`[PSKReporter] Fetching RX reports for ${callsign} (last ${minutes} min)`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const url = `https://retrieve.pskreporter.info/query?receiverCallsign=${encodeURIComponent(callsign)}&flowStartSeconds=${flowStartSeconds}&rronly=1&noactive=1&nolocator=1`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
|
|
|
const timeout = setTimeout(() => controller.abort(), 15000);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const response = await fetch(url, {
|
|
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
|
|
'User-Agent': 'OpenHamClock/3.10',
|
|
|
|
|
|
|
|
'Accept': 'application/xml'
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
signal: controller.signal
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
clearTimeout(timeout);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
|
|
throw new Error(`PSKReporter returned ${response.status}`);
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const xml = await response.text();
|
|
|
|
res.json({
|
|
|
|
const reports = parsePSKReporterXML(xml);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Add location data and band info
|
|
|
|
|
|
|
|
const enrichedReports = reports.map(r => {
|
|
|
|
|
|
|
|
const loc = r.senderGrid ? gridToLatLonSimple(r.senderGrid) : null;
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
|
|
...r,
|
|
|
|
|
|
|
|
lat: loc?.lat,
|
|
|
|
|
|
|
|
lon: loc?.lon,
|
|
|
|
|
|
|
|
band: r.freqMHz ? getBandFromMHz(r.freqMHz) : 'Unknown',
|
|
|
|
|
|
|
|
age: Math.floor((Date.now() - r.timestamp) / 60000)
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
}).filter(r => r.lat && r.lon);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
enrichedReports.sort((a, b) => b.timestamp - a.timestamp);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const result = {
|
|
|
|
|
|
|
|
callsign,
|
|
|
|
callsign,
|
|
|
|
direction: 'rx',
|
|
|
|
direction,
|
|
|
|
count: enrichedReports.length,
|
|
|
|
count: 0,
|
|
|
|
reports: enrichedReports.slice(0, 100),
|
|
|
|
reports: [],
|
|
|
|
timestamp: new Date().toISOString()
|
|
|
|
error: error.message,
|
|
|
|
};
|
|
|
|
hint: 'Consider using MQTT WebSocket connection for real-time data'
|
|
|
|
|
|
|
|
});
|
|
|
|
pskReporterCache[cacheKey] = { data: result, timestamp: now };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
console.log(`[PSKReporter] Found ${enrichedReports.length} stations heard by ${callsign}`);
|
|
|
|
|
|
|
|
res.json(result);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
|
|
if (error.name !== 'AbortError') {
|
|
|
|
|
|
|
|
logErrorOnce('PSKReporter', `RX query error: ${error.message}`);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if (pskReporterCache[cacheKey]) {
|
|
|
|
|
|
|
|
return res.json(pskReporterCache[cacheKey].data);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
res.json({ callsign, direction: 'rx', count: 0, reports: [], error: error.message });
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// PSKReporter - combined TX and RX for a callsign
|
|
|
|
// Combined endpoint that tries MQTT cache first, falls back to HTTP
|
|
|
|
app.get('/api/pskreporter/:callsign', async (req, res) => {
|
|
|
|
app.get('/api/pskreporter/:callsign', async (req, res) => {
|
|
|
|
const callsign = req.params.callsign.toUpperCase();
|
|
|
|
const callsign = req.params.callsign.toUpperCase();
|
|
|
|
const minutes = parseInt(req.query.minutes) || 15;
|
|
|
|
const minutes = parseInt(req.query.minutes) || 15;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// For now, redirect to HTTP endpoint since MQTT requires client-side connection
|
|
|
|
|
|
|
|
// The frontend should connect directly to MQTT for real-time updates
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
// Fetch both TX and RX in parallel
|
|
|
|
|
|
|
|
const [txRes, rxRes] = await Promise.allSettled([
|
|
|
|
const [txRes, rxRes] = await Promise.allSettled([
|
|
|
|
fetch(`http://localhost:${PORT}/api/pskreporter/tx/${callsign}?minutes=${minutes}`),
|
|
|
|
fetch(`http://localhost:${PORT}/api/pskreporter/http/${callsign}?minutes=${minutes}&direction=tx`),
|
|
|
|
fetch(`http://localhost:${PORT}/api/pskreporter/rx/${callsign}?minutes=${minutes}`)
|
|
|
|
fetch(`http://localhost:${PORT}/api/pskreporter/http/${callsign}?minutes=${minutes}&direction=rx`)
|
|
|
|
]);
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
let txData = { count: 0, reports: [] };
|
|
|
|
let txData = { count: 0, reports: [] };
|
|
|
|
@ -2128,15 +2100,24 @@ app.get('/api/pskreporter/:callsign', async (req, res) => {
|
|
|
|
callsign,
|
|
|
|
callsign,
|
|
|
|
tx: txData,
|
|
|
|
tx: txData,
|
|
|
|
rx: rxData,
|
|
|
|
rx: rxData,
|
|
|
|
timestamp: new Date().toISOString()
|
|
|
|
timestamp: new Date().toISOString(),
|
|
|
|
|
|
|
|
mqtt: {
|
|
|
|
|
|
|
|
available: true,
|
|
|
|
|
|
|
|
host: 'wss://mqtt.pskreporter.info:1886',
|
|
|
|
|
|
|
|
hint: 'Connect via WebSocket for real-time updates'
|
|
|
|
|
|
|
|
}
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
} catch (error) {
|
|
|
|
logErrorOnce('PSKReporter', `Combined query error: ${error.message}`);
|
|
|
|
logErrorOnce('PSKReporter', error.message);
|
|
|
|
res.json({ callsign, tx: { count: 0, reports: [] }, rx: { count: 0, reports: [] }, error: error.message });
|
|
|
|
res.json({
|
|
|
|
|
|
|
|
callsign,
|
|
|
|
|
|
|
|
tx: { count: 0, reports: [] },
|
|
|
|
|
|
|
|
rx: { count: 0, reports: [] },
|
|
|
|
|
|
|
|
error: error.message
|
|
|
|
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================
|
|
|
|
// ============================================
|
|
|
|
// SATELLITE TRACKING API
|
|
|
|
// SATELLITE TRACKING API
|
|
|
|
// ============================================
|
|
|
|
// ============================================
|
|
|
|
|