psk updates and version

pull/58/head
accius 2 days ago
parent 5dfe088118
commit 4403ddd657

@ -2,6 +2,22 @@
All notable changes to OpenHamClock will be documented in this file. All notable changes to OpenHamClock will be documented in this file.
## [3.12.0] - 2025-02-03
### Fixed
- **PSKReporter MQTT** - Fixed critical bug: field mapping used `sa`/`ra` (ADIF country codes) instead of `sc`/`rc` (callsigns), so no MQTT spots ever matched
- **PSKReporter RX topic** - Fixed subscription pattern that had one extra wildcard, subscribing to sender locator position instead of receiver callsign
- **PSKReporter HTTP fallback** - If MQTT fails to connect within 12 seconds, automatically falls back to HTTP API so users always get data
- **Map layer persistence** - Map style/zoom save was overwriting plugin layer settings (aurora, earthquakes, weather radar). Now merges correctly
- **Version consistency** - All version numbers now read from package.json as single source of truth. Previously health endpoint said 3.3.0, config said 3.10.0, UI said 3.7.0
- **PSKReporter 403 spam** - Server now backs off for 30 minutes on 403/429 responses instead of retrying every request
### Added
- **State persistence** - All user preferences now survive page refresh: PSK/WSJT-X panel mode, TX/RX tab, solar image wavelength, weather panel expanded state, temperature unit
- **Collapsible weather** - DE location weather section collapses to one-line summary, expands for full details
- **Lunar phase display** - 4th cycling mode in Solar panel shows current moon phase with SVG rendering, illumination %, and next full/new moon dates
- **F°/C° toggle** - Switch temperature units with localStorage persistence; header always shows both
## [3.11.0] - 2025-02-02 ## [3.11.0] - 2025-02-02
### Added ### Added

