From 4403ddd65740b7e625d1d7f350dabc0b0401ef30 Mon Sep 17 00:00:00 2001 From: accius Date: Tue, 3 Feb 2026 00:08:42 -0500 Subject: [PATCH] psk updates and version --- CHANGELOG.md | 16 ++++ server.js | 27 +++++-- src/App.jsx | 2 +- src/hooks/usePSKReporter.js | 151 ++++++++++++++++++++++++++++++------ 4 files changed, 167 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a419499..9808fd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ 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 ### Added diff --git a/server.js b/server.js index fc0db61..abd7562 100644 --- a/server.js +++ b/server.js @@ -1,5 +1,5 @@ /** - * OpenHamClock Server v3.10.0 + * OpenHamClock Server * * Express server that: * 1. Serves the static web application @@ -25,6 +25,14 @@ const net = require('net'); const dgram = require('dgram'); 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 const envPath = path.join(__dirname, '.env'); const envExamplePath = path.join(__dirname, '.env.example'); @@ -2097,10 +2105,13 @@ app.get('/api/pskreporter/http/:callsign', async (req, res) => { clearTimeout(timeout); 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) { psk503Backoff = Date.now() + (15 * 60 * 1000); 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}`); } @@ -2171,14 +2182,17 @@ app.get('/api/pskreporter/http/:callsign', async (req, res) => { res.json(result); } catch (error) { - logErrorOnce('PSKReporter HTTP', error.message); + // 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); + } // 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) + // Return empty result without error flag for rate limiting res.json({ callsign, direction, @@ -3553,7 +3567,7 @@ function getLastWeekendOfMonth(year, month) { app.get('/api/health', (req, res) => { res.json({ status: 'ok', - version: '3.3.0', + version: APP_VERSION, uptime: process.uptime(), timestamp: new Date().toISOString() }); @@ -3568,7 +3582,7 @@ app.get('/api/health', (req, res) => { app.get('/api/config', (req, res) => { // Don't expose API keys/passwords - only public config res.json({ - version: '3.10.0', + version: APP_VERSION, // Station info (from .env or config.json) callsign: CONFIG.callsign, @@ -4490,6 +4504,7 @@ app.listen(PORT, '0.0.0.0', () => { console.log('╚═══════════════════════════════════════════════════════╝'); console.log(''); const displayHost = HOST === '0.0.0.0' ? 'localhost' : HOST; + console.log(` 🌐 OpenHamClock v${APP_VERSION}`); console.log(` 🌐 Server running at http://${displayHost}:${PORT}`); if (HOST === '0.0.0.0') { console.log(` 🔗 Network access: http://:${PORT}`); diff --git a/src/App.jsx b/src/App.jsx index a00a75b..39f1c63 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,6 +1,6 @@ /** * OpenHamClock - Main Application Component - * Amateur Radio Dashboard v3.7.0 + * Amateur Radio Dashboard */ import React, { useState, useEffect, useCallback, useMemo } from 'react'; diff --git a/src/hooks/usePSKReporter.js b/src/hooks/usePSKReporter.js index e131531..90ead62 100644 --- a/src/hooks/usePSKReporter.js +++ b/src/hooks/usePSKReporter.js @@ -1,9 +1,18 @@ /** * 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 - * No HTTP API calls - direct WebSocket connection from browser + * Primary: Real-time MQTT feed from mqtt.pskreporter.info + * 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 mqtt from 'mqtt'; @@ -50,6 +59,11 @@ function getBandFromHz(freqHz) { 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 = {}) => { const { minutes = 15, // Time window to keep spots @@ -69,6 +83,8 @@ export const usePSKReporter = (callsign, options = {}) => { const txReportsRef = useRef([]); const rxReportsRef = useRef([]); const mountedRef = useRef(true); + const httpFallbackRef = useRef(null); + const mqttTimerRef = useRef(null); // Clean old spots (older than specified minutes) const cleanOldSpots = useCallback((spots, maxAgeMinutes) => { @@ -76,6 +92,45 @@ export const usePSKReporter = (callsign, options = {}) => { return spots.filter(s => s.timestamp > cutoff).slice(0, 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 const processMessage = useCallback((topic, message) => { if (!mountedRef.current) return; @@ -83,18 +138,20 @@ export const usePSKReporter = (callsign, options = {}) => { 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 + // PSKReporter MQTT message fields: + // sc = sender call, rc = receiver call (NOT sa/ra which are ADIF country codes) + // sl = sender locator, rl = receiver locator + // f = frequency, md = mode, rp = report (SNR), t = timestamp, b = band const { - sa: senderCallsign, + sc: senderCallsign, sl: senderLocator, - ra: receiverCallsign, + rc: receiverCallsign, rl: receiverLocator, f: frequency, md: mode, rp: snr, - t: timestamp + t: timestamp, + b: band } = data; if (!senderCallsign || !receiverCallsign) return; @@ -111,7 +168,7 @@ export const usePSKReporter = (callsign, options = {}) => { receiverGrid: receiverLocator, freq, freqMHz: freq ? (freq / 1000000).toFixed(3) : '?', - band: getBandFromHz(freq), + band: band || getBandFromHz(freq), mode: mode || 'Unknown', snr: snr !== undefined ? parseInt(snr) : null, timestamp: timestamp ? timestamp * 1000 : now, @@ -159,7 +216,7 @@ export const usePSKReporter = (callsign, options = {}) => { } }, [callsign, minutes, maxSpots, cleanOldSpots]); - // Connect to MQTT + // Connect to MQTT with HTTP fallback useEffect(() => { mountedRef.current = true; @@ -183,9 +240,11 @@ export const usePSKReporter = (callsign, options = {}) => { setError(null); setSource('connecting'); + let mqttFailed = false; + 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', { clientId: `ohc_${upperCallsign}_${Math.random().toString(16).substr(2, 6)}`, clean: true, @@ -196,17 +255,48 @@ export const usePSKReporter = (callsign, options = {}) => { 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', () => { 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!'); 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} + // Topic format: pskr/filter/v2/{band}/{mode}/{senderCall}/{receiverCall}/... + + // 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}/#`; client.subscribe(txTopic, { qos: 0 }, (err) => { if (err) { @@ -216,8 +306,9 @@ export const usePSKReporter = (callsign, options = {}) => { } }); - // Subscribe to spots where we are the receiver (hearing others) - const rxTopic = `pskr/filter/v2/+/+/+/+/${upperCallsign}/#`; + // RX: Subscribe where we are the receiver (hearing others) + // 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) => { if (err) { console.error('[PSKReporter MQTT] RX subscribe error:', err); @@ -232,9 +323,8 @@ export const usePSKReporter = (callsign, options = {}) => { client.on('error', (err) => { if (!mountedRef.current) return; console.error('[PSKReporter MQTT] Error:', err.message); - setError('Connection error'); - setConnected(false); - setLoading(false); + setError('MQTT connection error'); + // Don't set loading false here - let the timeout trigger HTTP fallback }); client.on('close', () => { @@ -247,24 +337,32 @@ export const usePSKReporter = (callsign, options = {}) => { if (!mountedRef.current) return; console.log('[PSKReporter MQTT] Offline'); setConnected(false); - setSource('offline'); + if (!mqttFailed) setSource('offline'); }); client.on('reconnect', () => { if (!mountedRef.current) return; console.log('[PSKReporter MQTT] Reconnecting...'); - setSource('reconnecting'); + if (!mqttFailed) setSource('reconnecting'); }); // Cleanup on unmount or callsign change return () => { mountedRef.current = false; + if (mqttTimerRef.current) { + clearTimeout(mqttTimerRef.current); + mqttTimerRef.current = null; + } + if (httpFallbackRef.current) { + clearInterval(httpFallbackRef.current); + httpFallbackRef.current = null; + } if (client) { console.log('[PSKReporter MQTT] Cleaning up...'); client.end(true); } }; - }, [callsign, enabled, processMessage]); + }, [callsign, enabled, processMessage, fetchHTTP]); // Periodically clean old spots and update ages useEffect(() => { @@ -291,6 +389,15 @@ export const usePSKReporter = (callsign, options = {}) => { // Manual refresh - force reconnect 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) { clientRef.current.end(true); clientRef.current = null;