fixing pskreporter integ

pull/37/head
accius 2 days ago
parent b037e25d8d
commit 54287f1a4b

@ -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;
// Clean up old spots periodically
while ((match = reportRegex.exec(xml)) !== null) { setInterval(() => {
const report = match[0]; const cutoff = Date.now() - pskReporterSpots.maxAge;
for (const [call, spots] of pskReporterSpots.tx) {
// Extract attributes const filtered = spots.filter(s => s.timestamp > cutoff);
const getAttr = (name) => { if (filtered.length === 0) {
const attrMatch = report.match(new RegExp(`${name}="([^"]*)"`)); pskReporterSpots.tx.delete(call);
return attrMatch ? attrMatch[1] : null; } else {
}; pskReporterSpots.tx.set(call, filtered);
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 flowStartSeconds = getAttr('flowStartSeconds');
const sNR = getAttr('sNR');
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 // Return cached data if available
if (pskReporterCache[cacheKey]) { if (pskHttpCache[cacheKey]) {
return res.json(pskReporterCache[cacheKey].data); return res.json({ ...pskHttpCache[cacheKey].data, cached: true, stale: true });
} }
res.json({ callsign, direction: 'tx', count: 0, reports: [], error: error.message });
}
});
// PSKReporter - what am I hearing?
app.get('/api/pskreporter/rx/:callsign', async (req, res) => {
const callsign = req.params.callsign.toUpperCase();
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`; res.json({
callsign,
const controller = new AbortController(); direction,
const timeout = setTimeout(() => controller.abort(), 15000); count: 0,
reports: [],
const response = await fetch(url, { error: error.message,
headers: { hint: 'Consider using MQTT WebSocket connection for real-time data'
'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();
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,
direction: 'rx',
count: enrichedReports.length,
reports: enrichedReports.slice(0, 100),
timestamp: new Date().toISOString()
};
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
// ============================================ // ============================================

@ -147,13 +147,21 @@ const PSKReporterPanel = ({ callsign, onShowOnMap }) => {
</div> </div>
<div className="panel-content" style={{ maxHeight: '300px', overflowY: 'auto' }}> <div className="panel-content" style={{ maxHeight: '300px', overflowY: 'auto' }}>
{loading && reports.length === 0 ? ( {error ? (
<div style={{ textAlign: 'center', padding: '20px', color: 'var(--text-muted)' }}>
<div style={{ marginBottom: '8px' }}> PSKReporter temporarily unavailable</div>
<div style={{ fontSize: '0.7rem' }}>Will retry automatically</div>
</div>
) : loading && reports.length === 0 ? (
<div style={{ textAlign: 'center', padding: '20px', color: 'var(--text-muted)' }}> <div style={{ textAlign: 'center', padding: '20px', color: 'var(--text-muted)' }}>
Loading... Loading...
</div> </div>
) : reports.length === 0 ? ( ) : reports.length === 0 ? (
<div style={{ textAlign: 'center', padding: '20px', color: 'var(--text-muted)' }}> <div style={{ textAlign: 'center', padding: '20px', color: 'var(--text-muted)' }}>
No {activeTab === 'tx' ? 'reception reports' : 'stations heard'} in the last {timeWindow} minutes No {activeTab === 'tx' ? 'reception reports' : 'stations heard'} in the last {timeWindow} minutes
<div style={{ fontSize: '0.65rem', marginTop: '8px' }}>
(Make sure you're transmitting digital modes like FT8)
</div>
</div> </div>
) : ( ) : (
<> <>

@ -1,28 +1,55 @@
/** /**
* usePSKReporter Hook * usePSKReporter Hook
* Fetches PSKReporter data showing where your signal is being received * Fetches PSKReporter data showing where your signal is being received
* and what stations you're hearing *
* Uses HTTP API with server-side caching to respect PSKReporter rate limits.
* For real-time updates, connect directly to mqtt.pskreporter.info:1886 (wss)
* Topic: pskr/filter/v2/+/+/YOURCALL/#
*/ */
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
// Convert grid square to lat/lon
function gridToLatLon(grid) {
if (!grid || grid.length < 4) return null;
const g = grid.toUpperCase();
const lon = (g.charCodeAt(0) - 65) * 20 - 180;
const lat = (g.charCodeAt(1) - 65) * 10 - 90;
const lonMin = parseInt(g[2]) * 2;
const latMin = parseInt(g[3]) * 1;
let finalLon = lon + lonMin + 1;
let finalLat = lat + latMin + 0.5;
if (grid.length >= 6) {
const lonSec = (g.charCodeAt(4) - 65) * (2/24);
const latSec = (g.charCodeAt(5) - 65) * (1/24);
finalLon = lon + lonMin + lonSec + (1/24);
finalLat = lat + latMin + latSec + (0.5/24);
}
return { lat: finalLat, lon: finalLon };
}
export const usePSKReporter = (callsign, options = {}) => { export const usePSKReporter = (callsign, options = {}) => {
const { const {
minutes = 15, // Time window in minutes (default 15) minutes = 15, // Time window in minutes (default 15)
direction = 'both', // 'tx' (being heard), 'rx' (hearing), or 'both'
enabled = true, // Enable/disable fetching enabled = true, // Enable/disable fetching
refreshInterval = 120000 // Refresh every 2 minutes refreshInterval = 300000, // Refresh every 5 minutes (PSKReporter friendly)
maxSpots = 100 // Max spots to display
} = options; } = options;
const [txData, setTxData] = useState({ count: 0, reports: [] }); const [txReports, setTxReports] = useState([]);
const [rxData, setRxData] = useState({ count: 0, reports: [] }); const [rxReports, setRxReports] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [rateLimited, setRateLimited] = useState(false);
const [lastUpdate, setLastUpdate] = useState(null); const [lastUpdate, setLastUpdate] = useState(null);
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
if (!callsign || callsign === 'N0CALL' || !enabled) { if (!callsign || callsign === 'N0CALL' || !enabled) {
setTxData({ count: 0, reports: [] }); setTxReports([]);
setRxData({ count: 0, reports: [] }); setRxReports([]);
setLoading(false); setLoading(false);
return; return;
} }
@ -30,38 +57,56 @@ export const usePSKReporter = (callsign, options = {}) => {
try { try {
setError(null); setError(null);
if (direction === 'both') { // Fetch combined endpoint from our server (handles caching)
// Fetch combined endpoint const response = await fetch(`/api/pskreporter/${encodeURIComponent(callsign)}?minutes=${minutes}`);
const response = await fetch(`/api/pskreporter/${encodeURIComponent(callsign)}?minutes=${minutes}`);
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setTxData(data.tx || { count: 0, reports: [] });
setRxData(data.rx || { count: 0, reports: [] }); // Process TX reports (where I'm being heard)
} const txData = data.tx?.reports || [];
} else if (direction === 'tx') { const processedTx = txData
// Fetch only TX (where am I being heard) .map(r => ({
const response = await fetch(`/api/pskreporter/tx/${encodeURIComponent(callsign)}?minutes=${minutes}`); ...r,
if (response.ok) { // Ensure we have location data
const data = await response.json(); lat: r.lat || (r.receiverGrid ? gridToLatLon(r.receiverGrid)?.lat : null),
setTxData(data); lon: r.lon || (r.receiverGrid ? gridToLatLon(r.receiverGrid)?.lon : null),
} age: r.age || Math.floor((Date.now() - r.timestamp) / 60000)
} else if (direction === 'rx') { }))
// Fetch only RX (what am I hearing) .filter(r => r.lat && r.lon)
const response = await fetch(`/api/pskreporter/rx/${encodeURIComponent(callsign)}?minutes=${minutes}`); .slice(0, maxSpots);
if (response.ok) {
const data = await response.json(); // Process RX reports (what I'm hearing)
setRxData(data); const rxData = data.rx?.reports || [];
const processedRx = rxData
.map(r => ({
...r,
lat: r.lat || (r.senderGrid ? gridToLatLon(r.senderGrid)?.lat : null),
lon: r.lon || (r.senderGrid ? gridToLatLon(r.senderGrid)?.lon : null),
age: r.age || Math.floor((Date.now() - r.timestamp) / 60000)
}))
.filter(r => r.lat && r.lon)
.slice(0, maxSpots);
setTxReports(processedTx);
setRxReports(processedRx);
setRateLimited(data.tx?.rateLimited || data.rx?.rateLimited || false);
setLastUpdate(new Date());
// Check for errors in response
if (data.error || data.tx?.error || data.rx?.error) {
setError(data.error || data.tx?.error || data.rx?.error);
} }
} else {
throw new Error(`HTTP ${response.status}`);
} }
setLastUpdate(new Date());
} catch (err) { } catch (err) {
console.error('PSKReporter fetch error:', err); console.error('PSKReporter fetch error:', err);
setError(err.message); setError(err.message);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [callsign, minutes, direction, enabled]); }, [callsign, minutes, enabled, maxSpots]);
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
@ -72,46 +117,30 @@ export const usePSKReporter = (callsign, options = {}) => {
} }
}, [fetchData, enabled, refreshInterval]); }, [fetchData, enabled, refreshInterval]);
// Computed values // Computed stats
const txReports = txData.reports || []; const txBands = [...new Set(txReports.map(r => r.band))].filter(b => b && b !== 'Unknown');
const rxReports = rxData.reports || []; const txModes = [...new Set(txReports.map(r => r.mode))].filter(Boolean);
// Get unique bands from TX reports
const txBands = [...new Set(txReports.map(r => r.band))].filter(b => b !== 'Unknown');
// Get unique modes from TX reports
const txModes = [...new Set(txReports.map(r => r.mode))];
// Stats
const stats = { const stats = {
txCount: txData.count || 0, txCount: txReports.length,
rxCount: rxData.count || 0, rxCount: rxReports.length,
txBands, txBands,
txModes, txModes,
furthestTx: txReports.length > 0
? txReports.reduce((max, r) => r.distance > (max?.distance || 0) ? r : max, null)
: null,
bestSnr: txReports.length > 0 bestSnr: txReports.length > 0
? txReports.reduce((max, r) => (r.snr || -99) > (max?.snr || -99) ? r : max, null) ? txReports.reduce((max, r) => (r.snr || -99) > (max?.snr || -99) ? r : max, null)
: null : null
}; };
return { return {
// TX data - where is my signal being heard
txReports, txReports,
txCount: txData.count || 0, txCount: txReports.length,
// RX data - what am I hearing
rxReports, rxReports,
rxCount: rxData.count || 0, rxCount: rxReports.length,
// Combined
stats, stats,
loading, loading,
error, error,
rateLimited,
lastUpdate, lastUpdate,
// Manual refresh
refresh: fetchData refresh: fetchData
}; };
}; };

Loading…
Cancel
Save

Powered by TurnKey Linux.