@ -1,5 +1,5 @@
/** /**
* OpenHamClock Server v3.10.0 * OpenHamClock Server
* *
* Express server that: * Express server that:
* 1. Serves the static web application * 1. Serves the static web application
@ -25,6 +25,14 @@ const net = require('net');
const dgram = require('dgram'); const dgram = require('dgram');
const fs = require('fs'); const fs = require('fs');
// Read version from package.json as single source of truth
const APP_VERSION = (() => {
try {
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
return pkg.version || '0.0.0';
} catch { return '0.0.0'; }
})();
// Auto-create .env from .env.example on first run // Auto-create .env from .env.example on first run
const envPath = path.join(__dirname, '.env'); const envPath = path.join(__dirname, '.env');
const envExamplePath = path.join(__dirname, '.env.example'); const envExamplePath = path.join(__dirname, '.env.example');
@ -2097,10 +2105,13 @@ app.get('/api/pskreporter/http/:callsign', async (req, res) => {
clearTimeout(timeout); clearTimeout(timeout);
if (!response.ok) { if (!response.ok) {
// On 503, set backoff period (15 minutes) to avoid hammering // Back off on rate-limit or access errors to avoid hammering
if (response.status === 503) { if (response.status === 503) {
psk503Backoff = Date.now() + (15 * 60 * 1000); psk503Backoff = Date.now() + (15 * 60 * 1000);
logErrorOnce('PSKReporter HTTP', '503 - backing off for 15 minutes'); logErrorOnce('PSKReporter HTTP', '503 - backing off for 15 minutes');
} else if (response.status === 403 || response.status === 429) {
psk503Backoff = Date.now() + (30 * 60 * 1000);
logErrorOnce('PSKReporter HTTP', `${response.status} - backing off for 30 minutes (server-side HTTP proxy blocked; users unaffected via MQTT)`);
} }
throw new Error(`HTTP ${response.status}`); throw new Error(`HTTP ${response.status}`);
} }
@ -2171,14 +2182,17 @@ app.get('/api/pskreporter/http/:callsign', async (req, res) => {
res.json(result); res.json(result);
} catch (error) { } catch (error) {
// Don't re-log 403/503 errors - already logged above with backoff info
if (!error.message?.includes('HTTP 403') && !error.message?.includes('HTTP 503')) {
logErrorOnce('PSKReporter HTTP', error.message); logErrorOnce('PSKReporter HTTP', error.message);
}
// Return cached data if available (without error flag) // Return cached data if available (without error flag)
if (pskHttpCache[cacheKey]) { if (pskHttpCache[cacheKey]) {
return res.json({ ...pskHttpCache[cacheKey].data, cached: true, stale: true }); return res.json({ ...pskHttpCache[cacheKey].data, cached: true, stale: true });
} }
// Return empty result without error flag for 503s (rate limiting is expected) // Return empty result without error flag for rate limiting
res.json({ res.json({
callsign, callsign,
direction, direction,
@ -3553,7 +3567,7 @@ function getLastWeekendOfMonth(year, month) {
app.get('/api/health', (req, res) => { app.get('/api/health', (req, res) => {
res.json({ res.json({
status: 'ok', status: 'ok',
version: '3.3.0', version: APP_VERSION,
uptime: process.uptime(), uptime: process.uptime(),
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
}); });
@ -3568,7 +3582,7 @@ app.get('/api/health', (req, res) => {
app.get('/api/config', (req, res) => { app.get('/api/config', (req, res) => {
// Don't expose API keys/passwords - only public config // Don't expose API keys/passwords - only public config
res.json({ res.json({
version: '3.10.0', version: APP_VERSION,
// Station info (from .env or config.json) // Station info (from .env or config.json)
callsign: CONFIG.callsign, callsign: CONFIG.callsign,
@ -4490,6 +4504,7 @@ app.listen(PORT, '0.0.0.0', () => {
console.log('╚═══════════════════════════════════════════════════════╝'); console.log('╚═══════════════════════════════════════════════════════╝');
console.log(''); console.log('');
const displayHost = HOST === '0.0.0.0' ? 'localhost' : HOST; const displayHost = HOST === '0.0.0.0' ? 'localhost' : HOST;
console.log(` 🌐 OpenHamClock v${APP_VERSION}`);
console.log(` 🌐 Server running at http://${displayHost}:${PORT}`); console.log(` 🌐 Server running at http://${displayHost}:${PORT}`);
if (HOST === '0.0.0.0') { if (HOST === '0.0.0.0') {
console.log(` 🔗 Network access: http://<your-ip>:${PORT}`); console.log(` 🔗 Network access: http://<your-ip>:${PORT}`);

@ -1,6 +1,6 @@
/** /**
* OpenHamClock - Main Application Component * OpenHamClock - Main Application Component
* Amateur Radio Dashboard v3.7.0 * Amateur Radio Dashboard
*/ */
import React, { useState, useEffect, useCallback, useMemo } from 'react'; import React, { useState, useEffect, useCallback, useMemo } from 'react';

