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;