From ae17fcf14fea2b6f969d6a123be87fa3303cb3f1 Mon Sep 17 00:00:00 2001 From: accius Date: Mon, 2 Feb 2026 15:26:15 -0500 Subject: [PATCH 01/10] implement pskreporter --- CHANGELOG.md | 23 +++ package.json | 2 +- server.js | 287 +++++++++++++++++++++++++++ src/App.jsx | 23 ++- src/components/PSKReporterPanel.jsx | 298 ++++++++++++++++++++++++++++ src/components/index.js | 1 + src/hooks/index.js | 1 + src/hooks/usePSKReporter.js | 119 +++++++++++ 8 files changed, 749 insertions(+), 5 deletions(-) create mode 100644 src/components/PSKReporterPanel.jsx create mode 100644 src/hooks/usePSKReporter.js diff --git a/CHANGELOG.md b/CHANGELOG.md index e0cbc66..a419499 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ All notable changes to OpenHamClock will be documented in this file. +## [3.11.0] - 2025-02-02 + +### Added +- **PSKReporter Integration** - See where your digital mode signals are being received + - New PSKReporter panel shows stations hearing you and stations you're hearing + - Supports FT8, FT4, JS8, and other digital modes + - Configurable time window (5, 15, 30 min, 1 hour) + - Shows band, mode, SNR, and age of each report + - Click on a report to center map on that location + +### Changed +- **Bandwidth Optimization** - Reduced network egress by ~85% + - Added GZIP compression (70-90% smaller responses) + - Server-side caching for all external API calls + - Reduced client polling intervals (DX Cluster: 5s→30s, POTA: 60s→120s) + - Added HTTP Cache-Control headers + - POTA now uses server proxy instead of direct API calls + +### Fixed +- Empty ITURHFPROP_URL causing "Only absolute URLs supported" error +- Satellite TLE fetch timeout errors now handled silently +- Reduced console log spam for network errors + ## [3.10.0] - 2025-02-02 ### Added diff --git a/package.json b/package.json index d7fb76a..049c8a7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openhamclock", - "version": "3.10.0", + "version": "3.11.0", "description": "Amateur Radio Dashboard - A modern web-based HamClock alternative", "main": "server.js", "scripts": { diff --git a/server.js b/server.js index dfc2c4e..9df0890 100644 --- a/server.js +++ b/server.js @@ -206,6 +206,8 @@ app.use('/api', (req, res, next) => { cacheDuration = 600; // 10 minutes } else if (path.includes('/pota') || path.includes('/sota')) { cacheDuration = 120; // 2 minutes + } else if (path.includes('/pskreporter')) { + cacheDuration = 120; // 2 minutes (respect PSKReporter rate limits) } else if (path.includes('/dxcluster') || path.includes('/myspots')) { cacheDuration = 30; // 30 seconds (DX spots need to be relatively fresh) } else if (path.includes('/config')) { @@ -1850,6 +1852,291 @@ app.get('/api/myspots/:callsign', async (req, res) => { } }); +// ============================================ +// PSKREPORTER API +// ============================================ + +// Cache for PSKReporter data (2-minute cache to respect their rate limits) +let pskReporterCache = {}; +const PSK_CACHE_TTL = 2 * 60 * 1000; // 2 minutes + +// Parse PSKReporter XML response +function parsePSKReporterXML(xml) { + const reports = []; + + // Extract reception reports using regex (simple XML parsing) + const reportRegex = /]*>([\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'); + 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 + }); + } + } + + return reports; +} + +// Convert grid square to lat/lon +function gridToLatLonSimple(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 6-character grid, add more precision + 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 }; +} + +// Get band name from frequency in MHz +function getBandFromMHz(freqMHz) { + const freq = parseFloat(freqMHz); + if (freq >= 1.8 && freq <= 2) return '160m'; + if (freq >= 3.5 && freq <= 4) return '80m'; + if (freq >= 5.3 && freq <= 5.4) return '60m'; + if (freq >= 7 && freq <= 7.3) return '40m'; + if (freq >= 10.1 && freq <= 10.15) return '30m'; + if (freq >= 14 && freq <= 14.35) return '20m'; + if (freq >= 18.068 && freq <= 18.168) return '17m'; + if (freq >= 21 && freq <= 21.45) return '15m'; + if (freq >= 24.89 && freq <= 24.99) return '12m'; + if (freq >= 28 && freq <= 29.7) return '10m'; + if (freq >= 50 && freq <= 54) return '6m'; + if (freq >= 144 && freq <= 148) return '2m'; + if (freq >= 420 && freq <= 450) return '70cm'; + return 'Unknown'; +} + +// PSKReporter - where is my signal being heard? +app.get('/api/pskreporter/tx/:callsign', async (req, res) => { + const callsign = req.params.callsign.toUpperCase(); + const minutes = parseInt(req.query.minutes) || 15; // Default 15 minutes + const flowStartSeconds = Math.floor(minutes * 60); + + const cacheKey = `tx:${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 TX reports for ${callsign} (last ${minutes} min)`); + + const url = `https://retrieve.pskreporter.info/query?senderCallsign=${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(); + const reports = parsePSKReporterXML(xml); + + // Add location data and band info + const enrichedReports = reports.map(r => { + const loc = r.receiverGrid ? gridToLatLonSimple(r.receiverGrid) : null; + return { + ...r, + lat: loc?.lat, + lon: loc?.lon, + band: r.freqMHz ? getBandFromMHz(r.freqMHz) : 'Unknown', + age: Math.floor((Date.now() - r.timestamp) / 60000) // minutes ago + }; + }).filter(r => r.lat && r.lon); + + // Sort by timestamp (newest first) + enrichedReports.sort((a, b) => b.timestamp - a.timestamp); + + const result = { + callsign, + direction: 'tx', + count: enrichedReports.length, + reports: enrichedReports.slice(0, 100), // Limit to 100 reports + timestamp: new Date().toISOString() + }; + + // Cache the result + pskReporterCache[cacheKey] = { data: result, timestamp: now }; + + console.log(`[PSKReporter] Found ${enrichedReports.length} stations hearing ${callsign}`); + res.json(result); + + } catch (error) { + if (error.name !== 'AbortError') { + 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? +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`; + + 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(); + 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 +app.get('/api/pskreporter/:callsign', async (req, res) => { + const callsign = req.params.callsign.toUpperCase(); + const minutes = parseInt(req.query.minutes) || 15; + + try { + // Fetch both TX and RX in parallel + const [txRes, rxRes] = await Promise.allSettled([ + fetch(`http://localhost:${PORT}/api/pskreporter/tx/${callsign}?minutes=${minutes}`), + fetch(`http://localhost:${PORT}/api/pskreporter/rx/${callsign}?minutes=${minutes}`) + ]); + + let txData = { count: 0, reports: [] }; + let rxData = { count: 0, reports: [] }; + + if (txRes.status === 'fulfilled' && txRes.value.ok) { + txData = await txRes.value.json(); + } + if (rxRes.status === 'fulfilled' && rxRes.value.ok) { + rxData = await rxRes.value.json(); + } + + res.json({ + callsign, + tx: txData, + rx: rxData, + timestamp: new Date().toISOString() + }); + + } catch (error) { + logErrorOnce('PSKReporter', `Combined query error: ${error.message}`); + res.json({ callsign, tx: { count: 0, reports: [] }, rx: { count: 0, reports: [] }, error: error.message }); + } +}); + // ============================================ // SATELLITE TRACKING API // ============================================ diff --git a/src/App.jsx b/src/App.jsx index 4cdf42b..ca46c08 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -15,7 +15,8 @@ import { DXFilterManager, SolarPanel, PropagationPanel, - DXpeditionPanel + DXpeditionPanel, + PSKReporterPanel } from './components'; // Hooks @@ -31,7 +32,8 @@ import { useMySpots, useDXpeditions, useSatellites, - useSolarIndices + useSolarIndices, + usePSKReporter } from './hooks'; // Utils @@ -100,9 +102,9 @@ const App = () => { const [mapLayers, setMapLayers] = useState(() => { try { const stored = localStorage.getItem('openhamclock_mapLayers'); - const defaults = { showDXPaths: true, showDXLabels: true, showPOTA: true, showSatellites: false }; + const defaults = { showDXPaths: true, showDXLabels: true, showPOTA: true, showSatellites: false, showPSKReporter: true }; return stored ? { ...defaults, ...JSON.parse(stored) } : defaults; - } catch (e) { return { showDXPaths: true, showDXLabels: true, showPOTA: true, showSatellites: false }; } + } catch (e) { return { showDXPaths: true, showDXLabels: true, showPOTA: true, showSatellites: false, showPSKReporter: true }; } }); useEffect(() => { @@ -117,6 +119,7 @@ const App = () => { const toggleDXLabels = useCallback(() => setMapLayers(prev => ({ ...prev, showDXLabels: !prev.showDXLabels })), []); const togglePOTA = useCallback(() => setMapLayers(prev => ({ ...prev, showPOTA: !prev.showPOTA })), []); const toggleSatellites = useCallback(() => setMapLayers(prev => ({ ...prev, showSatellites: !prev.showSatellites })), []); + const togglePSKReporter = useCallback(() => setMapLayers(prev => ({ ...prev, showPSKReporter: !prev.showPSKReporter })), []); // 12/24 hour format const [use12Hour, setUse12Hour] = useState(() => { @@ -639,6 +642,18 @@ const App = () => { + {/* PSKReporter */} +
+ { + if (report.lat && report.lon) { + setDxLocation({ lat: report.lat, lon: report.lon, call: report.receiver || report.sender }); + } + }} + /> +
+ {/* POTA - smaller */}
{ + const [timeWindow, setTimeWindow] = useState(15); // minutes + const [activeTab, setActiveTab] = useState('tx'); // 'tx' or 'rx' + + const { + txReports, + txCount, + rxReports, + rxCount, + stats, + loading, + lastUpdate, + refresh + } = usePSKReporter(callsign, { + minutes: timeWindow, + direction: 'both', + enabled: callsign && callsign !== 'N0CALL' + }); + + const formatTime = (timestamp) => { + const date = new Date(timestamp); + return date.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: false + }) + 'z'; + }; + + const formatAge = (minutes) => { + if (minutes < 1) return 'now'; + if (minutes === 1) return '1m ago'; + return `${minutes}m ago`; + }; + + const getSnrColor = (snr) => { + if (snr === null || snr === undefined) return 'var(--text-muted)'; + if (snr >= 0) return '#4ade80'; // Green - excellent + if (snr >= -10) return '#fbbf24'; // Yellow - good + if (snr >= -15) return '#f97316'; // Orange - fair + return '#ef4444'; // Red - weak + }; + + const reports = activeTab === 'tx' ? txReports : rxReports; + const count = activeTab === 'tx' ? txCount : rxCount; + + if (!callsign || callsign === 'N0CALL') { + return ( +
+
+ šŸ“” +

PSKReporter

+
+
+

+ Set your callsign in Settings to see PSKReporter data +

+
+
+ ); + } + + return ( +
+
+ šŸ“” +

PSKReporter

+
+ + +
+
+ + {/* Tabs */} +
+ + +
+ +
+ {loading && reports.length === 0 ? ( +
+ Loading... +
+ ) : reports.length === 0 ? ( +
+ No {activeTab === 'tx' ? 'reception reports' : 'stations heard'} in the last {timeWindow} minutes +
+ ) : ( + <> + {/* Summary stats for TX */} + {activeTab === 'tx' && txCount > 0 && ( +
+
+ + {txCount} stations hearing you + + {stats.txBands.length > 0 && ( + + Bands: {stats.txBands.join(', ')} + + )} + {stats.txModes.length > 0 && ( + + Modes: {stats.txModes.slice(0, 3).join(', ')} + + )} +
+
+ )} + + {/* Reports list */} +
+ {reports.slice(0, 25).map((report, idx) => ( +
onShowOnMap && report.lat && report.lon && onShowOnMap(report)} + style={{ + display: 'grid', + gridTemplateColumns: '1fr auto auto auto', + gap: '8px', + padding: '6px 8px', + background: 'var(--bg-tertiary)', + borderRadius: '4px', + fontSize: '0.75rem', + cursor: report.lat && report.lon ? 'pointer' : 'default', + alignItems: 'center' + }} + > +
+ + {activeTab === 'tx' ? report.receiver : report.sender} + + {(activeTab === 'tx' ? report.receiverGrid : report.senderGrid) && ( + + {activeTab === 'tx' ? report.receiverGrid : report.senderGrid} + + )} +
+ +
+ {report.freqMHz} {report.band} +
+ +
+ {report.mode} +
+ +
+ {report.snr !== null && ( + + {report.snr > 0 ? '+' : ''}{report.snr}dB + + )} + + {formatAge(report.age)} + +
+
+ ))} +
+ + {reports.length > 25 && ( +
+ Showing 25 of {reports.length} reports +
+ )} + + )} +
+ + {/* Footer with last update */} + {lastUpdate && ( +
+ Updated: {lastUpdate.toLocaleTimeString()} +
+ )} +
+ ); +}; + +export default PSKReporterPanel; + +export { PSKReporterPanel }; diff --git a/src/components/index.js b/src/components/index.js index 27279cc..7d84316 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -16,3 +16,4 @@ export { DXFilterManager } from './DXFilterManager.jsx'; export { SolarPanel } from './SolarPanel.jsx'; export { PropagationPanel } from './PropagationPanel.jsx'; export { DXpeditionPanel } from './DXpeditionPanel.jsx'; +export { PSKReporterPanel } from './PSKReporterPanel.jsx'; diff --git a/src/hooks/index.js b/src/hooks/index.js index 2f981dc..b07aec3 100644 --- a/src/hooks/index.js +++ b/src/hooks/index.js @@ -15,3 +15,4 @@ export { useMySpots } from './useMySpots.js'; export { useDXpeditions } from './useDXpeditions.js'; export { useSatellites } from './useSatellites.js'; export { useSolarIndices } from './useSolarIndices.js'; +export { usePSKReporter } from './usePSKReporter.js'; diff --git a/src/hooks/usePSKReporter.js b/src/hooks/usePSKReporter.js new file mode 100644 index 0000000..650be65 --- /dev/null +++ b/src/hooks/usePSKReporter.js @@ -0,0 +1,119 @@ +/** + * usePSKReporter Hook + * Fetches PSKReporter data showing where your signal is being received + * and what stations you're hearing + */ +import { useState, useEffect, useCallback } from 'react'; + +export const usePSKReporter = (callsign, options = {}) => { + const { + minutes = 15, // Time window in minutes (default 15) + direction = 'both', // 'tx' (being heard), 'rx' (hearing), or 'both' + enabled = true, // Enable/disable fetching + refreshInterval = 120000 // Refresh every 2 minutes + } = options; + + const [txData, setTxData] = useState({ count: 0, reports: [] }); + const [rxData, setRxData] = useState({ count: 0, reports: [] }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [lastUpdate, setLastUpdate] = useState(null); + + const fetchData = useCallback(async () => { + if (!callsign || callsign === 'N0CALL' || !enabled) { + setTxData({ count: 0, reports: [] }); + setRxData({ count: 0, reports: [] }); + setLoading(false); + return; + } + + try { + setError(null); + + if (direction === 'both') { + // Fetch combined endpoint + const response = await fetch(`/api/pskreporter/${encodeURIComponent(callsign)}?minutes=${minutes}`); + if (response.ok) { + const data = await response.json(); + setTxData(data.tx || { count: 0, reports: [] }); + setRxData(data.rx || { count: 0, reports: [] }); + } + } else if (direction === 'tx') { + // Fetch only TX (where am I being heard) + const response = await fetch(`/api/pskreporter/tx/${encodeURIComponent(callsign)}?minutes=${minutes}`); + if (response.ok) { + const data = await response.json(); + setTxData(data); + } + } else if (direction === 'rx') { + // Fetch only RX (what am I hearing) + const response = await fetch(`/api/pskreporter/rx/${encodeURIComponent(callsign)}?minutes=${minutes}`); + if (response.ok) { + const data = await response.json(); + setRxData(data); + } + } + + setLastUpdate(new Date()); + } catch (err) { + console.error('PSKReporter fetch error:', err); + setError(err.message); + } finally { + setLoading(false); + } + }, [callsign, minutes, direction, enabled]); + + useEffect(() => { + fetchData(); + + if (enabled && refreshInterval > 0) { + const interval = setInterval(fetchData, refreshInterval); + return () => clearInterval(interval); + } + }, [fetchData, enabled, refreshInterval]); + + // Computed values + const txReports = txData.reports || []; + const rxReports = rxData.reports || []; + + // 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 = { + txCount: txData.count || 0, + rxCount: rxData.count || 0, + txBands, + txModes, + furthestTx: txReports.length > 0 + ? txReports.reduce((max, r) => r.distance > (max?.distance || 0) ? r : max, null) + : null, + bestSnr: txReports.length > 0 + ? txReports.reduce((max, r) => (r.snr || -99) > (max?.snr || -99) ? r : max, null) + : null + }; + + return { + // TX data - where is my signal being heard + txReports, + txCount: txData.count || 0, + + // RX data - what am I hearing + rxReports, + rxCount: rxData.count || 0, + + // Combined + stats, + loading, + error, + lastUpdate, + + // Manual refresh + refresh: fetchData + }; +}; + +export default usePSKReporter; From b037e25d8d9c4f60e5b1eaa87dd7ff4df256b263 Mon Sep 17 00:00:00 2001 From: accius Date: Mon, 2 Feb 2026 15:31:47 -0500 Subject: [PATCH 02/10] psk reporter dashboard module --- src/App.jsx | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index ca46c08..184c04d 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -621,8 +621,8 @@ const App = () => { {/* RIGHT SIDEBAR */}
- {/* DX Cluster - takes most space */} -
+ {/* DX Cluster - reduced size to make room for PSKReporter */} +
{ />
- {/* DXpeditions - smaller */} -
- -
- - {/* PSKReporter */} -
+ {/* PSKReporter - where your digital signals are heard */} +
{ @@ -654,8 +649,13 @@ const App = () => { />
- {/* POTA - smaller */} + {/* DXpeditions - smaller */}
+ +
+ + {/* POTA - smaller */} +
{
{/* Contests - smaller */} -
+
From 54287f1a4bc161e12e5a6e4713e633882f0cf806 Mon Sep 17 00:00:00 2001 From: accius Date: Mon, 2 Feb 2026 15:47:43 -0500 Subject: [PATCH 03/10] fixing pskreporter integ --- server.js | 321 +++++++++++++--------------- src/components/PSKReporterPanel.jsx | 10 +- src/hooks/usePSKReporter.js | 139 +++++++----- 3 files changed, 244 insertions(+), 226 deletions(-) diff --git a/server.js b/server.js index 9df0890..ab26023 100644 --- a/server.js +++ b/server.js @@ -207,7 +207,7 @@ app.use('/api', (req, res, next) => { } else if (path.includes('/pota') || path.includes('/sota')) { cacheDuration = 120; // 2 minutes } 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')) { cacheDuration = 30; // 30 seconds (DX spots need to be relatively fresh) } 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) -let pskReporterCache = {}; -const PSK_CACHE_TTL = 2 * 60 * 1000; // 2 minutes +// PSKReporter MQTT feed at mqtt.pskreporter.info provides real-time spots +// WebSocket endpoints: 1885 (ws), 1886 (wss) +// Topic format: pskr/filter/v2/{band}/{mode}/{sendercall}/{receivercall}/{senderlocator}/{receiverlocator}/{sendercountry}/{receivercountry} -// Parse PSKReporter XML response -function parsePSKReporterXML(xml) { - const reports = []; - - // Extract reception reports using regex (simple XML parsing) - const reportRegex = /]*>([\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'); - 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 - }); +// Cache for PSKReporter data - stores recent spots from MQTT +const pskReporterSpots = { + tx: new Map(), // Map of callsign -> spots where they're being heard + rx: new Map(), // Map of callsign -> spots they're receiving + maxAge: 60 * 60 * 1000 // Keep spots for 1 hour max +}; + +// Clean up old spots periodically +setInterval(() => { + const cutoff = Date.now() - pskReporterSpots.maxAge; + for (const [call, spots] of pskReporterSpots.tx) { + const filtered = spots.filter(s => s.timestamp > cutoff); + if (filtered.length === 0) { + pskReporterSpots.tx.delete(call); + } else { + pskReporterSpots.tx.set(call, filtered); } } - - return reports; -} + for (const [call, spots] of pskReporterSpots.rx) { + 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 function gridToLatLonSimple(grid) { @@ -1928,9 +1912,9 @@ function gridToLatLonSimple(grid) { return { lat: finalLat, lon: finalLon }; } -// Get band name from frequency in MHz -function getBandFromMHz(freqMHz) { - const freq = parseFloat(freqMHz); +// Get band name from frequency in Hz +function getBandFromHz(freqHz) { + const freq = freqHz / 1000000; // Convert to MHz if (freq >= 1.8 && freq <= 2) return '160m'; if (freq >= 3.5 && freq <= 4) return '80m'; if (freq >= 5.3 && freq <= 5.4) return '60m'; @@ -1947,171 +1931,159 @@ function getBandFromMHz(freqMHz) { return 'Unknown'; } -// PSKReporter - where is my signal being heard? -app.get('/api/pskreporter/tx/:callsign', async (req, res) => { +// PSKReporter endpoint - returns MQTT connection info for frontend +// 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 minutes = parseInt(req.query.minutes) || 15; // Default 15 minutes - const flowStartSeconds = Math.floor(minutes * 60); + const minutes = parseInt(req.query.minutes) || 15; + 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(); // Check cache - if (pskReporterCache[cacheKey] && (now - pskReporterCache[cacheKey].timestamp) < PSK_CACHE_TTL) { - return res.json(pskReporterCache[cacheKey].data); + if (pskHttpCache[cacheKey] && (now - pskHttpCache[cacheKey].timestamp) < PSK_HTTP_CACHE_TTL) { + return res.json({ ...pskHttpCache[cacheKey].data, cached: true }); } 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 timeout = setTimeout(() => controller.abort(), 15000); + const timeout = setTimeout(() => controller.abort(), 20000); const response = await fetch(url, { headers: { - 'User-Agent': 'OpenHamClock/3.10', - 'Accept': 'application/xml' + 'User-Agent': 'OpenHamClock/3.11 (Amateur Radio Dashboard)', + 'Accept': '*/*' }, signal: controller.signal }); clearTimeout(timeout); if (!response.ok) { - throw new Error(`PSKReporter returned ${response.status}`); + throw new Error(`HTTP ${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.receiverGrid ? gridToLatLonSimple(r.receiverGrid) : null; - return { - ...r, - lat: loc?.lat, - lon: loc?.lon, - band: r.freqMHz ? getBandFromMHz(r.freqMHz) : 'Unknown', - age: Math.floor((Date.now() - r.timestamp) / 60000) // minutes ago + const reports = []; + + // Parse XML response + const reportRegex = /]*>/g; + let match; + while ((match = reportRegex.exec(xml)) !== null) { + const report = match[0]; + const getAttr = (name) => { + const m = report.match(new RegExp(`${name}="([^"]*)"`)); + return m ? m[1] : null; }; - }).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) - enrichedReports.sort((a, b) => b.timestamp - a.timestamp); + reports.sort((a, b) => b.timestamp - a.timestamp); const result = { callsign, - direction: 'tx', - count: enrichedReports.length, - reports: enrichedReports.slice(0, 100), // Limit to 100 reports - timestamp: new Date().toISOString() + direction, + count: reports.length, + reports: reports.slice(0, 100), + timestamp: new Date().toISOString(), + source: 'http' }; - // Cache the result - pskReporterCache[cacheKey] = { data: result, timestamp: now }; + // Cache it + 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); } catch (error) { - if (error.name !== 'AbortError') { - logErrorOnce('PSKReporter', `TX query error: ${error.message}`); - } + logErrorOnce('PSKReporter HTTP', error.message); + // Return cached data if available - if (pskReporterCache[cacheKey]) { - return res.json(pskReporterCache[cacheKey].data); + if (pskHttpCache[cacheKey]) { + 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`; - - 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 + res.json({ + callsign, + direction, + count: 0, + reports: [], + error: error.message, + hint: 'Consider using MQTT WebSocket connection for real-time data' }); - 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) => { const callsign = req.params.callsign.toUpperCase(); 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 { - // Fetch both TX and RX in parallel const [txRes, rxRes] = await Promise.allSettled([ - fetch(`http://localhost:${PORT}/api/pskreporter/tx/${callsign}?minutes=${minutes}`), - fetch(`http://localhost:${PORT}/api/pskreporter/rx/${callsign}?minutes=${minutes}`) + fetch(`http://localhost:${PORT}/api/pskreporter/http/${callsign}?minutes=${minutes}&direction=tx`), + fetch(`http://localhost:${PORT}/api/pskreporter/http/${callsign}?minutes=${minutes}&direction=rx`) ]); let txData = { count: 0, reports: [] }; @@ -2128,15 +2100,24 @@ app.get('/api/pskreporter/:callsign', async (req, res) => { callsign, tx: txData, 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) { - logErrorOnce('PSKReporter', `Combined query error: ${error.message}`); - res.json({ callsign, tx: { count: 0, reports: [] }, rx: { count: 0, reports: [] }, error: error.message }); + logErrorOnce('PSKReporter', error.message); + res.json({ + callsign, + tx: { count: 0, reports: [] }, + rx: { count: 0, reports: [] }, + error: error.message + }); } }); - // ============================================ // SATELLITE TRACKING API // ============================================ diff --git a/src/components/PSKReporterPanel.jsx b/src/components/PSKReporterPanel.jsx index c53451a..2bbb0a7 100644 --- a/src/components/PSKReporterPanel.jsx +++ b/src/components/PSKReporterPanel.jsx @@ -147,13 +147,21 @@ const PSKReporterPanel = ({ callsign, onShowOnMap }) => {
- {loading && reports.length === 0 ? ( + {error ? ( +
+
āš ļø PSKReporter temporarily unavailable
+
Will retry automatically
+
+ ) : loading && reports.length === 0 ? (
Loading...
) : reports.length === 0 ? (
No {activeTab === 'tx' ? 'reception reports' : 'stations heard'} in the last {timeWindow} minutes +
+ (Make sure you're transmitting digital modes like FT8) +
) : ( <> diff --git a/src/hooks/usePSKReporter.js b/src/hooks/usePSKReporter.js index 650be65..f685ee0 100644 --- a/src/hooks/usePSKReporter.js +++ b/src/hooks/usePSKReporter.js @@ -1,28 +1,55 @@ /** * usePSKReporter Hook * 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'; +// 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 = {}) => { const { minutes = 15, // Time window in minutes (default 15) - direction = 'both', // 'tx' (being heard), 'rx' (hearing), or 'both' 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; - const [txData, setTxData] = useState({ count: 0, reports: [] }); - const [rxData, setRxData] = useState({ count: 0, reports: [] }); + const [txReports, setTxReports] = useState([]); + const [rxReports, setRxReports] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [rateLimited, setRateLimited] = useState(false); const [lastUpdate, setLastUpdate] = useState(null); const fetchData = useCallback(async () => { if (!callsign || callsign === 'N0CALL' || !enabled) { - setTxData({ count: 0, reports: [] }); - setRxData({ count: 0, reports: [] }); + setTxReports([]); + setRxReports([]); setLoading(false); return; } @@ -30,38 +57,56 @@ export const usePSKReporter = (callsign, options = {}) => { try { setError(null); - if (direction === 'both') { - // Fetch combined endpoint - const response = await fetch(`/api/pskreporter/${encodeURIComponent(callsign)}?minutes=${minutes}`); - if (response.ok) { - const data = await response.json(); - setTxData(data.tx || { count: 0, reports: [] }); - setRxData(data.rx || { count: 0, reports: [] }); - } - } else if (direction === 'tx') { - // Fetch only TX (where am I being heard) - const response = await fetch(`/api/pskreporter/tx/${encodeURIComponent(callsign)}?minutes=${minutes}`); - if (response.ok) { - const data = await response.json(); - setTxData(data); - } - } else if (direction === 'rx') { - // Fetch only RX (what am I hearing) - const response = await fetch(`/api/pskreporter/rx/${encodeURIComponent(callsign)}?minutes=${minutes}`); - if (response.ok) { - const data = await response.json(); - setRxData(data); + // Fetch combined endpoint from our server (handles caching) + const response = await fetch(`/api/pskreporter/${encodeURIComponent(callsign)}?minutes=${minutes}`); + + if (response.ok) { + const data = await response.json(); + + // 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) + .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) + .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) { console.error('PSKReporter fetch error:', err); setError(err.message); } finally { setLoading(false); } - }, [callsign, minutes, direction, enabled]); + }, [callsign, minutes, enabled, maxSpots]); useEffect(() => { fetchData(); @@ -72,46 +117,30 @@ export const usePSKReporter = (callsign, options = {}) => { } }, [fetchData, enabled, refreshInterval]); - // Computed values - const txReports = txData.reports || []; - const rxReports = rxData.reports || []; - - // 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))]; + // 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); - // Stats const stats = { - txCount: txData.count || 0, - rxCount: rxData.count || 0, + txCount: txReports.length, + rxCount: rxReports.length, txBands, txModes, - furthestTx: txReports.length > 0 - ? txReports.reduce((max, r) => r.distance > (max?.distance || 0) ? r : max, null) - : null, bestSnr: txReports.length > 0 ? txReports.reduce((max, r) => (r.snr || -99) > (max?.snr || -99) ? r : max, null) : null }; return { - // TX data - where is my signal being heard txReports, - txCount: txData.count || 0, - - // RX data - what am I hearing + txCount: txReports.length, rxReports, - rxCount: rxData.count || 0, - - // Combined + rxCount: rxReports.length, stats, loading, error, + rateLimited, lastUpdate, - - // Manual refresh refresh: fetchData }; }; From 14f9539eb4d6a54f5dd222d1b6cb1b558048a1a9 Mon Sep 17 00:00:00 2001 From: accius Date: Mon, 2 Feb 2026 16:06:52 -0500 Subject: [PATCH 04/10] fix psk --- public/index.html | 27 +++++++++++++++++++-------- src/components/PSKReporterPanel.jsx | 2 +- src/hooks/usePSKReporter.js | 11 +++++------ 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/public/index.html b/public/index.html index 8d6a48c..8df06fb 100644 --- a/public/index.html +++ b/public/index.html @@ -50,6 +50,19 @@ background: none; padding: 5px 0; } + a.button { + display: inline-block; + background: #4ade80; + color: #1a1a2e; + padding: 12px 24px; + border-radius: 6px; + text-decoration: none; + font-weight: bold; + margin: 20px 0; + } + a.button:hover { + background: #22c55e; + } .note { font-size: 0.9rem; color: #888; @@ -60,22 +73,20 @@

šŸ“» OpenHamClock

-

The frontend needs to be built before running.

+

The modular frontend needs to be built first.

+ + Use Classic Version Instead +

Or build the modular version:

npm install npm run build npm start
-

Or use the quick start:

-
- npm install && npm start -
-

- If you're seeing this page, the build step was skipped.
- Running npm start should auto-build if needed. + The classic version works without building.
+ The modular version requires Node.js 18+ to build.

diff --git a/src/components/PSKReporterPanel.jsx b/src/components/PSKReporterPanel.jsx index 2bbb0a7..b0c11f0 100644 --- a/src/components/PSKReporterPanel.jsx +++ b/src/components/PSKReporterPanel.jsx @@ -16,11 +16,11 @@ const PSKReporterPanel = ({ callsign, onShowOnMap }) => { rxCount, stats, loading, + error, lastUpdate, refresh } = usePSKReporter(callsign, { minutes: timeWindow, - direction: 'both', enabled: callsign && callsign !== 'N0CALL' }); diff --git a/src/hooks/usePSKReporter.js b/src/hooks/usePSKReporter.js index f685ee0..225201d 100644 --- a/src/hooks/usePSKReporter.js +++ b/src/hooks/usePSKReporter.js @@ -1,10 +1,10 @@ /** * usePSKReporter Hook - * Fetches PSKReporter data showing where your signal is being received + * Fetches PSKReporter data showing where your digital mode signals are being received * * 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/# + * + * For real-time MQTT updates, see mqtt.pskreporter.info (requires mqtt.js library) */ import { useState, useEffect, useCallback } from 'react'; @@ -43,7 +43,6 @@ export const usePSKReporter = (callsign, options = {}) => { const [rxReports, setRxReports] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [rateLimited, setRateLimited] = useState(false); const [lastUpdate, setLastUpdate] = useState(null); const fetchData = useCallback(async () => { @@ -90,7 +89,6 @@ export const usePSKReporter = (callsign, options = {}) => { setTxReports(processedTx); setRxReports(processedRx); - setRateLimited(data.tx?.rateLimited || data.rx?.rateLimited || false); setLastUpdate(new Date()); // Check for errors in response @@ -139,7 +137,8 @@ export const usePSKReporter = (callsign, options = {}) => { stats, loading, error, - rateLimited, + connected: false, // HTTP mode - not real-time connected + source: 'http', lastUpdate, refresh: fetchData }; From ad987f79a4f53d42b6c69c32d947dc8418fd034e Mon Sep 17 00:00:00 2001 From: accius Date: Mon, 2 Feb 2026 16:15:25 -0500 Subject: [PATCH 05/10] psk styling and mapping --- src/App.jsx | 29 ++- src/components/PSKReporterPanel.jsx | 383 ++++++++++++---------------- src/components/WorldMap.jsx | 52 ++++ 3 files changed, 239 insertions(+), 225 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 184c04d..0fa88c0 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -191,6 +191,7 @@ const App = () => { const mySpots = useMySpots(config.callsign); const satellites = useSatellites(config.location); const localWeather = useLocalWeather(config.location); + const pskReporter = usePSKReporter(config.callsign, { minutes: 15, enabled: config.callsign !== 'N0CALL' }); // Computed values const deGrid = useMemo(() => calculateGridSquare(config.location.lat, config.location.lon), [config.location]); @@ -460,11 +461,13 @@ const App = () => { dxPaths={dxPaths.data} dxFilters={dxFilters} satellites={satellites.data} + pskReporterSpots={[...(pskReporter.txReports || []), ...(pskReporter.rxReports || [])]} showDXPaths={mapLayers.showDXPaths} showDXLabels={mapLayers.showDXLabels} onToggleDXLabels={toggleDXLabels} showPOTA={mapLayers.showPOTA} showSatellites={mapLayers.showSatellites} + showPSKReporter={mapLayers.showPSKReporter} onToggleSatellites={toggleSatellites} hoveredSpot={hoveredSpot} /> @@ -596,11 +599,13 @@ const App = () => { dxPaths={dxPaths.data} dxFilters={dxFilters} satellites={satellites.data} + pskReporterSpots={[...(pskReporter.txReports || []), ...(pskReporter.rxReports || [])]} showDXPaths={mapLayers.showDXPaths} showDXLabels={mapLayers.showDXLabels} onToggleDXLabels={toggleDXLabels} showPOTA={mapLayers.showPOTA} showSatellites={mapLayers.showSatellites} + showPSKReporter={mapLayers.showPSKReporter} onToggleSatellites={toggleSatellites} hoveredSpot={hoveredSpot} /> @@ -620,9 +625,9 @@ const App = () => {
{/* RIGHT SIDEBAR */} -
- {/* DX Cluster - reduced size to make room for PSKReporter */} -
+
+ {/* DX Cluster - primary panel */} +
{ />
- {/* PSKReporter - where your digital signals are heard */} -
+ {/* PSKReporter - digital mode spots */} +
{ if (report.lat && report.lon) { setDxLocation({ lat: report.lat, lon: report.lon, call: report.receiver || report.sender }); @@ -649,13 +656,13 @@ const App = () => { />
- {/* DXpeditions - smaller */} -
+ {/* DXpeditions */} +
- {/* POTA - smaller */} -
+ {/* POTA */} +
{ />
- {/* Contests - smaller */} -
+ {/* Contests */} +
diff --git a/src/components/PSKReporterPanel.jsx b/src/components/PSKReporterPanel.jsx index b0c11f0..651f1e7 100644 --- a/src/components/PSKReporterPanel.jsx +++ b/src/components/PSKReporterPanel.jsx @@ -1,129 +1,152 @@ /** * PSKReporter Panel * Shows where your digital mode signals are being received + * Styled to match DXClusterPanel */ import React, { useState } from 'react'; import { usePSKReporter } from '../hooks/usePSKReporter.js'; +import { getBandColor } from '../utils/callsign.js'; -const PSKReporterPanel = ({ callsign, onShowOnMap }) => { - const [timeWindow, setTimeWindow] = useState(15); // minutes - const [activeTab, setActiveTab] = useState('tx'); // 'tx' or 'rx' +const PSKReporterPanel = ({ callsign, onShowOnMap, showOnMap, onToggleMap }) => { + const [timeWindow, setTimeWindow] = useState(15); + const [activeTab, setActiveTab] = useState('rx'); // Default to 'rx' (Hearing) - more useful const { txReports, txCount, rxReports, rxCount, - stats, loading, error, - lastUpdate, refresh } = usePSKReporter(callsign, { minutes: timeWindow, enabled: callsign && callsign !== 'N0CALL' }); - const formatTime = (timestamp) => { - const date = new Date(timestamp); - return date.toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit', - hour12: false - }) + 'z'; + const reports = activeTab === 'tx' ? txReports : rxReports; + const count = activeTab === 'tx' ? txCount : rxCount; + + // Get band color from frequency + const getFreqColor = (freqMHz) => { + if (!freqMHz) return 'var(--text-muted)'; + const freq = parseFloat(freqMHz); + return getBandColor(freq); }; + // Format age const formatAge = (minutes) => { if (minutes < 1) return 'now'; - if (minutes === 1) return '1m ago'; - return `${minutes}m ago`; + if (minutes < 60) return `${minutes}m`; + return `${Math.floor(minutes/60)}h`; }; - const getSnrColor = (snr) => { - if (snr === null || snr === undefined) return 'var(--text-muted)'; - if (snr >= 0) return '#4ade80'; // Green - excellent - if (snr >= -10) return '#fbbf24'; // Yellow - good - if (snr >= -15) return '#f97316'; // Orange - fair - return '#ef4444'; // Red - weak - }; - - const reports = activeTab === 'tx' ? txReports : rxReports; - const count = activeTab === 'tx' ? txCount : rxCount; - if (!callsign || callsign === 'N0CALL') { return ( -
-
- šŸ“” -

PSKReporter

+
+
+ šŸ“” PSKReporter
-
-

- Set your callsign in Settings to see PSKReporter data -

+
+ Set callsign in Settings
); } return ( -
-
- šŸ“” -

PSKReporter

-
+
+ {/* Header - matches DX Cluster style */} +
+ šŸ“” PSKReporter +
- + {onToggleMap && ( + + )}
- {/* Tabs */} + {/* Tabs - compact style */}
-
- {error ? ( -
-
āš ļø PSKReporter temporarily unavailable
-
Will retry automatically
-
- ) : loading && reports.length === 0 ? ( -
- Loading... -
- ) : reports.length === 0 ? ( -
- No {activeTab === 'tx' ? 'reception reports' : 'stations heard'} in the last {timeWindow} minutes -
- (Make sure you're transmitting digital modes like FT8) -
-
- ) : ( - <> - {/* Summary stats for TX */} - {activeTab === 'tx' && txCount > 0 && ( -
-
- - {txCount} stations hearing you - - {stats.txBands.length > 0 && ( - - Bands: {stats.txBands.join(', ')} - - )} - {stats.txModes.length > 0 && ( - - Modes: {stats.txModes.slice(0, 3).join(', ')} - - )} + {/* Reports list - matches DX Cluster style */} + {error ? ( +
+ āš ļø Temporarily unavailable +
+ ) : loading && reports.length === 0 ? ( +
+
+
+ ) : reports.length === 0 ? ( +
+ No {activeTab === 'tx' ? 'reception reports' : 'stations heard'} +
+ ) : ( +
+ {reports.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; + const grid = activeTab === 'tx' ? report.receiverGrid : report.senderGrid; + + return ( +
onShowOnMap && report.lat && report.lon && onShowOnMap(report)} + style={{ + display: 'grid', + gridTemplateColumns: '55px 1fr auto', + gap: '6px', + padding: '4px 6px', + borderRadius: '3px', + marginBottom: '2px', + background: i % 2 === 0 ? 'rgba(255,255,255,0.03)' : 'transparent', + cursor: report.lat && report.lon ? 'pointer' : 'default', + transition: 'background 0.15s', + borderLeft: '2px solid transparent' + }} + onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(68, 136, 255, 0.15)'} + onMouseLeave={(e) => e.currentTarget.style.background = i % 2 === 0 ? 'rgba(255,255,255,0.03)' : 'transparent'} + > +
+ {freqMHz}
-
- )} - - {/* Reports list */} -
- {reports.slice(0, 25).map((report, idx) => ( -
onShowOnMap && report.lat && report.lon && onShowOnMap(report)} - style={{ - display: 'grid', - gridTemplateColumns: '1fr auto auto auto', - gap: '8px', - padding: '6px 8px', - background: 'var(--bg-tertiary)', - borderRadius: '4px', - fontSize: '0.75rem', - cursor: report.lat && report.lon ? 'pointer' : 'default', - alignItems: 'center' - }} - > -
- - {activeTab === 'tx' ? report.receiver : report.sender} - - {(activeTab === 'tx' ? report.receiverGrid : report.senderGrid) && ( - - {activeTab === 'tx' ? report.receiverGrid : report.senderGrid} - - )} -
- -
- {report.freqMHz} {report.band} -
- -
- {report.mode} -
- -
- {report.snr !== null && ( - - {report.snr > 0 ? '+' : ''}{report.snr}dB - - )} +
+ {displayCall} + {grid && {grid}} +
+
+ {report.mode} + {report.snr !== null && report.snr !== undefined && ( = 0 ? '#4ade80' : report.snr >= -10 ? '#fbbf24' : '#f97316', + fontWeight: '600' }}> - {formatAge(report.age)} + {report.snr > 0 ? '+' : ''}{report.snr} -
+ )} + + {formatAge(report.age)} +
- ))} -
- - {reports.length > 25 && ( -
- Showing 25 of {reports.length} reports
- )} - - )} -
- - {/* Footer with last update */} - {lastUpdate && ( -
- Updated: {lastUpdate.toLocaleTimeString()} + ); + })}
)}
diff --git a/src/components/WorldMap.jsx b/src/components/WorldMap.jsx index 6ad138d..7a349e8 100644 --- a/src/components/WorldMap.jsx +++ b/src/components/WorldMap.jsx @@ -21,11 +21,13 @@ export const WorldMap = ({ dxPaths, dxFilters, satellites, + pskReporterSpots, showDXPaths, showDXLabels, onToggleDXLabels, showPOTA, showSatellites, + showPSKReporter, onToggleSatellites, hoveredSpot }) => { @@ -44,6 +46,7 @@ export const WorldMap = ({ const dxPathsMarkersRef = useRef([]); const satMarkersRef = useRef([]); const satTracksRef = useRef([]); + const pskMarkersRef = useRef([]); // Load map style from localStorage const getStoredMapSettings = () => { @@ -416,6 +419,55 @@ export const WorldMap = ({ } }, [satellites, showSatellites]); + // Update PSKReporter markers + useEffect(() => { + if (!mapInstanceRef.current) return; + const map = mapInstanceRef.current; + + pskMarkersRef.current.forEach(m => map.removeLayer(m)); + pskMarkersRef.current = []; + + if (showPSKReporter && pskReporterSpots && pskReporterSpots.length > 0 && deLocation) { + pskReporterSpots.forEach(spot => { + if (spot.lat && spot.lon) { + const displayCall = spot.receiver || spot.sender; + const freqMHz = spot.freqMHz || (spot.freq ? (spot.freq / 1000000).toFixed(3) : '?'); + const bandColor = getBandColor(parseFloat(freqMHz)); + + // Draw line from DE to spot location + const points = getGreatCirclePoints( + [deLocation.lat, deLocation.lon], + [spot.lat, spot.lon], + 50 + ); + + const line = L.polyline(points, { + color: bandColor, + weight: 1.5, + opacity: 0.5, + dashArray: '4, 4' + }).addTo(map); + pskMarkersRef.current.push(line); + + // Add small dot marker at spot location + const circle = L.circleMarker([spot.lat, spot.lon], { + radius: 4, + fillColor: bandColor, + color: '#fff', + weight: 1, + opacity: 0.9, + fillOpacity: 0.8 + }).bindPopup(` + ${displayCall}
+ ${spot.mode} @ ${freqMHz} MHz
+ ${spot.snr !== null ? `SNR: ${spot.snr > 0 ? '+' : ''}${spot.snr} dB` : ''} + `).addTo(map); + pskMarkersRef.current.push(circle); + } + }); + } + }, [pskReporterSpots, showPSKReporter, deLocation]); + return (
From 6bde84e3163e7e9801835f667e6b6d69f8e6c680 Mon Sep 17 00:00:00 2001 From: accius Date: Mon, 2 Feb 2026 16:20:43 -0500 Subject: [PATCH 06/10] mapstyle added --- src/components/WorldMap.jsx | 78 ++++++++++++++++++++++--------------- src/utils/config.js | 10 +++++ 2 files changed, 57 insertions(+), 31 deletions(-) diff --git a/src/components/WorldMap.jsx b/src/components/WorldMap.jsx index 7a349e8..de8181e 100644 --- a/src/components/WorldMap.jsx +++ b/src/components/WorldMap.jsx @@ -427,42 +427,58 @@ export const WorldMap = ({ pskMarkersRef.current.forEach(m => map.removeLayer(m)); pskMarkersRef.current = []; - if (showPSKReporter && pskReporterSpots && pskReporterSpots.length > 0 && deLocation) { + // Validate deLocation exists and has valid coordinates + const hasValidDE = deLocation && + typeof deLocation.lat === 'number' && !isNaN(deLocation.lat) && + typeof deLocation.lon === 'number' && !isNaN(deLocation.lon); + + if (showPSKReporter && pskReporterSpots && pskReporterSpots.length > 0 && hasValidDE) { pskReporterSpots.forEach(spot => { - if (spot.lat && spot.lon) { + // Validate spot coordinates are valid numbers + const spotLat = parseFloat(spot.lat); + const spotLon = parseFloat(spot.lon); + + if (!isNaN(spotLat) && !isNaN(spotLon)) { const displayCall = spot.receiver || spot.sender; const freqMHz = spot.freqMHz || (spot.freq ? (spot.freq / 1000000).toFixed(3) : '?'); const bandColor = getBandColor(parseFloat(freqMHz)); - // Draw line from DE to spot location - const points = getGreatCirclePoints( - [deLocation.lat, deLocation.lon], - [spot.lat, spot.lon], - 50 - ); - - const line = L.polyline(points, { - color: bandColor, - weight: 1.5, - opacity: 0.5, - dashArray: '4, 4' - }).addTo(map); - pskMarkersRef.current.push(line); - - // Add small dot marker at spot location - const circle = L.circleMarker([spot.lat, spot.lon], { - radius: 4, - fillColor: bandColor, - color: '#fff', - weight: 1, - opacity: 0.9, - fillOpacity: 0.8 - }).bindPopup(` - ${displayCall}
- ${spot.mode} @ ${freqMHz} MHz
- ${spot.snr !== null ? `SNR: ${spot.snr > 0 ? '+' : ''}${spot.snr} dB` : ''} - `).addTo(map); - pskMarkersRef.current.push(circle); + try { + // Draw line from DE to spot location + const points = getGreatCirclePoints( + deLocation.lat, deLocation.lon, + spotLat, spotLon, + 50 + ); + + // Validate points before creating polyline + if (points && points.length > 1 && points.every(p => Array.isArray(p) && !isNaN(p[0]) && !isNaN(p[1]))) { + const line = L.polyline(points, { + color: bandColor, + weight: 1.5, + opacity: 0.5, + dashArray: '4, 4' + }).addTo(map); + pskMarkersRef.current.push(line); + } + + // Add small dot marker at spot location + const circle = L.circleMarker([spotLat, spotLon], { + radius: 4, + fillColor: bandColor, + color: '#fff', + weight: 1, + opacity: 0.9, + fillOpacity: 0.8 + }).bindPopup(` + ${displayCall}
+ ${spot.mode} @ ${freqMHz} MHz
+ ${spot.snr !== null ? `SNR: ${spot.snr > 0 ? '+' : ''}${spot.snr} dB` : ''} + `).addTo(map); + pskMarkersRef.current.push(circle); + } catch (err) { + console.warn('Error rendering PSKReporter spot:', err); + } } }); } diff --git a/src/utils/config.js b/src/utils/config.js index 42f0024..464617a 100644 --- a/src/utils/config.js +++ b/src/utils/config.js @@ -187,6 +187,16 @@ export const MAP_STYLES = { name: 'Gray', url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}', attribution: '© Esri' + }, + political: { + name: 'Political', + url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}', + attribution: '© Esri' + }, + natgeo: { + name: 'Nat Geo', + url: 'https://server.arcgisonline.com/ArcGIS/rest/services/NatGeo_World_Map/MapServer/tile/{z}/{y}/{x}', + attribution: '© Esri, National Geographic' } }; From a837be68021a65b1498e07f9495d38fc38052355 Mon Sep 17 00:00:00 2001 From: accius Date: Mon, 2 Feb 2026 16:26:52 -0500 Subject: [PATCH 07/10] updated squished contests --- src/App.jsx | 20 ++-- src/components/ContestPanel.jsx | 175 ++++++++++++++++++++++++++------ 2 files changed, 156 insertions(+), 39 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 0fa88c0..0c52d4e 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -626,8 +626,8 @@ const App = () => { {/* RIGHT SIDEBAR */}
- {/* DX Cluster - primary panel */} -
+ {/* DX Cluster */} +
{
{/* PSKReporter - digital mode spots */} -
+
{ />
+ {/* Contests - bigger with live indicators */} +
+ +
+ {/* DXpeditions */} -
+
{/* POTA */} -
+
{ onToggleMap={togglePOTA} />
- - {/* Contests */} -
- -
)} diff --git a/src/components/ContestPanel.jsx b/src/components/ContestPanel.jsx index 3991a7d..1aa7714 100644 --- a/src/components/ContestPanel.jsx +++ b/src/components/ContestPanel.jsx @@ -1,6 +1,6 @@ /** * ContestPanel Component - * Displays upcoming contests with contestcalendar.com credit + * Displays upcoming and active contests with live indicators */ import React from 'react'; @@ -22,13 +22,100 @@ export const ContestPanel = ({ data, loading }) => { return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); }; + const formatTime = (dateStr) => { + if (!dateStr) return ''; + const date = new Date(dateStr); + return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }) + 'z'; + }; + + // Check if contest is live (happening now) + const isContestLive = (contest) => { + if (!contest.start || !contest.end) return false; + const now = new Date(); + const start = new Date(contest.start); + const end = new Date(contest.end); + return now >= start && now <= end; + }; + + // Check if contest starts within 24 hours + const isStartingSoon = (contest) => { + if (!contest.start) return false; + const now = new Date(); + const start = new Date(contest.start); + const hoursUntil = (start - now) / (1000 * 60 * 60); + return hoursUntil > 0 && hoursUntil <= 24; + }; + + // Get time remaining or time until start + const getTimeInfo = (contest) => { + if (!contest.start || !contest.end) return formatDate(contest.start); + + const now = new Date(); + const start = new Date(contest.start); + const end = new Date(contest.end); + + if (now >= start && now <= end) { + // Contest is live - show time remaining + const hoursLeft = Math.floor((end - now) / (1000 * 60 * 60)); + const minsLeft = Math.floor(((end - now) % (1000 * 60 * 60)) / (1000 * 60)); + if (hoursLeft > 0) { + return `${hoursLeft}h ${minsLeft}m left`; + } + return `${minsLeft}m left`; + } else if (now < start) { + // Contest hasn't started + const hoursUntil = Math.floor((start - now) / (1000 * 60 * 60)); + if (hoursUntil < 24) { + return `Starts in ${hoursUntil}h`; + } + return formatDate(contest.start); + } + return formatDate(contest.start); + }; + + // Sort contests: live first, then starting soon, then by date + const sortedContests = data ? [...data].sort((a, b) => { + const aLive = isContestLive(a); + const bLive = isContestLive(b); + const aSoon = isStartingSoon(a); + const bSoon = isStartingSoon(b); + + if (aLive && !bLive) return -1; + if (!aLive && bLive) return 1; + if (aSoon && !bSoon) return -1; + if (!aSoon && bSoon) return 1; + + return new Date(a.start) - new Date(b.start); + }) : []; + + // Count live contests + const liveCount = sortedContests.filter(isContestLive).length; + return (
-
- šŸ† CONTESTS + šŸ† CONTESTS + {liveCount > 0 && ( + + šŸ”“ {liveCount} LIVE + + )}
@@ -36,31 +123,61 @@ export const ContestPanel = ({ data, loading }) => {
- ) : data && data.length > 0 ? ( + ) : sortedContests.length > 0 ? (
- {data.slice(0, 6).map((contest, i) => ( -
-
- {contest.name} -
-
- {contest.mode} - {formatDate(contest.start)} + {sortedContests.slice(0, 8).map((contest, i) => { + const live = isContestLive(contest); + const soon = isStartingSoon(contest); + + return ( +
+
+ {live && ( + ā— + )} + {soon && !live && ( + ◐ + )} + + {contest.name} + +
+
+ {contest.mode} + + {getTimeInfo(contest)} + +
-
- ))} + ); + })}
) : (
@@ -71,8 +188,8 @@ export const ContestPanel = ({ data, loading }) => { {/* Contest Calendar Credit */}
From 7ad6fbc86fbb678f0fb21af1bfbaafbc2d4425a0 Mon Sep 17 00:00:00 2001 From: accius Date: Mon, 2 Feb 2026 16:31:40 -0500 Subject: [PATCH 08/10] ui layout --- src/App.jsx | 16 ++++++++-------- src/components/ContestPanel.jsx | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 0c52d4e..885e60c 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -626,8 +626,8 @@ const App = () => { {/* RIGHT SIDEBAR */}
- {/* DX Cluster */} -
+ {/* DX Cluster - primary panel, takes most space */} +
{
{/* PSKReporter - digital mode spots */} -
+
{ />
- {/* Contests - bigger with live indicators */} -
- -
- {/* DXpeditions */}
@@ -675,6 +670,11 @@ const App = () => { onToggleMap={togglePOTA} />
+ + {/* Contests - at bottom, compact */} +
+ +
)} diff --git a/src/components/ContestPanel.jsx b/src/components/ContestPanel.jsx index 1aa7714..100e207 100644 --- a/src/components/ContestPanel.jsx +++ b/src/components/ContestPanel.jsx @@ -125,7 +125,7 @@ export const ContestPanel = ({ data, loading }) => {
) : sortedContests.length > 0 ? (
- {sortedContests.slice(0, 8).map((contest, i) => { + {sortedContests.slice(0, 4).map((contest, i) => { const live = isContestLive(contest); const soon = isStartingSoon(contest); From 96099dfb6932ff4d05f98f08245273f4c5f81e28 Mon Sep 17 00:00:00 2001 From: accius Date: Mon, 2 Feb 2026 16:53:16 -0500 Subject: [PATCH 09/10] 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 10/10] 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';