diff --git a/package.json b/package.json index 049c8a7..dfcde40 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openhamclock", - "version": "3.11.0", + "version": "3.12.0", "description": "Amateur Radio Dashboard - A modern web-based HamClock alternative", "main": "server.js", "scripts": { @@ -18,6 +18,7 @@ "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", + "mqtt": "^5.3.4", "node-fetch": "^2.7.0", "satellite.js": "^5.0.0", "ws": "^8.14.2" @@ -35,7 +36,8 @@ "dx-cluster", "propagation", "pota", - "satellite-tracking" + "satellite-tracking", + "pskreporter" ], "author": "K0CJH", "license": "MIT" diff --git a/server.js b/server.js index ab26023..3327460 100644 --- a/server.js +++ b/server.js @@ -1952,7 +1952,8 @@ app.get('/api/pskreporter/config', (req, res) => { // 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 +const PSK_HTTP_CACHE_TTL = 10 * 60 * 1000; // 10 minutes - PSKReporter rate limits aggressively +let psk503Backoff = 0; // Timestamp when we can try again after 503 app.get('/api/pskreporter/http/:callsign', async (req, res) => { const callsign = req.params.callsign.toUpperCase(); @@ -1964,11 +1965,26 @@ app.get('/api/pskreporter/http/:callsign', async (req, res) => { const cacheKey = `${direction}:${callsign}:${minutes}`; const now = Date.now(); - // Check cache + // Check cache first if (pskHttpCache[cacheKey] && (now - pskHttpCache[cacheKey].timestamp) < PSK_HTTP_CACHE_TTL) { return res.json({ ...pskHttpCache[cacheKey].data, cached: true }); } + // If we're in 503 backoff period, return cached data or empty result + if (psk503Backoff > now) { + console.log(`[PSKReporter HTTP] In backoff period, ${Math.round((psk503Backoff - now) / 1000)}s remaining`); + if (pskHttpCache[cacheKey]) { + return res.json({ ...pskHttpCache[cacheKey].data, cached: true, stale: true }); + } + return res.json({ + callsign, + direction, + count: 0, + reports: [], + backoff: true + }); + } + try { const param = direction === 'tx' ? 'senderCallsign' : 'receiverCallsign'; // Add appcontact parameter as requested by PSKReporter developer docs @@ -1989,6 +2005,11 @@ app.get('/api/pskreporter/http/:callsign', async (req, res) => { clearTimeout(timeout); if (!response.ok) { + // On 503, set backoff period (15 minutes) to avoid hammering + if (response.status === 503) { + psk503Backoff = Date.now() + (15 * 60 * 1000); + console.log(`[PSKReporter HTTP] Got 503, backing off for 15 minutes`); + } throw new Error(`HTTP ${response.status}`); } @@ -2039,6 +2060,9 @@ app.get('/api/pskreporter/http/:callsign', async (req, res) => { // Sort by timestamp (newest first) reports.sort((a, b) => b.timestamp - a.timestamp); + // Clear backoff on success + psk503Backoff = 0; + const result = { callsign, direction, @@ -2057,18 +2081,17 @@ app.get('/api/pskreporter/http/:callsign', async (req, res) => { } catch (error) { logErrorOnce('PSKReporter HTTP', error.message); - // Return cached data if available + // Return cached data if available (without error flag) if (pskHttpCache[cacheKey]) { return res.json({ ...pskHttpCache[cacheKey].data, cached: true, stale: true }); } + // Return empty result without error flag for 503s (rate limiting is expected) res.json({ callsign, direction, count: 0, - reports: [], - error: error.message, - hint: 'Consider using MQTT WebSocket connection for real-time data' + reports: [] }); } }); diff --git a/src/components/PSKReporterPanel.jsx b/src/components/PSKReporterPanel.jsx index 651f1e7..dda1c82 100644 --- a/src/components/PSKReporterPanel.jsx +++ b/src/components/PSKReporterPanel.jsx @@ -1,15 +1,15 @@ /** * PSKReporter Panel * Shows where your digital mode signals are being received - * Styled to match DXClusterPanel + * Uses MQTT WebSocket for real-time data */ import React, { useState } from 'react'; import { usePSKReporter } from '../hooks/usePSKReporter.js'; import { getBandColor } from '../utils/callsign.js'; const PSKReporterPanel = ({ callsign, onShowOnMap, showOnMap, onToggleMap }) => { - const [timeWindow, setTimeWindow] = useState(15); - const [activeTab, setActiveTab] = useState('rx'); // Default to 'rx' (Hearing) - more useful + const [timeWindow] = useState(15); // Keep spots for 15 minutes + const [activeTab, setActiveTab] = useState('tx'); // Default to 'tx' (Being Heard) const { txReports, @@ -18,6 +18,8 @@ const PSKReporterPanel = ({ callsign, onShowOnMap, showOnMap, onToggleMap }) => rxCount, loading, error, + connected, + source, refresh } = usePSKReporter(callsign, { minutes: timeWindow, @@ -25,7 +27,6 @@ const PSKReporterPanel = ({ callsign, onShowOnMap, showOnMap, onToggleMap }) => }); const reports = activeTab === 'tx' ? txReports : rxReports; - const count = activeTab === 'tx' ? txCount : rxCount; // Get band color from frequency const getFreqColor = (freqMHz) => { @@ -41,6 +42,20 @@ const PSKReporterPanel = ({ callsign, onShowOnMap, showOnMap, onToggleMap }) => return `${Math.floor(minutes/60)}h`; }; + // Get status indicator + const getStatusIndicator = () => { + if (connected) { + return ● LIVE; + } + if (source === 'connecting' || source === 'reconnecting') { + return ◐ {source}; + } + if (error) { + return ● offline; + } + return null; + }; + if (!callsign || callsign === 'N0CALL') { return (
@@ -72,30 +87,12 @@ const PSKReporterPanel = ({ callsign, onShowOnMap, showOnMap, onToggleMap }) => justifyContent: 'space-between', alignItems: 'center' }}> - 📡 PSKReporter + 📡 PSKReporter {getStatusIndicator()}
-
{/* Reports list - matches DX Cluster style */} - {error ? ( + {error && !connected ? (
- ⚠️ Temporarily unavailable + ⚠️ Connection failed - click 🔄 to retry
) : loading && reports.length === 0 ? ( -
-
+
+
+ Connecting to MQTT... +
+ ) : !connected && reports.length === 0 ? ( +
+ Waiting for connection...
) : reports.length === 0 ? (
- No {activeTab === 'tx' ? 'reception reports' : 'stations heard'} + {activeTab === 'tx' + ? 'Waiting for spots... (TX to see reports)' + : 'No stations heard yet'}
) : (
= 1.8 && freqMHz <= 2) return '160m'; + if (freqMHz >= 3.5 && freqMHz <= 4) return '80m'; + if (freqMHz >= 5.3 && freqMHz <= 5.4) return '60m'; + if (freqMHz >= 7 && freqMHz <= 7.3) return '40m'; + if (freqMHz >= 10.1 && freqMHz <= 10.15) return '30m'; + if (freqMHz >= 14 && freqMHz <= 14.35) return '20m'; + if (freqMHz >= 18.068 && freqMHz <= 18.168) return '17m'; + if (freqMHz >= 21 && freqMHz <= 21.45) return '15m'; + if (freqMHz >= 24.89 && freqMHz <= 24.99) return '12m'; + if (freqMHz >= 28 && freqMHz <= 29.7) return '10m'; + if (freqMHz >= 50 && freqMHz <= 54) return '6m'; + if (freqMHz >= 144 && freqMHz <= 148) return '2m'; + if (freqMHz >= 420 && freqMHz <= 450) return '70cm'; + return 'Unknown'; +} + export const usePSKReporter = (callsign, options = {}) => { const { - minutes = 15, // Time window in minutes (default 15) + minutes = 15, // Time window to keep spots enabled = true, // Enable/disable fetching - refreshInterval = 300000, // Refresh every 5 minutes (PSKReporter friendly) - maxSpots = 100 // Max spots to display + maxSpots = 100 // Max spots to keep } = options; const [txReports, setTxReports] = useState([]); const [rxReports, setRxReports] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [connected, setConnected] = useState(false); const [lastUpdate, setLastUpdate] = useState(null); + const [source, setSource] = useState('connecting'); + + const clientRef = useRef(null); + const txReportsRef = useRef([]); + const rxReportsRef = useRef([]); + const mountedRef = useRef(true); - const fetchData = useCallback(async () => { - if (!callsign || callsign === 'N0CALL' || !enabled) { - setTxReports([]); - setRxReports([]); - setLoading(false); - return; - } + // Clean old spots (older than specified minutes) + const cleanOldSpots = useCallback((spots, maxAgeMinutes) => { + const cutoff = Date.now() - (maxAgeMinutes * 60 * 1000); + return spots.filter(s => s.timestamp > cutoff).slice(0, maxSpots); + }, [maxSpots]); + // Process incoming MQTT message + const processMessage = useCallback((topic, message) => { + if (!mountedRef.current) return; + try { - setError(null); + const data = JSON.parse(message.toString()); + + // PSKReporter MQTT message format + // sa=sender callsign, sl=sender locator, ra=receiver callsign, rl=receiver locator + // f=frequency, md=mode, rp=snr (report), t=timestamp + const { + sa: senderCallsign, + sl: senderLocator, + ra: receiverCallsign, + rl: receiverLocator, + f: frequency, + md: mode, + rp: snr, + t: timestamp + } = data; + + if (!senderCallsign || !receiverCallsign) return; + + const senderLoc = gridToLatLon(senderLocator); + const receiverLoc = gridToLatLon(receiverLocator); + const freq = parseInt(frequency) || 0; + const now = Date.now(); + + const report = { + sender: senderCallsign, + senderGrid: senderLocator, + receiver: receiverCallsign, + receiverGrid: receiverLocator, + freq, + freqMHz: freq ? (freq / 1000000).toFixed(3) : '?', + band: getBandFromHz(freq), + mode: mode || 'Unknown', + snr: snr !== undefined ? parseInt(snr) : null, + timestamp: timestamp ? timestamp * 1000 : now, + age: 0, + lat: null, + lon: null + }; - // Fetch combined endpoint from our server (handles caching) - const response = await fetch(`/api/pskreporter/${encodeURIComponent(callsign)}?minutes=${minutes}`); + const upperCallsign = callsign?.toUpperCase(); + if (!upperCallsign) return; - if (response.ok) { - const data = await response.json(); + // If I'm the sender, this is a TX report (someone heard me) + if (senderCallsign.toUpperCase() === upperCallsign) { + report.lat = receiverLoc?.lat; + report.lon = receiverLoc?.lon; - // Process TX reports (where I'm being heard) - const txData = data.tx?.reports || []; - const processedTx = txData - .map(r => ({ - ...r, - // Ensure we have location data - lat: r.lat || (r.receiverGrid ? gridToLatLon(r.receiverGrid)?.lat : null), - lon: r.lon || (r.receiverGrid ? gridToLatLon(r.receiverGrid)?.lon : null), - age: r.age || Math.floor((Date.now() - r.timestamp) / 60000) - })) - .filter(r => r.lat && r.lon) + // Add to front, dedupe by receiver+freq, limit size + txReportsRef.current = [report, ...txReportsRef.current] + .filter((r, i, arr) => + i === arr.findIndex(x => x.receiver === r.receiver && Math.abs(x.freq - r.freq) < 1000) + ) .slice(0, maxSpots); - // Process RX reports (what I'm hearing) - 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) + setTxReports(cleanOldSpots([...txReportsRef.current], minutes)); + setLastUpdate(new Date()); + } + + // If I'm the receiver, this is an RX report (I heard someone) + if (receiverCallsign.toUpperCase() === upperCallsign) { + report.lat = senderLoc?.lat; + report.lon = senderLoc?.lon; + + rxReportsRef.current = [report, ...rxReportsRef.current] + .filter((r, i, arr) => + i === arr.findIndex(x => x.sender === r.sender && Math.abs(x.freq - r.freq) < 1000) + ) .slice(0, maxSpots); - setTxReports(processedTx); - setRxReports(processedRx); + setRxReports(cleanOldSpots([...rxReportsRef.current], minutes)); 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}`); } + } catch (err) { - console.error('PSKReporter fetch error:', err); - setError(err.message); - } finally { - setLoading(false); + // Silently ignore parse errors - malformed messages happen } - }, [callsign, minutes, enabled, maxSpots]); + }, [callsign, minutes, maxSpots, cleanOldSpots]); + // Connect to MQTT useEffect(() => { - fetchData(); + mountedRef.current = true; - if (enabled && refreshInterval > 0) { - const interval = setInterval(fetchData, refreshInterval); - return () => clearInterval(interval); + if (!callsign || callsign === 'N0CALL' || !enabled) { + setTxReports([]); + setRxReports([]); + setLoading(false); + setSource('disabled'); + setConnected(false); + return; } - }, [fetchData, enabled, refreshInterval]); - // Computed stats - const txBands = [...new Set(txReports.map(r => r.band))].filter(b => b && b !== 'Unknown'); - const txModes = [...new Set(txReports.map(r => r.mode))].filter(Boolean); - - const stats = { - txCount: txReports.length, - rxCount: rxReports.length, - txBands, - txModes, - bestSnr: txReports.length > 0 - ? txReports.reduce((max, r) => (r.snr || -99) > (max?.snr || -99) ? r : max, null) - : null - }; + const upperCallsign = callsign.toUpperCase(); + + // Clear old data + txReportsRef.current = []; + rxReportsRef.current = []; + setTxReports([]); + setRxReports([]); + setLoading(true); + setError(null); + setSource('connecting'); + + console.log(`[PSKReporter MQTT] Connecting for ${upperCallsign}...`); + + // Connect to PSKReporter MQTT via WebSocket + const client = mqtt.connect('wss://mqtt.pskreporter.info:1886/mqtt', { + clientId: `ohc_${upperCallsign}_${Math.random().toString(16).substr(2, 6)}`, + clean: true, + connectTimeout: 15000, + reconnectPeriod: 60000, + keepalive: 60 + }); + + clientRef.current = client; + + client.on('connect', () => { + if (!mountedRef.current) return; + + console.log('[PSKReporter MQTT] Connected!'); + setConnected(true); + setLoading(false); + setSource('mqtt'); + setError(null); + + // Subscribe to spots where we are the sender (being heard by others) + // Topic format: pskr/filter/v2/{mode}/{band}/{senderCall}/{senderLoc}/{rxCall}/{rxLoc}/{freq}/{snr} + const txTopic = `pskr/filter/v2/+/+/${upperCallsign}/#`; + client.subscribe(txTopic, { qos: 0 }, (err) => { + if (err) { + console.error('[PSKReporter MQTT] TX subscribe error:', err); + } else { + console.log(`[PSKReporter MQTT] Subscribed TX: ${txTopic}`); + } + }); + + // Subscribe to spots where we are the receiver (hearing others) + const rxTopic = `pskr/filter/v2/+/+/+/+/${upperCallsign}/#`; + client.subscribe(rxTopic, { qos: 0 }, (err) => { + if (err) { + console.error('[PSKReporter MQTT] RX subscribe error:', err); + } else { + console.log(`[PSKReporter MQTT] Subscribed RX: ${rxTopic}`); + } + }); + }); + + client.on('message', processMessage); + + client.on('error', (err) => { + if (!mountedRef.current) return; + console.error('[PSKReporter MQTT] Error:', err.message); + setError('Connection error'); + setConnected(false); + setLoading(false); + }); + + client.on('close', () => { + if (!mountedRef.current) return; + console.log('[PSKReporter MQTT] Disconnected'); + setConnected(false); + }); + + client.on('offline', () => { + if (!mountedRef.current) return; + console.log('[PSKReporter MQTT] Offline'); + setConnected(false); + setSource('offline'); + }); + + client.on('reconnect', () => { + if (!mountedRef.current) return; + console.log('[PSKReporter MQTT] Reconnecting...'); + setSource('reconnecting'); + }); + + // Cleanup on unmount or callsign change + return () => { + mountedRef.current = false; + if (client) { + console.log('[PSKReporter MQTT] Cleaning up...'); + client.end(true); + } + }; + }, [callsign, enabled, processMessage]); + + // Periodically clean old spots and update ages + useEffect(() => { + if (!enabled) return; + + const interval = setInterval(() => { + // Update ages and clean old spots + const now = Date.now(); + + setTxReports(prev => prev.map(r => ({ + ...r, + age: Math.floor((now - r.timestamp) / 60000) + })).filter(r => r.age <= minutes)); + + setRxReports(prev => prev.map(r => ({ + ...r, + age: Math.floor((now - r.timestamp) / 60000) + })).filter(r => r.age <= minutes)); + + }, 30000); // Every 30 seconds + + return () => clearInterval(interval); + }, [enabled, minutes]); + + // Manual refresh - force reconnect + const refresh = useCallback(() => { + if (clientRef.current) { + clientRef.current.end(true); + clientRef.current = null; + } + setConnected(false); + setLoading(true); + setSource('reconnecting'); + // useEffect will reconnect due to state change + }, []); return { txReports, txCount: txReports.length, rxReports, rxCount: rxReports.length, - stats, loading, error, - connected: false, // HTTP mode - not real-time connected - source: 'http', + connected, + source, lastUpdate, - refresh: fetchData + refresh }; }; diff --git a/vite.config.mjs b/vite.config.mjs index a7bf8e7..e41bbb0 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -22,6 +22,13 @@ export default defineConfig({ '@styles': path.resolve(__dirname, './src/styles') } }, + define: { + // mqtt.js needs these for browser + global: 'globalThis', + }, + optimizeDeps: { + include: ['mqtt'] + }, build: { outDir: 'dist', sourcemap: false, @@ -29,7 +36,8 @@ export default defineConfig({ output: { manualChunks: { vendor: ['react', 'react-dom'], - satellite: ['satellite.js'] + satellite: ['satellite.js'], + mqtt: ['mqtt'] } } }