From 96099dfb6932ff4d05f98f08245273f4c5f81e28 Mon Sep 17 00:00:00 2001 From: accius Date: Mon, 2 Feb 2026 16:53:16 -0500 Subject: [PATCH 1/2] mqtt live info from psk --- package.json | 6 +- server.js | 35 ++- src/components/PSKReporterPanel.jsx | 62 +++--- src/hooks/usePSKReporter.js | 325 +++++++++++++++++++++------- vite.config.mjs | 10 +- 5 files changed, 323 insertions(+), 115 deletions(-) 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'] } } } From 42debad3b2bd57a92082a3371c717834604f5b06 Mon Sep 17 00:00:00 2001 From: accius Date: Mon, 2 Feb 2026 17:04:13 -0500 Subject: [PATCH 2/2] psk updates --- src/App.jsx | 47 +++- src/components/PSKFilterManager.jsx | 405 ++++++++++++++++++++++++++++ src/components/PSKReporterPanel.jsx | 93 +++++-- src/components/index.js | 1 + 4 files changed, 527 insertions(+), 19 deletions(-) create mode 100644 src/components/PSKFilterManager.jsx diff --git a/src/App.jsx b/src/App.jsx index 885e60c..2aa0d9e 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -13,6 +13,7 @@ import { ContestPanel, SettingsPanel, DXFilterManager, + PSKFilterManager, SolarPanel, PropagationPanel, DXpeditionPanel, @@ -96,6 +97,7 @@ const App = () => { // UI state const [showSettings, setShowSettings] = useState(false); const [showDXFilters, setShowDXFilters] = useState(false); + const [showPSKFilters, setShowPSKFilters] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); // Map layer visibility @@ -183,6 +185,20 @@ const App = () => { } catch (e) {} }, [dxFilters]); + // PSKReporter Filters + const [pskFilters, setPskFilters] = useState(() => { + try { + const stored = localStorage.getItem('openhamclock_pskFilters'); + return stored ? JSON.parse(stored) : {}; + } catch (e) { return {}; } + }); + + useEffect(() => { + try { + localStorage.setItem('openhamclock_pskFilters', JSON.stringify(pskFilters)); + } catch (e) {} + }, [pskFilters]); + const dxCluster = useDXCluster(config.dxClusterSource || 'auto', dxFilters); const dxPaths = useDXPaths(); const dxpeditions = useDXpeditions(); @@ -193,6 +209,25 @@ const App = () => { const localWeather = useLocalWeather(config.location); const pskReporter = usePSKReporter(config.callsign, { minutes: 15, enabled: config.callsign !== 'N0CALL' }); + // Filter PSKReporter spots for map display + const filteredPskSpots = useMemo(() => { + const allSpots = [...(pskReporter.txReports || []), ...(pskReporter.rxReports || [])]; + if (!pskFilters?.bands?.length && !pskFilters?.grids?.length && !pskFilters?.modes?.length) { + return allSpots; + } + return allSpots.filter(spot => { + if (pskFilters?.bands?.length && !pskFilters.bands.includes(spot.band)) return false; + if (pskFilters?.modes?.length && !pskFilters.modes.includes(spot.mode)) return false; + if (pskFilters?.grids?.length) { + const grid = spot.receiverGrid || spot.senderGrid; + if (!grid) return false; + const gridPrefix = grid.substring(0, 2).toUpperCase(); + if (!pskFilters.grids.includes(gridPrefix)) return false; + } + return true; + }); + }, [pskReporter.txReports, pskReporter.rxReports, pskFilters]); + // Computed values const deGrid = useMemo(() => calculateGridSquare(config.location.lat, config.location.lon), [config.location]); const dxGrid = useMemo(() => calculateGridSquare(dxLocation.lat, dxLocation.lon), [dxLocation]); @@ -461,7 +496,7 @@ const App = () => { dxPaths={dxPaths.data} dxFilters={dxFilters} satellites={satellites.data} - pskReporterSpots={[...(pskReporter.txReports || []), ...(pskReporter.rxReports || [])]} + pskReporterSpots={filteredPskSpots} showDXPaths={mapLayers.showDXPaths} showDXLabels={mapLayers.showDXLabels} onToggleDXLabels={toggleDXLabels} @@ -599,7 +634,7 @@ const App = () => { dxPaths={dxPaths.data} dxFilters={dxFilters} satellites={satellites.data} - pskReporterSpots={[...(pskReporter.txReports || []), ...(pskReporter.rxReports || [])]} + pskReporterSpots={filteredPskSpots} showDXPaths={mapLayers.showDXPaths} showDXLabels={mapLayers.showDXLabels} onToggleDXLabels={toggleDXLabels} @@ -648,6 +683,8 @@ const App = () => { callsign={config.callsign} showOnMap={mapLayers.showPSKReporter} onToggleMap={togglePSKReporter} + filters={pskFilters} + onOpenFilters={() => setShowPSKFilters(true)} onShowOnMap={(report) => { if (report.lat && report.lon) { setDxLocation({ lat: report.lat, lon: report.lon, call: report.receiver || report.sender }); @@ -692,6 +729,12 @@ const App = () => { isOpen={showDXFilters} onClose={() => setShowDXFilters(false)} /> + setShowPSKFilters(false)} + />
); }; diff --git a/src/components/PSKFilterManager.jsx b/src/components/PSKFilterManager.jsx new file mode 100644 index 0000000..ab5172a --- /dev/null +++ b/src/components/PSKFilterManager.jsx @@ -0,0 +1,405 @@ +/** + * PSKFilterManager Component + * Filter modal for PSKReporter spots - Bands, Grids, Modes + */ +import React, { useState } from 'react'; + +const BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '2m', '70cm']; +const MODES = ['FT8', 'FT4', 'JS8', 'WSPR', 'JT65', 'JT9', 'MSK144', 'Q65', 'FST4', 'FST4W']; + +// Common grid field prefixes by region +const GRID_REGIONS = [ + { name: 'North America East', grids: ['FN', 'FM', 'EN', 'EM', 'DN', 'DM'] }, + { name: 'North America West', grids: ['CN', 'CM', 'DM', 'DN', 'BN', 'BM'] }, + { name: 'Europe', grids: ['JO', 'JN', 'IO', 'IN', 'KO', 'KN', 'LO', 'LN'] }, + { name: 'South America', grids: ['GG', 'GH', 'GI', 'FG', 'FH', 'FI', 'FF', 'FE'] }, + { name: 'Asia', grids: ['PM', 'PL', 'OM', 'OL', 'QL', 'QM', 'NM', 'NL'] }, + { name: 'Oceania', grids: ['QF', 'QG', 'PF', 'PG', 'RF', 'RG', 'OF', 'OG'] }, + { name: 'Africa', grids: ['KH', 'KG', 'JH', 'JG', 'IH', 'IG'] }, +]; + +export const PSKFilterManager = ({ filters, onFilterChange, isOpen, onClose }) => { + const [activeTab, setActiveTab] = useState('bands'); + const [customGrid, setCustomGrid] = useState(''); + + if (!isOpen) return null; + + const toggleArrayItem = (key, item) => { + const current = filters[key] || []; + const newArray = current.includes(item) + ? current.filter(x => x !== item) + : [...current, item]; + onFilterChange({ ...filters, [key]: newArray.length ? newArray : undefined }); + }; + + const selectAll = (key, items) => { + onFilterChange({ ...filters, [key]: [...items] }); + }; + + const clearFilter = (key) => { + const newFilters = { ...filters }; + delete newFilters[key]; + onFilterChange(newFilters); + }; + + const clearAllFilters = () => { + onFilterChange({}); + }; + + const addCustomGrid = () => { + if (customGrid.trim() && customGrid.length >= 2) { + const grid = customGrid.toUpperCase().substring(0, 2); + const current = filters?.grids || []; + if (!current.includes(grid)) { + onFilterChange({ ...filters, grids: [...current, grid] }); + } + setCustomGrid(''); + } + }; + + const getActiveFilterCount = () => { + let count = 0; + if (filters?.bands?.length) count += filters.bands.length; + if (filters?.grids?.length) count += filters.grids.length; + if (filters?.modes?.length) count += filters.modes.length; + return count; + }; + + const tabStyle = (active) => ({ + padding: '8px 16px', + background: active ? 'var(--bg-tertiary)' : 'transparent', + border: 'none', + borderBottom: active ? '2px solid var(--accent-cyan)' : '2px solid transparent', + color: active ? 'var(--accent-cyan)' : 'var(--text-muted)', + fontSize: '13px', + cursor: 'pointer', + fontFamily: 'inherit' + }); + + const chipStyle = (selected) => ({ + padding: '6px 12px', + background: selected ? 'rgba(0, 221, 255, 0.2)' : 'var(--bg-tertiary)', + border: `1px solid ${selected ? 'var(--accent-cyan)' : 'var(--border-color)'}`, + borderRadius: '4px', + color: selected ? 'var(--accent-cyan)' : 'var(--text-secondary)', + fontSize: '12px', + cursor: 'pointer', + fontFamily: 'JetBrains Mono, monospace' + }); + + const renderBandsTab = () => ( +
+
+ + Filter by Band + +
+ + +
+
+
+ {BANDS.map(band => ( + + ))} +
+
+ {filters?.bands?.length + ? `Showing only: ${filters.bands.join(', ')}` + : 'Showing all bands (no filter)'} +
+
+ ); + + const renderGridsTab = () => ( +
+
+ + Filter by Grid Square + + +
+ + {/* Custom grid input */} +
+ setCustomGrid(e.target.value.toUpperCase())} + maxLength={2} + onKeyPress={(e) => e.key === 'Enter' && addCustomGrid()} + style={{ + flex: 1, + padding: '8px 12px', + background: 'var(--bg-tertiary)', + border: '1px solid var(--border-color)', + borderRadius: '4px', + color: 'var(--text-primary)', + fontSize: '13px', + fontFamily: 'JetBrains Mono' + }} + /> + +
+ + {/* Selected grids */} + {filters?.grids?.length > 0 && ( +
+
+ Active Grid Filters: +
+
+ {filters.grids.map(grid => ( + + ))} +
+
+ )} + + {/* Quick select by region */} +
+ Quick Select by Region: +
+ {GRID_REGIONS.map(region => ( +
+
+ {region.name} +
+
+ {region.grids.map(grid => ( + + ))} +
+
+ ))} +
+ ); + + const renderModesTab = () => ( +
+
+ + Filter by Mode + +
+ + +
+
+
+ {MODES.map(mode => ( + + ))} +
+
+ {filters?.modes?.length + ? `Showing only: ${filters.modes.join(', ')}` + : 'Showing all modes (no filter)'} +
+
+ ); + + return ( +
e.target === e.currentTarget && onClose()} + > +
+ {/* Header */} +
+
+

