Merge pull request #38 from accius/Modular-Staging

Modular staging
pull/65/head
accius 2 days ago committed by GitHub
commit 293190e117
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

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

@ -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: []
});
}
});

@ -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)}
/>
<PSKFilterManager
filters={pskFilters}
onFilterChange={setPskFilters}
isOpen={showPSKFilters}
onClose={() => setShowPSKFilters(false)}
/>
</div>
);
};

@ -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 = () => (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '15px' }}>
<span style={{ fontSize: '13px', fontWeight: '600', color: 'var(--text-primary)' }}>
Filter by Band
</span>
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={() => selectAll('bands', BANDS)}
style={{ background: 'none', border: 'none', color: 'var(--accent-cyan)', fontSize: '12px', cursor: 'pointer' }}
>
Select All
</button>
<button
onClick={() => clearFilter('bands')}
style={{ background: 'none', border: 'none', color: 'var(--accent-red)', fontSize: '12px', cursor: 'pointer' }}
>
Clear
</button>
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{BANDS.map(band => (
<button
key={band}
onClick={() => toggleArrayItem('bands', band)}
style={chipStyle(filters?.bands?.includes(band))}
>
{band}
</button>
))}
</div>
<div style={{ marginTop: '15px', fontSize: '11px', color: 'var(--text-muted)' }}>
{filters?.bands?.length
? `Showing only: ${filters.bands.join(', ')}`
: 'Showing all bands (no filter)'}
</div>
</div>
);
const renderGridsTab = () => (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '15px' }}>
<span style={{ fontSize: '13px', fontWeight: '600', color: 'var(--text-primary)' }}>
Filter by Grid Square
</span>
<button
onClick={() => clearFilter('grids')}
style={{ background: 'none', border: 'none', color: 'var(--accent-red)', fontSize: '12px', cursor: 'pointer' }}
>
Clear All
</button>
</div>
{/* Custom grid input */}
<div style={{ display: 'flex', gap: '8px', marginBottom: '20px' }}>
<input
type="text"
placeholder="Add grid (e.g. FN)"
value={customGrid}
onChange={(e) => 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'
}}
/>
<button
onClick={addCustomGrid}
style={{
padding: '8px 16px',
background: 'var(--accent-cyan)',
border: 'none',
borderRadius: '4px',
color: '#000',
fontSize: '12px',
cursor: 'pointer',
fontWeight: '600'
}}
>
Add
</button>
</div>
{/* Selected grids */}
{filters?.grids?.length > 0 && (
<div style={{ marginBottom: '20px' }}>
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginBottom: '8px' }}>
Active Grid Filters:
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{filters.grids.map(grid => (
<button
key={grid}
onClick={() => toggleArrayItem('grids', grid)}
style={{
...chipStyle(true),
display: 'flex',
alignItems: 'center',
gap: '6px'
}}
>
{grid}
<span style={{ color: 'var(--accent-red)', fontWeight: '700' }}>×</span>
</button>
))}
</div>
</div>
)}
{/* Quick select by region */}
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginBottom: '10px' }}>
Quick Select by Region:
</div>
{GRID_REGIONS.map(region => (
<div key={region.name} style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '6px' }}>
{region.name}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
{region.grids.map(grid => (
<button
key={grid}
onClick={() => toggleArrayItem('grids', grid)}
style={{
...chipStyle(filters?.grids?.includes(grid)),
padding: '4px 8px',
fontSize: '11px'
}}
>
{grid}
</button>
))}
</div>
</div>
))}
</div>
);
const renderModesTab = () => (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '15px' }}>
<span style={{ fontSize: '13px', fontWeight: '600', color: 'var(--text-primary)' }}>
Filter by Mode
</span>
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={() => selectAll('modes', MODES)}
style={{ background: 'none', border: 'none', color: 'var(--accent-cyan)', fontSize: '12px', cursor: 'pointer' }}
>
Select All
</button>
<button
onClick={() => clearFilter('modes')}
style={{ background: 'none', border: 'none', color: 'var(--accent-red)', fontSize: '12px', cursor: 'pointer' }}
>
Clear
</button>
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{MODES.map(mode => (
<button
key={mode}
onClick={() => toggleArrayItem('modes', mode)}
style={chipStyle(filters?.modes?.includes(mode))}
>
{mode}
</button>
))}
</div>
<div style={{ marginTop: '15px', fontSize: '11px', color: 'var(--text-muted)' }}>
{filters?.modes?.length
? `Showing only: ${filters.modes.join(', ')}`
: 'Showing all modes (no filter)'}
</div>
</div>
);
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 2000
}}
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div style={{
background: 'var(--bg-primary)',
border: '1px solid var(--border-color)',
borderRadius: '8px',
width: '500px',
maxWidth: '95vw',
maxHeight: '85vh',
display: 'flex',
flexDirection: 'column',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.5)'
}}>
{/* Header */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '16px 20px',
borderBottom: '1px solid var(--border-color)'
}}>
<div>
<h3 style={{ margin: 0, fontSize: '16px', color: 'var(--text-primary)' }}>
📡 PSKReporter Filters
</h3>
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
{getActiveFilterCount()} filter{getActiveFilterCount() !== 1 ? 's' : ''} active
</span>
</div>
<button
onClick={onClose}
style={{
background: 'none',
border: 'none',
color: 'var(--text-muted)',
fontSize: '24px',
cursor: 'pointer',
lineHeight: 1
}}
>
×
</button>
</div>
{/* Tabs */}
<div style={{
display: 'flex',
borderBottom: '1px solid var(--border-color)',
background: 'var(--bg-secondary)'
}}>
<button onClick={() => setActiveTab('bands')} style={tabStyle(activeTab === 'bands')}>
Bands {filters?.bands?.length ? `(${filters.bands.length})` : ''}
</button>
<button onClick={() => setActiveTab('grids')} style={tabStyle(activeTab === 'grids')}>
Grids {filters?.grids?.length ? `(${filters.grids.length})` : ''}
</button>
<button onClick={() => setActiveTab('modes')} style={tabStyle(activeTab === 'modes')}>
Modes {filters?.modes?.length ? `(${filters.modes.length})` : ''}
</button>
</div>
{/* Tab Content */}
<div style={{
flex: 1,
overflow: 'auto',
padding: '20px'
}}>
{activeTab === 'bands' && renderBandsTab()}
{activeTab === 'grids' && renderGridsTab()}
{activeTab === 'modes' && renderModesTab()}
</div>
{/* Footer */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
padding: '16px 20px',
borderTop: '1px solid var(--border-color)',
background: 'var(--bg-secondary)'
}}>
<button
onClick={clearAllFilters}
style={{
padding: '8px 16px',
background: 'transparent',
border: '1px solid var(--accent-red)',
borderRadius: '4px',
color: 'var(--accent-red)',
fontSize: '13px',
cursor: 'pointer'
}}
>
Clear All Filters
</button>
<button
onClick={onClose}
style={{
padding: '8px 24px',
background: 'var(--accent-cyan)',
border: 'none',
borderRadius: '4px',
color: '#000',
fontSize: '13px',
cursor: 'pointer',
fontWeight: '600'
}}
>
Done
</button>
</div>
</div>
</div>
);
};
export default PSKFilterManager;