@ -1,9 +1,18 @@
/** /**
* usePSKReporter Hook * usePSKReporter Hook
* Fetches PSKReporter data via MQTT WebSocket connection * Fetches PSKReporter data via MQTT WebSocket + HTTP fallback
* *
* Uses real-time MQTT feed from mqtt.pskreporter.info for live spots * Primary: Real-time MQTT feed from mqtt.pskreporter.info
* No HTTP API calls - direct WebSocket connection from browser * Fallback: HTTP API via server proxy if MQTT fails to connect
*
* MQTT message format (from mqtt.pskreporter.info):
* sc = sender call, rc = receiver call
* sl = sender locator, rl = receiver locator
* sa = sender ADIF country code, ra = receiver ADIF country code
* f = frequency, md = mode, rp = report (SNR), t = timestamp
* b = band, sq = sequence number
*
* Topic format: pskr/filter/v2/{band}/{mode}/{sendercall}/{receivercall}/{senderlocator}/{receiverlocator}/{sendercountry}/{receivercountry}
*/ */
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import mqtt from 'mqtt'; import mqtt from 'mqtt';
@ -50,6 +59,11 @@ function getBandFromHz(freqHz) {
return 'Unknown'; return 'Unknown';
} }
// MQTT connection timeout before falling back to HTTP (seconds)
const MQTT_TIMEOUT_MS = 12000;
// HTTP poll interval when using fallback (ms)
const HTTP_POLL_INTERVAL = 120000; // 2 minutes
export const usePSKReporter = (callsign, options = {}) => { export const usePSKReporter = (callsign, options = {}) => {
const { const {
minutes = 15, // Time window to keep spots minutes = 15, // Time window to keep spots
@ -69,6 +83,8 @@ export const usePSKReporter = (callsign, options = {}) => {
const txReportsRef = useRef([]); const txReportsRef = useRef([]);
const rxReportsRef = useRef([]); const rxReportsRef = useRef([]);
const mountedRef = useRef(true); const mountedRef = useRef(true);
const httpFallbackRef = useRef(null);
const mqttTimerRef = useRef(null);
// Clean old spots (older than specified minutes) // Clean old spots (older than specified minutes)
const cleanOldSpots = useCallback((spots, maxAgeMinutes) => { const cleanOldSpots = useCallback((spots, maxAgeMinutes) => {
@ -76,6 +92,45 @@ export const usePSKReporter = (callsign, options = {}) => {
return spots.filter(s => s.timestamp > cutoff).slice(0, maxSpots); return spots.filter(s => s.timestamp > cutoff).slice(0, maxSpots);
}, [maxSpots]); }, [maxSpots]);
// HTTP fallback fetch
const fetchHTTP = useCallback(async (cs) => {
if (!mountedRef.current || !cs) return;
try {
const res = await fetch(`/api/pskreporter/${encodeURIComponent(cs)}?minutes=${minutes}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (!mountedRef.current) return;
// Merge TX reports
if (data.tx?.reports?.length) {
txReportsRef.current = data.tx.reports.slice(0, maxSpots);
setTxReports([...txReportsRef.current]);
}
// Merge RX reports
if (data.rx?.reports?.length) {
rxReportsRef.current = data.rx.reports.slice(0, maxSpots);
setRxReports([...rxReportsRef.current]);
}
setLastUpdate(new Date());
setLoading(false);
setError(null);
setSource('http');
setConnected(true);
console.log(`[PSKReporter HTTP] Loaded ${data.tx?.count || 0} TX, ${data.rx?.count || 0} RX for ${cs}`);
} catch (err) {
if (!mountedRef.current) return;
console.error('[PSKReporter HTTP] Fallback error:', err.message);
setError('HTTP fallback failed');
setLoading(false);
setSource('error');
}
}, [minutes, maxSpots]);
// Process incoming MQTT message // Process incoming MQTT message
const processMessage = useCallback((topic, message) => { const processMessage = useCallback((topic, message) => {
if (!mountedRef.current) return; if (!mountedRef.current) return;
@ -83,18 +138,20 @@ export const usePSKReporter = (callsign, options = {}) => {
try { try {
const data = JSON.parse(message.toString()); const data = JSON.parse(message.toString());
// PSKReporter MQTT message format // PSKReporter MQTT message fields:
// sa=sender callsign, sl=sender locator, ra=receiver callsign, rl=receiver locator // sc = sender call, rc = receiver call (NOT sa/ra which are ADIF country codes)
// f=frequency, md=mode, rp=snr (report), t=timestamp // sl = sender locator, rl = receiver locator
// f = frequency, md = mode, rp = report (SNR), t = timestamp, b = band
const { const {
sa: senderCallsign, sc: senderCallsign,
sl: senderLocator, sl: senderLocator,
ra: receiverCallsign, rc: receiverCallsign,
rl: receiverLocator, rl: receiverLocator,
f: frequency, f: frequency,
md: mode, md: mode,
rp: snr, rp: snr,
t: timestamp t: timestamp,
b: band
} = data; } = data;
if (!senderCallsign || !receiverCallsign) return; if (!senderCallsign || !receiverCallsign) return;
@ -111,7 +168,7 @@ export const usePSKReporter = (callsign, options = {}) => {
receiverGrid: receiverLocator, receiverGrid: receiverLocator,
freq, freq,
freqMHz: freq ? (freq / 1000000).toFixed(3) : '?', freqMHz: freq ? (freq / 1000000).toFixed(3) : '?',
band: getBandFromHz(freq), band: band || getBandFromHz(freq),
mode: mode || 'Unknown', mode: mode || 'Unknown',
snr: snr !== undefined ? parseInt(snr) : null, snr: snr !== undefined ? parseInt(snr) : null,
timestamp: timestamp ? timestamp * 1000 : now, timestamp: timestamp ? timestamp * 1000 : now,
@ -159,7 +216,7 @@ export const usePSKReporter = (callsign, options = {}) => {
} }
}, [callsign, minutes, maxSpots, cleanOldSpots]); }, [callsign, minutes, maxSpots, cleanOldSpots]);
// Connect to MQTT // Connect to MQTT with HTTP fallback
useEffect(() => { useEffect(() => {
mountedRef.current = true; mountedRef.current = true;
@ -183,9 +240,11 @@ export const usePSKReporter = (callsign, options = {}) => {
setError(null); setError(null);
setSource('connecting'); setSource('connecting');
let mqttFailed = false;
console.log(`[PSKReporter MQTT] Connecting for ${upperCallsign}...`); console.log(`[PSKReporter MQTT] Connecting for ${upperCallsign}...`);
// Connect to PSKReporter MQTT via WebSocket // Connect to PSKReporter MQTT via WebSocket (TLS on port 1886)
const client = mqtt.connect('wss://mqtt.pskreporter.info:1886/mqtt', { const client = mqtt.connect('wss://mqtt.pskreporter.info:1886/mqtt', {
clientId: `ohc_${upperCallsign}_${Math.random().toString(16).substr(2, 6)}`, clientId: `ohc_${upperCallsign}_${Math.random().toString(16).substr(2, 6)}`,
clean: true, clean: true,
@ -196,17 +255,48 @@ export const usePSKReporter = (callsign, options = {}) => {
clientRef.current = client; clientRef.current = client;
// Set timeout — if MQTT doesn't connect within N seconds, fall back to HTTP
mqttTimerRef.current = setTimeout(() => {
if (!mountedRef.current || connected) return;
mqttFailed = true;
console.log('[PSKReporter] MQTT timeout, falling back to HTTP...');
setSource('http-fallback');
// Initial HTTP fetch
fetchHTTP(upperCallsign);
// Poll periodically
httpFallbackRef.current = setInterval(() => {
if (mountedRef.current) fetchHTTP(upperCallsign);
}, HTTP_POLL_INTERVAL);
}, MQTT_TIMEOUT_MS);
client.on('connect', () => { client.on('connect', () => {
if (!mountedRef.current) return; if (!mountedRef.current) return;
// Cancel HTTP fallback timer
if (mqttTimerRef.current) {
clearTimeout(mqttTimerRef.current);
mqttTimerRef.current = null;
}
// Stop any HTTP polling
if (httpFallbackRef.current) {
clearInterval(httpFallbackRef.current);
httpFallbackRef.current = null;
}
mqttFailed = false;
console.log('[PSKReporter MQTT] Connected!'); console.log('[PSKReporter MQTT] Connected!');
setConnected(true); setConnected(true);
setLoading(false); setLoading(false);
setSource('mqtt'); setSource('mqtt');
setError(null); setError(null);
// Subscribe to spots where we are the sender (being heard by others) // Topic format: pskr/filter/v2/{band}/{mode}/{senderCall}/{receiverCall}/...
// Topic format: pskr/filter/v2/{mode}/{band}/{senderCall}/{senderLoc}/{rxCall}/{rxLoc}/{freq}/{snr}
// TX: Subscribe where we are the sender (being heard by others)
// Sender call is at position 3 after v2 (index 5 in full topic)
const txTopic = `pskr/filter/v2/+/+/${upperCallsign}/#`; const txTopic = `pskr/filter/v2/+/+/${upperCallsign}/#`;
client.subscribe(txTopic, { qos: 0 }, (err) => { client.subscribe(txTopic, { qos: 0 }, (err) => {
if (err) { if (err) {
@ -216,8 +306,9 @@ export const usePSKReporter = (callsign, options = {}) => {
} }
}); });
// Subscribe to spots where we are the receiver (hearing others) // RX: Subscribe where we are the receiver (hearing others)
const rxTopic = `pskr/filter/v2/+/+/+/+/${upperCallsign}/#`; // Receiver call is at position 4 after v2 (index 6 in full topic)
const rxTopic = `pskr/filter/v2/+/+/+/${upperCallsign}/#`;
client.subscribe(rxTopic, { qos: 0 }, (err) => { client.subscribe(rxTopic, { qos: 0 }, (err) => {
if (err) { if (err) {
console.error('[PSKReporter MQTT] RX subscribe error:', err); console.error('[PSKReporter MQTT] RX subscribe error:', err);
@ -232,9 +323,8 @@ export const usePSKReporter = (callsign, options = {}) => {
client.on('error', (err) => { client.on('error', (err) => {
if (!mountedRef.current) return; if (!mountedRef.current) return;
console.error('[PSKReporter MQTT] Error:', err.message); console.error('[PSKReporter MQTT] Error:', err.message);
setError('Connection error'); setError('MQTT connection error');
setConnected(false); // Don't set loading false here - let the timeout trigger HTTP fallback
setLoading(false);
}); });
client.on('close', () => { client.on('close', () => {
@ -247,24 +337,32 @@ export const usePSKReporter = (callsign, options = {}) => {
if (!mountedRef.current) return; if (!mountedRef.current) return;
console.log('[PSKReporter MQTT] Offline'); console.log('[PSKReporter MQTT] Offline');
setConnected(false); setConnected(false);
setSource('offline'); if (!mqttFailed) setSource('offline');
}); });
client.on('reconnect', () => { client.on('reconnect', () => {
if (!mountedRef.current) return; if (!mountedRef.current) return;
console.log('[PSKReporter MQTT] Reconnecting...'); console.log('[PSKReporter MQTT] Reconnecting...');
setSource('reconnecting'); if (!mqttFailed) setSource('reconnecting');
}); });
// Cleanup on unmount or callsign change // Cleanup on unmount or callsign change
return () => { return () => {
mountedRef.current = false; mountedRef.current = false;
if (mqttTimerRef.current) {
clearTimeout(mqttTimerRef.current);
mqttTimerRef.current = null;
}
if (httpFallbackRef.current) {
clearInterval(httpFallbackRef.current);
httpFallbackRef.current = null;
}
if (client) { if (client) {
console.log('[PSKReporter MQTT] Cleaning up...'); console.log('[PSKReporter MQTT] Cleaning up...');
client.end(true); client.end(true);
} }
}; };
}, [callsign, enabled, processMessage]); }, [callsign, enabled, processMessage, fetchHTTP]);
// Periodically clean old spots and update ages // Periodically clean old spots and update ages
useEffect(() => { useEffect(() => {
@ -291,6 +389,15 @@ export const usePSKReporter = (callsign, options = {}) => {
// Manual refresh - force reconnect // Manual refresh - force reconnect
const refresh = useCallback(() => { const refresh = useCallback(() => {
// Stop HTTP polling
if (httpFallbackRef.current) {
clearInterval(httpFallbackRef.current);
httpFallbackRef.current = null;
}
if (mqttTimerRef.current) {
clearTimeout(mqttTimerRef.current);
mqttTimerRef.current = null;
}
if (clientRef.current) { if (clientRef.current) {
clientRef.current.end(true); clientRef.current.end(true);
clientRef.current = null; clientRef.current = null;

Loading…
Cancel
Save

Powered by TurnKey Linux.