+ 📡 PSKReporter Filters +

+ + {getActiveFilterCount()} filter{getActiveFilterCount() !== 1 ? 's' : ''} active + +
+ +
+ + {/* Tabs */} +
+ + + +
+ + {/* Tab Content */} +
+ {activeTab === 'bands' && renderBandsTab()} + {activeTab === 'grids' && renderGridsTab()} + {activeTab === 'modes' && renderModesTab()} +
+ + {/* Footer */} +
+ + +
+
+
+ ); +}; + +export default PSKFilterManager; diff --git a/src/components/PSKReporterPanel.jsx b/src/components/PSKReporterPanel.jsx index dda1c82..d7fff17 100644 --- a/src/components/PSKReporterPanel.jsx +++ b/src/components/PSKReporterPanel.jsx @@ -3,12 +3,18 @@ * Shows where your digital mode signals are being received * Uses MQTT WebSocket for real-time data */ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { usePSKReporter } from '../hooks/usePSKReporter.js'; import { getBandColor } from '../utils/callsign.js'; -const PSKReporterPanel = ({ callsign, onShowOnMap, showOnMap, onToggleMap }) => { - const [timeWindow] = useState(15); // Keep spots for 15 minutes +const PSKReporterPanel = ({ + callsign, + onShowOnMap, + showOnMap, + onToggleMap, + filters = {}, + onOpenFilters +}) => { const [activeTab, setActiveTab] = useState('tx'); // Default to 'tx' (Being Heard) const { @@ -22,11 +28,44 @@ const PSKReporterPanel = ({ callsign, onShowOnMap, showOnMap, onToggleMap }) => source, refresh } = usePSKReporter(callsign, { - minutes: timeWindow, + minutes: 15, enabled: callsign && callsign !== 'N0CALL' }); - const reports = activeTab === 'tx' ? txReports : rxReports; + // Filter reports by band, grid, and mode + const filterReports = (reports) => { + return reports.filter(r => { + // Band filter + if (filters?.bands?.length && !filters.bands.includes(r.band)) return false; + + // Grid filter (prefix match) + if (filters?.grids?.length) { + const grid = activeTab === 'tx' ? r.receiverGrid : r.senderGrid; + if (!grid) return false; + const gridPrefix = grid.substring(0, 2).toUpperCase(); + if (!filters.grids.includes(gridPrefix)) return false; + } + + // Mode filter + if (filters?.modes?.length && !filters.modes.includes(r.mode)) return false; + + return true; + }); + }; + + const filteredTx = useMemo(() => filterReports(txReports), [txReports, filters, activeTab]); + const filteredRx = useMemo(() => filterReports(rxReports), [rxReports, filters, activeTab]); + const filteredReports = activeTab === 'tx' ? filteredTx : filteredRx; + + // Count active filters + const getActiveFilterCount = () => { + let count = 0; + if (filters?.bands?.length) count++; + if (filters?.grids?.length) count++; + if (filters?.modes?.length) count++; + return count; + }; + const filterCount = getActiveFilterCount(); // Get band color from frequency const getFreqColor = (freqMHz) => { @@ -77,7 +116,7 @@ const PSKReporterPanel = ({ callsign, onShowOnMap, showOnMap, onToggleMap }) => height: '100%', overflow: 'hidden' }}> - {/* Header - matches DX Cluster style */} + {/* Header */}
}}> 📡 PSKReporter {getStatusIndicator()}
+ + {filteredReports.length}/{activeTab === 'tx' ? txCount : rxCount} + +
- {/* Tabs - compact style */} + {/* Tabs */}
fontFamily: 'JetBrains Mono' }} > - 📤 Being Heard ({txCount}) + 📤 Being Heard ({filterCount > 0 ? `${filteredTx.length}` : txCount})
- {/* Reports list - matches DX Cluster style */} + {/* Reports list */} {error && !connected ? (
⚠️ Connection failed - click 🔄 to retry
- ) : loading && reports.length === 0 ? ( + ) : loading && filteredReports.length === 0 && filterCount === 0 ? (
Connecting to MQTT...
- ) : !connected && reports.length === 0 ? ( + ) : !connected && filteredReports.length === 0 && filterCount === 0 ? (
Waiting for connection...
- ) : reports.length === 0 ? ( + ) : filteredReports.length === 0 ? (
- {activeTab === 'tx' - ? 'Waiting for spots... (TX to see reports)' - : 'No stations heard yet'} + {filterCount > 0 + ? 'No spots match filters' + : activeTab === 'tx' + ? 'Waiting for spots... (TX to see reports)' + : 'No stations heard yet'}
) : (
fontSize: '12px', fontFamily: 'JetBrains Mono, monospace' }}> - {reports.slice(0, 20).map((report, i) => { + {filteredReports.slice(0, 20).map((report, i) => { const freqMHz = report.freqMHz || (report.freq ? (report.freq / 1000000).toFixed(3) : '?'); const color = getFreqColor(freqMHz); const displayCall = activeTab === 'tx' ? report.receiver : report.sender; diff --git a/src/components/index.js b/src/components/index.js index 7d84316..3093e72 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -13,6 +13,7 @@ export { ContestPanel } from './ContestPanel.jsx'; export { LocationPanel } from './LocationPanel.jsx'; export { SettingsPanel } from './SettingsPanel.jsx'; export { DXFilterManager } from './DXFilterManager.jsx'; +export { PSKFilterManager } from './PSKFilterManager.jsx'; export { SolarPanel } from './SolarPanel.jsx'; export { PropagationPanel } from './PropagationPanel.jsx'; export { DXpeditionPanel } from './DXpeditionPanel.jsx';