@ -1,15 +1,21 @@
/**
* 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 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, setTimeWindow] = useState(15);
const [activeTab, setActiveTab] = useState('rx'); // Default to 'rx' (Hearing) - more useful
const PSKReporterPanel = ({
callsign,
onShowOnMap,
showOnMap,
onToggleMap,
filters = {},
onOpenFilters
}) => {
const [activeTab, setActiveTab] = useState('tx'); // Default to 'tx' (Being Heard)
const {
txReports,
@ -18,14 +24,48 @@ const PSKReporterPanel = ({ callsign, onShowOnMap, showOnMap, onToggleMap }) =>
rxCount,
loading,
error,
connected,
source,
refresh
} = usePSKReporter(callsign, {
minutes: timeWindow,
minutes: 15,
enabled: callsign && callsign !== 'N0CALL'
});
const reports = activeTab === 'tx' ? txReports : rxReports;
const count = activeTab === 'tx' ? txCount : rxCount;
// 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) => {
@ -41,6 +81,20 @@ const PSKReporterPanel = ({ callsign, onShowOnMap, showOnMap, onToggleMap }) =>
return `${Math.floor(minutes/60)}h`;
};
// Get status indicator
const getStatusIndicator = () => {
if (connected) {
return <span style={{ color: '#4ade80', fontSize: '10px' }}> LIVE</span>;
}
if (source === 'connecting' || source === 'reconnecting') {
return <span style={{ color: '#fbbf24', fontSize: '10px' }}> {source}</span>;
}
if (error) {
return <span style={{ color: '#ef4444', fontSize: '10px' }}> offline</span>;
}
return null;
};
if (!callsign || callsign === 'N0CALL') {
return (
<div className="panel" style={{ padding: '10px' }}>
@ -62,7 +116,7 @@ const PSKReporterPanel = ({ callsign, onShowOnMap, showOnMap, onToggleMap }) =>
height: '100%',
overflow: 'hidden'
}}>
{/* Header - matches DX Cluster style */}
{/* Header */}
<div style={{
fontSize: '12px',
color: 'var(--accent-primary)',
@ -72,30 +126,30 @@ const PSKReporterPanel = ({ callsign, onShowOnMap, showOnMap, onToggleMap }) =>
justifyContent: 'space-between',
alignItems: 'center'
}}>
<span>📡 PSKReporter</span>
<span>📡 PSKReporter {getStatusIndicator()}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<select
value={timeWindow}
onChange={(e) => setTimeWindow(parseInt(e.target.value))}
<span style={{ fontSize: '9px', color: 'var(--text-muted)' }}>
{filteredReports.length}/{activeTab === 'tx' ? txCount : rxCount}
</span>
<button
onClick={onOpenFilters}
style={{
background: 'rgba(100, 100, 100, 0.3)',
border: '1px solid #666',
color: '#aaa',
padding: '2px 4px',
background: filterCount > 0 ? 'rgba(255, 170, 0, 0.3)' : 'rgba(100, 100, 100, 0.3)',
border: `1px solid ${filterCount > 0 ? '#ffaa00' : '#666'}`,
color: filterCount > 0 ? '#ffaa00' : '#888',
padding: '2px 8px',
borderRadius: '4px',
fontSize: '10px',
fontFamily: 'JetBrains Mono',
cursor: 'pointer'
}}
>
<option value={5}>5m</option>
<option value={15}>15m</option>
<option value={30}>30m</option>
<option value={60}>1h</option>
</select>
🔍 Filters
</button>
<button
onClick={refresh}
disabled={loading}
title={connected ? 'Reconnect' : 'Connect'}
style={{
background: 'rgba(100, 100, 100, 0.3)',
border: '1px solid #666',
@ -129,7 +183,7 @@ const PSKReporterPanel = ({ callsign, onShowOnMap, showOnMap, onToggleMap }) =>
</div>
</div>
{/* Tabs - compact style */}
{/* Tabs */}
<div style={{
display: 'flex',
gap: '4px',
@ -149,7 +203,7 @@ const PSKReporterPanel = ({ callsign, onShowOnMap, showOnMap, onToggleMap }) =>
fontFamily: 'JetBrains Mono'
}}
>
📤 Being Heard ({txCount})
📤 Being Heard ({filterCount > 0 ? `${filteredTx.length}` : txCount})
</button>
<button
onClick={() => setActiveTab('rx')}
@ -165,22 +219,31 @@ const PSKReporterPanel = ({ callsign, onShowOnMap, showOnMap, onToggleMap }) =>
fontFamily: 'JetBrains Mono'
}}
>
📥 Hearing ({rxCount})
📥 Hearing ({filterCount > 0 ? `${filteredRx.length}` : rxCount})
</button>
</div>
{/* Reports list - matches DX Cluster style */}
{error ? (
{/* Reports list */}
{error && !connected ? (
<div style={{ textAlign: 'center', padding: '10px', color: 'var(--text-muted)', fontSize: '11px' }}>
Temporarily unavailable
Connection failed - click 🔄 to retry
</div>
) : loading && filteredReports.length === 0 && filterCount === 0 ? (
<div style={{ textAlign: 'center', padding: '15px', color: 'var(--text-muted)', fontSize: '11px' }}>
<div className="loading-spinner" style={{ margin: '0 auto 8px' }} />
Connecting to MQTT...
</div>
) : loading && reports.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '20px' }}>
<div className="loading-spinner" />
) : !connected && filteredReports.length === 0 && filterCount === 0 ? (
<div style={{ textAlign: 'center', padding: '10px', color: 'var(--text-muted)', fontSize: '11px' }}>
Waiting for connection...
</div>
) : reports.length === 0 ? (
) : filteredReports.length === 0 ? (
<div style={{ textAlign: 'center', padding: '10px', color: 'var(--text-muted)', fontSize: '11px' }}>
No {activeTab === 'tx' ? 'reception reports' : 'stations heard'}
{filterCount > 0
? 'No spots match filters'
: activeTab === 'tx'
? 'Waiting for spots... (TX to see reports)'
: 'No stations heard yet'}
</div>
) : (
<div style={{
@ -189,7 +252,7 @@ const PSKReporterPanel = ({ callsign, onShowOnMap, showOnMap, onToggleMap }) =>
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;

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

@ -1,12 +1,12 @@
/**
* usePSKReporter Hook
* Fetches PSKReporter data showing where your digital mode signals are being received
* Fetches PSKReporter data via MQTT WebSocket connection
*
* Uses HTTP API with server-side caching to respect PSKReporter rate limits.
*
* For real-time MQTT updates, see mqtt.pskreporter.info (requires mqtt.js library)
* Uses real-time MQTT feed from mqtt.pskreporter.info for live spots
* No HTTP API calls - direct WebSocket connection from browser
*/
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import mqtt from 'mqtt';
// Convert grid square to lat/lon
function gridToLatLon(grid) {
@ -31,116 +31,287 @@ function gridToLatLon(grid) {
return { lat: finalLat, lon: finalLon };
}
// Get band name from frequency in Hz
function getBandFromHz(freqHz) {
const freqMHz = freqHz / 1000000;
if (freqMHz >= 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);
// 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 {
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
};
const upperCallsign = callsign?.toUpperCase();
if (!upperCallsign) return;
// 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;
// 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);
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);
setRxReports(cleanOldSpots([...rxReportsRef.current], minutes));
setLastUpdate(new Date());
}
} catch (err) {
// Silently ignore parse errors - malformed messages happen
}
}, [callsign, minutes, maxSpots, cleanOldSpots]);
// Connect to MQTT
useEffect(() => {
mountedRef.current = true;
const fetchData = useCallback(async () => {
if (!callsign || callsign === 'N0CALL' || !enabled) {
setTxReports([]);
setRxReports([]);
setLoading(false);
setSource('disabled');
setConnected(false);
return;
}
try {
setError(null);
const upperCallsign = callsign.toUpperCase();
// 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);
// Clear old data
txReportsRef.current = [];
rxReportsRef.current = [];
setTxReports([]);
setRxReports([]);
setLoading(true);
setError(null);
setSource('connecting');
// 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);
console.log(`[PSKReporter MQTT] Connecting for ${upperCallsign}...`);
setTxReports(processedTx);
setRxReports(processedRx);
setLastUpdate(new Date());
// 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;
// Check for errors in response
if (data.error || data.tx?.error || data.rx?.error) {
setError(data.error || data.tx?.error || data.rx?.error);
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}`);
}
} else {
throw new Error(`HTTP ${response.status}`);
}
} catch (err) {
console.error('PSKReporter fetch error:', err);
setError(err.message);
} finally {
});
// 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);
}
}, [callsign, minutes, enabled, maxSpots]);
});
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(() => {
fetchData();
if (!enabled) return;
if (enabled && refreshInterval > 0) {
const interval = setInterval(fetchData, refreshInterval);
return () => clearInterval(interval);
}
}, [fetchData, enabled, refreshInterval]);
const interval = setInterval(() => {
// Update ages and clean old spots
const now = Date.now();
// 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);
setTxReports(prev => prev.map(r => ({
...r,
age: Math.floor((now - r.timestamp) / 60000)
})).filter(r => r.age <= minutes));
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
};
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
};
};

@ -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']
}
}
}

Loading…
Cancel
Save

Powered by TurnKey Linux.