implement pskreporter

pull/37/head
accius 2 days ago
parent b12231e334
commit ae17fcf14f

@ -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

@ -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": {

@ -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 = /<receptionReport[^>]*>([\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
// ============================================

@ -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 = () => {
<DXpeditionPanel data={dxpeditions.data} loading={dxpeditions.loading} />
</div>
{/* PSKReporter */}
<div style={{ flex: '0 0 auto', maxHeight: '220px', overflow: 'hidden' }}>
<PSKReporterPanel
callsign={config.callsign}
onShowOnMap={(report) => {
if (report.lat && report.lon) {
setDxLocation({ lat: report.lat, lon: report.lon, call: report.receiver || report.sender });
}
}}
/>
</div>
{/* POTA - smaller */}
<div style={{ flex: '0 0 auto', maxHeight: '120px', overflow: 'hidden' }}>
<POTAPanel

@ -0,0 +1,298 @@
/**
* PSKReporter Panel
* Shows where your digital mode signals are being received
*/
import React, { useState } from 'react';
import { usePSKReporter } from '../hooks/usePSKReporter.js';
const PSKReporterPanel = ({ callsign, onShowOnMap }) => {
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 (
<div className="panel">
<div className="panel-header">
<span className="panel-icon">📡</span>
<h3>PSKReporter</h3>
</div>
<div className="panel-content">
<p style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '20px' }}>
Set your callsign in Settings to see PSKReporter data
</p>
</div>
</div>
);
}
return (
<div className="panel">
<div className="panel-header">
<span className="panel-icon">📡</span>
<h3>PSKReporter</h3>
<div style={{ marginLeft: 'auto', display: 'flex', gap: '8px', alignItems: 'center' }}>
<select
value={timeWindow}
onChange={(e) => setTimeWindow(parseInt(e.target.value))}
style={{
background: 'var(--bg-tertiary)',
border: '1px solid var(--border-color)',
borderRadius: '4px',
padding: '2px 6px',
fontSize: '0.75rem',
color: 'var(--text-primary)'
}}
>
<option value={5}>5 min</option>
<option value={15}>15 min</option>
<option value={30}>30 min</option>
<option value={60}>1 hour</option>
</select>
<button
onClick={refresh}
style={{
background: 'transparent',
border: 'none',
cursor: 'pointer',
fontSize: '0.9rem',
opacity: loading ? 0.5 : 1
}}
disabled={loading}
title="Refresh"
>
🔄
</button>
</div>
</div>
{/* Tabs */}
<div style={{
display: 'flex',
borderBottom: '1px solid var(--border-color)',
background: 'var(--bg-tertiary)'
}}>
<button
onClick={() => setActiveTab('tx')}
style={{
flex: 1,
padding: '8px',
background: activeTab === 'tx' ? 'var(--bg-secondary)' : 'transparent',
border: 'none',
borderBottom: activeTab === 'tx' ? '2px solid var(--accent-primary)' : '2px solid transparent',
color: activeTab === 'tx' ? 'var(--text-primary)' : 'var(--text-muted)',
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: activeTab === 'tx' ? '600' : '400'
}}
>
📤 Being Heard ({txCount})
</button>
<button
onClick={() => setActiveTab('rx')}
style={{
flex: 1,
padding: '8px',
background: activeTab === 'rx' ? 'var(--bg-secondary)' : 'transparent',
border: 'none',
borderBottom: activeTab === 'rx' ? '2px solid var(--accent-primary)' : '2px solid transparent',
color: activeTab === 'rx' ? 'var(--text-primary)' : 'var(--text-muted)',
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: activeTab === 'rx' ? '600' : '400'
}}
>
📥 Hearing ({rxCount})
</button>
</div>
<div className="panel-content" style={{ maxHeight: '300px', overflowY: 'auto' }}>
{loading && reports.length === 0 ? (
<div style={{ textAlign: 'center', padding: '20px', color: 'var(--text-muted)' }}>
Loading...
</div>
) : reports.length === 0 ? (
<div style={{ textAlign: 'center', padding: '20px', color: 'var(--text-muted)' }}>
No {activeTab === 'tx' ? 'reception reports' : 'stations heard'} in the last {timeWindow} minutes
</div>
) : (
<>
{/* Summary stats for TX */}
{activeTab === 'tx' && txCount > 0 && (
<div style={{
padding: '8px 12px',
background: 'var(--bg-tertiary)',
borderRadius: '4px',
marginBottom: '8px',
fontSize: '0.75rem'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: '8px' }}>
<span>
<strong style={{ color: 'var(--accent-primary)' }}>{txCount}</strong> stations hearing you
</span>
{stats.txBands.length > 0 && (
<span>
Bands: {stats.txBands.join(', ')}
</span>
)}
{stats.txModes.length > 0 && (
<span>
Modes: {stats.txModes.slice(0, 3).join(', ')}
</span>
)}
</div>
</div>
)}
{/* Reports list */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{reports.slice(0, 25).map((report, idx) => (
<div
key={idx}
onClick={() => 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'
}}
>
<div>
<span style={{
fontWeight: '600',
color: 'var(--accent-primary)',
fontFamily: 'var(--font-mono)'
}}>
{activeTab === 'tx' ? report.receiver : report.sender}
</span>
{(activeTab === 'tx' ? report.receiverGrid : report.senderGrid) && (
<span style={{
marginLeft: '6px',
color: 'var(--text-muted)',
fontSize: '0.7rem'
}}>
{activeTab === 'tx' ? report.receiverGrid : report.senderGrid}
</span>
)}
</div>
<div style={{
color: 'var(--text-secondary)',
fontFamily: 'var(--font-mono)'
}}>
{report.freqMHz} {report.band}
</div>
<div style={{
color: 'var(--text-muted)',
minWidth: '40px',
textAlign: 'center'
}}>
{report.mode}
</div>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
minWidth: '70px',
justifyContent: 'flex-end'
}}>
{report.snr !== null && (
<span style={{
color: getSnrColor(report.snr),
fontFamily: 'var(--font-mono)',
fontWeight: '600'
}}>
{report.snr > 0 ? '+' : ''}{report.snr}dB
</span>
)}
<span style={{
color: 'var(--text-muted)',
fontSize: '0.65rem'
}}>
{formatAge(report.age)}
</span>
</div>
</div>
))}
</div>
{reports.length > 25 && (
<div style={{
textAlign: 'center',
padding: '8px',
color: 'var(--text-muted)',
fontSize: '0.7rem'
}}>
Showing 25 of {reports.length} reports
</div>
)}
</>
)}
</div>
{/* Footer with last update */}
{lastUpdate && (
<div style={{
padding: '4px 12px',
borderTop: '1px solid var(--border-color)',
fontSize: '0.65rem',
color: 'var(--text-muted)',
textAlign: 'right'
}}>
Updated: {lastUpdate.toLocaleTimeString()}
</div>
)}
</div>
);
};
export default PSKReporterPanel;
export { PSKReporterPanel };

@ -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';

@ -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';

@ -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;
Loading…
Cancel
Save

Powered by TurnKey Linux.