From 82585acb3e1740bdf809220920778cf9127fad88 Mon Sep 17 00:00:00 2001 From: accius Date: Mon, 2 Feb 2026 21:07:44 -0500 Subject: [PATCH 1/4] maybe wsjtx integration --- .env.example | 11 + server.js | 517 +++++++++++++++++++++++++ src/App.jsx | 29 +- src/components/PSKReporterPanel.jsx | 576 +++++++++++++++++----------- src/components/WorldMap.jsx | 84 ++++ src/hooks/index.js | 1 + src/hooks/useWSJTX.js | 104 +++++ 7 files changed, 1100 insertions(+), 222 deletions(-) create mode 100644 src/hooks/useWSJTX.js diff --git a/.env.example b/.env.example index 0d05b11..7f8f2a7 100644 --- a/.env.example +++ b/.env.example @@ -77,6 +77,17 @@ SHOW_SATELLITES=true # Show DX paths on map (true/false) SHOW_DX_PATHS=true +# =========================================== +# WSJT-X / JTDX UDP INTEGRATION +# =========================================== + +# Enable WSJT-X UDP listener (true/false) +# Listens for decoded FT8/FT4/JT65/WSPR messages from WSJT-X, JTDX, etc. +WSJTX_ENABLED=true + +# UDP port to listen on (must match WSJT-X Settings > Reporting > UDP Server port) +WSJTX_UDP_PORT=2237 + # =========================================== # DX CLUSTER SETTINGS # =========================================== diff --git a/server.js b/server.js index 4fa3ff3..8dafe7b 100644 --- a/server.js +++ b/server.js @@ -22,6 +22,7 @@ const compression = require('compression'); const path = require('path'); const fetch = require('node-fetch'); const net = require('net'); +const dgram = require('dgram'); const fs = require('fs'); // Auto-create .env from .env.example on first run @@ -3620,6 +3621,519 @@ app.get('/api/config', (req, res) => { }); }); +// ============================================ +// WSJT-X UDP LISTENER +// ============================================ +// Receives decoded messages from WSJT-X, JTDX, etc. +// Configure WSJT-X: Settings > Reporting > UDP Server > address/port +// Protocol: QDataStream binary format per NetworkMessage.hpp + +const WSJTX_UDP_PORT = parseInt(process.env.WSJTX_UDP_PORT || '2237'); +const WSJTX_ENABLED = process.env.WSJTX_ENABLED !== 'false'; // enabled by default +const WSJTX_MAX_DECODES = 200; // max decodes to keep in memory +const WSJTX_MAX_AGE = 30 * 60 * 1000; // 30 minutes + +// WSJT-X protocol magic number +const WSJTX_MAGIC = 0xADBCCBDA; + +// Message types +const WSJTX_MSG = { + HEARTBEAT: 0, + STATUS: 1, + DECODE: 2, + CLEAR: 3, + REPLY: 4, + QSO_LOGGED: 5, + CLOSE: 6, + REPLAY: 7, + HALT_TX: 8, + FREE_TEXT: 9, + WSPR_DECODE: 10, + LOCATION: 11, + LOGGED_ADIF: 12, + HIGHLIGHT_CALLSIGN: 13, + SWITCH_CONFIG: 14, + CONFIGURE: 15, +}; + +// In-memory store +const wsjtxState = { + clients: {}, // clientId -> { status, lastSeen } + decodes: [], // decoded messages (ring buffer) + qsos: [], // logged QSOs + wspr: [], // WSPR decodes +}; + +/** + * QDataStream binary reader for WSJT-X protocol + * Reads big-endian Qt-serialized data types + */ +class WSJTXReader { + constructor(buffer) { + this.buf = buffer; + this.offset = 0; + } + + remaining() { return this.buf.length - this.offset; } + + readUInt8() { + if (this.remaining() < 1) return null; + const v = this.buf.readUInt8(this.offset); + this.offset += 1; + return v; + } + + readInt32() { + if (this.remaining() < 4) return null; + const v = this.buf.readInt32BE(this.offset); + this.offset += 4; + return v; + } + + readUInt32() { + if (this.remaining() < 4) return null; + const v = this.buf.readUInt32BE(this.offset); + this.offset += 4; + return v; + } + + readUInt64() { + if (this.remaining() < 8) return null; + // JavaScript can't do 64-bit ints natively, use BigInt or approximate + const high = this.buf.readUInt32BE(this.offset); + const low = this.buf.readUInt32BE(this.offset + 4); + this.offset += 8; + return high * 0x100000000 + low; + } + + readBool() { + const v = this.readUInt8(); + return v === null ? null : v !== 0; + } + + readDouble() { + if (this.remaining() < 8) return null; + const v = this.buf.readDoubleBE(this.offset); + this.offset += 8; + return v; + } + + // Qt utf8 string: uint32 length + bytes (0xFFFFFFFF = null) + readUtf8() { + const len = this.readUInt32(); + if (len === null || len === 0xFFFFFFFF) return null; + if (len === 0) return ''; + if (this.remaining() < len) return null; + const str = this.buf.toString('utf8', this.offset, this.offset + len); + this.offset += len; + return str; + } + + // QTime: uint32 milliseconds since midnight + readQTime() { + const ms = this.readUInt32(); + if (ms === null) return null; + const h = Math.floor(ms / 3600000); + const m = Math.floor((ms % 3600000) / 60000); + const s = Math.floor((ms % 60000) / 1000); + return { ms, hours: h, minutes: m, seconds: s, + formatted: `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}` }; + } + + // QDateTime: QDate (int64 julian day) + QTime (uint32 ms) + timespec + readQDateTime() { + const julianDay = this.readUInt64(); + const time = this.readQTime(); + const timeSpec = this.readUInt8(); + if (timeSpec === 2) this.readInt32(); // UTC offset + return { julianDay, time, timeSpec }; + } +} + +/** + * Parse a WSJT-X UDP datagram + */ +function parseWSJTXMessage(buffer) { + const reader = new WSJTXReader(buffer); + + // Header + const magic = reader.readUInt32(); + if (magic !== WSJTX_MAGIC) return null; + + const schema = reader.readUInt32(); + const type = reader.readUInt32(); + const id = reader.readUtf8(); + + if (type === null || id === null) return null; + + const msg = { type, id, schema, timestamp: Date.now() }; + + try { + switch (type) { + case WSJTX_MSG.HEARTBEAT: { + msg.maxSchema = reader.readUInt32(); + msg.version = reader.readUtf8(); + msg.revision = reader.readUtf8(); + break; + } + + case WSJTX_MSG.STATUS: { + msg.dialFrequency = reader.readUInt64(); + msg.mode = reader.readUtf8(); + msg.dxCall = reader.readUtf8(); + msg.report = reader.readUtf8(); + msg.txMode = reader.readUtf8(); + msg.txEnabled = reader.readBool(); + msg.transmitting = reader.readBool(); + msg.decoding = reader.readBool(); + msg.rxDF = reader.readUInt32(); + msg.txDF = reader.readUInt32(); + msg.deCall = reader.readUtf8(); + msg.deGrid = reader.readUtf8(); + msg.dxGrid = reader.readUtf8(); + msg.txWatchdog = reader.readBool(); + msg.subMode = reader.readUtf8(); + msg.fastMode = reader.readBool(); + msg.specialOp = reader.readUInt8(); + msg.freqTolerance = reader.readUInt32(); + msg.trPeriod = reader.readUInt32(); + msg.configName = reader.readUtf8(); + msg.txMessage = reader.readUtf8(); + break; + } + + case WSJTX_MSG.DECODE: { + msg.isNew = reader.readBool(); + msg.time = reader.readQTime(); + msg.snr = reader.readInt32(); + msg.deltaTime = reader.readDouble(); + msg.deltaFreq = reader.readUInt32(); + msg.mode = reader.readUtf8(); + msg.message = reader.readUtf8(); + msg.lowConfidence = reader.readBool(); + msg.offAir = reader.readBool(); + break; + } + + case WSJTX_MSG.CLEAR: { + msg.window = reader.readUInt8(); + break; + } + + case WSJTX_MSG.QSO_LOGGED: { + msg.dateTimeOff = reader.readQDateTime(); + msg.dxCall = reader.readUtf8(); + msg.dxGrid = reader.readUtf8(); + msg.txFrequency = reader.readUInt64(); + msg.mode = reader.readUtf8(); + msg.reportSent = reader.readUtf8(); + msg.reportRecv = reader.readUtf8(); + msg.txPower = reader.readUtf8(); + msg.comments = reader.readUtf8(); + msg.name = reader.readUtf8(); + msg.dateTimeOn = reader.readQDateTime(); + msg.operatorCall = reader.readUtf8(); + msg.myCall = reader.readUtf8(); + msg.myGrid = reader.readUtf8(); + msg.exchangeSent = reader.readUtf8(); + msg.exchangeRecv = reader.readUtf8(); + msg.adifPropMode = reader.readUtf8(); + break; + } + + case WSJTX_MSG.WSPR_DECODE: { + msg.isNew = reader.readBool(); + msg.time = reader.readQTime(); + msg.snr = reader.readInt32(); + msg.deltaTime = reader.readDouble(); + msg.frequency = reader.readUInt64(); + msg.drift = reader.readInt32(); + msg.callsign = reader.readUtf8(); + msg.grid = reader.readUtf8(); + msg.power = reader.readInt32(); + msg.offAir = reader.readBool(); + break; + } + + case WSJTX_MSG.LOGGED_ADIF: { + msg.adif = reader.readUtf8(); + break; + } + + case WSJTX_MSG.CLOSE: + break; + + default: + // Unknown message type - ignore per protocol spec + return null; + } + } catch (e) { + // Malformed packet - ignore + return null; + } + + return msg; +} + +/** + * Parse decoded message text to extract callsigns and grid + * FT8/FT4 messages follow a standard format + */ +function parseDecodeMessage(text) { + if (!text) return {}; + const result = {}; + + // CQ message: "CQ DX K1ABC FN42" or "CQ K1ABC FN42" + const cqMatch = text.match(/^CQ\s+(?:(\S+)\s+)?([A-Z0-9/]+)\s+([A-Z]{2}\d{2}[a-z]{0,2})?/i); + if (cqMatch) { + result.type = 'CQ'; + result.modifier = cqMatch[1] && !cqMatch[1].match(/^[A-Z0-9/]{3,}$/) ? cqMatch[1] : null; + result.caller = cqMatch[2] || cqMatch[1]; + result.grid = cqMatch[3] || null; + return result; + } + + // Standard QSO exchange: "K1ABC W2DEF +05" or "K1ABC W2DEF R-12" or "K1ABC W2DEF RR73" + const qsoMatch = text.match(/^([A-Z0-9/]+)\s+([A-Z0-9/]+)\s+(.*)/i); + if (qsoMatch) { + result.type = 'QSO'; + result.dxCall = qsoMatch[1]; + result.deCall = qsoMatch[2]; + result.exchange = qsoMatch[3].trim(); + // Check for grid in exchange + const gridMatch = result.exchange.match(/^([A-Z]{2}\d{2}[a-z]{0,2})$/i); + if (gridMatch) result.grid = gridMatch[1]; + return result; + } + + return result; +} + +/** + * Convert frequency in Hz to band name + */ +function freqToBand(freqHz) { + const mhz = freqHz / 1000000; + if (mhz >= 1.8 && mhz < 2.0) return '160m'; + if (mhz >= 3.5 && mhz < 4.0) return '80m'; + if (mhz >= 5.3 && mhz < 5.4) return '60m'; + if (mhz >= 7.0 && mhz < 7.3) return '40m'; + if (mhz >= 10.1 && mhz < 10.15) return '30m'; + if (mhz >= 14.0 && mhz < 14.35) return '20m'; + if (mhz >= 18.068 && mhz < 18.168) return '17m'; + if (mhz >= 21.0 && mhz < 21.45) return '15m'; + if (mhz >= 24.89 && mhz < 24.99) return '12m'; + if (mhz >= 28.0 && mhz < 29.7) return '10m'; + if (mhz >= 50.0 && mhz < 54.0) return '6m'; + if (mhz >= 144.0 && mhz < 148.0) return '2m'; + if (mhz >= 420.0 && mhz < 450.0) return '70cm'; + return `${mhz.toFixed(3)} MHz`; +} + +/** + * Handle incoming WSJT-X messages + */ +function handleWSJTXMessage(msg) { + if (!msg) return; + + switch (msg.type) { + case WSJTX_MSG.HEARTBEAT: { + wsjtxState.clients[msg.id] = { + ...(wsjtxState.clients[msg.id] || {}), + version: msg.version, + lastSeen: msg.timestamp + }; + break; + } + + case WSJTX_MSG.STATUS: { + wsjtxState.clients[msg.id] = { + ...(wsjtxState.clients[msg.id] || {}), + lastSeen: msg.timestamp, + dialFrequency: msg.dialFrequency, + mode: msg.mode, + dxCall: msg.dxCall, + deCall: msg.deCall, + deGrid: msg.deGrid, + txEnabled: msg.txEnabled, + transmitting: msg.transmitting, + decoding: msg.decoding, + subMode: msg.subMode, + band: msg.dialFrequency ? freqToBand(msg.dialFrequency) : null, + configName: msg.configName, + txMessage: msg.txMessage, + }; + break; + } + + case WSJTX_MSG.DECODE: { + const clientStatus = wsjtxState.clients[msg.id] || {}; + const parsed = parseDecodeMessage(msg.message); + + const decode = { + id: `${msg.id}-${msg.timestamp}-${msg.deltaFreq}`, + clientId: msg.id, + isNew: msg.isNew, + time: msg.time?.formatted || '', + timeMs: msg.time?.ms || 0, + snr: msg.snr, + dt: msg.deltaTime ? msg.deltaTime.toFixed(1) : '0.0', + freq: msg.deltaFreq, + mode: msg.mode || clientStatus.mode || '', + message: msg.message, + lowConfidence: msg.lowConfidence, + offAir: msg.offAir, + dialFrequency: clientStatus.dialFrequency || 0, + band: clientStatus.band || '', + ...parsed, + timestamp: msg.timestamp, + }; + + // Resolve grid to lat/lon for map plotting + if (parsed.grid) { + const coords = gridToLatLon(parsed.grid); + if (coords) { + decode.lat = coords.latitude; + decode.lon = coords.longitude; + } + } + + // Only keep new decodes (not replays) + if (msg.isNew) { + wsjtxState.decodes.push(decode); + + // Trim old decodes + const cutoff = Date.now() - WSJTX_MAX_AGE; + while (wsjtxState.decodes.length > WSJTX_MAX_DECODES || + (wsjtxState.decodes.length > 0 && wsjtxState.decodes[0].timestamp < cutoff)) { + wsjtxState.decodes.shift(); + } + } + break; + } + + case WSJTX_MSG.CLEAR: { + // WSJT-X cleared its band activity - optionally clear our decodes for this client + wsjtxState.decodes = wsjtxState.decodes.filter(d => d.clientId !== msg.id); + break; + } + + case WSJTX_MSG.QSO_LOGGED: { + const clientStatus = wsjtxState.clients[msg.id] || {}; + const qso = { + clientId: msg.id, + dxCall: msg.dxCall, + dxGrid: msg.dxGrid, + frequency: msg.txFrequency, + band: msg.txFrequency ? freqToBand(msg.txFrequency) : '', + mode: msg.mode, + reportSent: msg.reportSent, + reportRecv: msg.reportRecv, + myCall: msg.myCall || clientStatus.deCall, + myGrid: msg.myGrid || clientStatus.deGrid, + timestamp: msg.timestamp, + }; + // Resolve grid to lat/lon + if (msg.dxGrid) { + const coords = gridToLatLon(msg.dxGrid); + if (coords) { qso.lat = coords.latitude; qso.lon = coords.longitude; } + } + wsjtxState.qsos.push(qso); + // Keep last 50 QSOs + if (wsjtxState.qsos.length > 50) wsjtxState.qsos.shift(); + break; + } + + case WSJTX_MSG.WSPR_DECODE: { + const wsprDecode = { + clientId: msg.id, + isNew: msg.isNew, + time: msg.time?.formatted || '', + snr: msg.snr, + dt: msg.deltaTime ? msg.deltaTime.toFixed(1) : '0.0', + frequency: msg.frequency, + drift: msg.drift, + callsign: msg.callsign, + grid: msg.grid, + power: msg.power, + timestamp: msg.timestamp, + }; + if (msg.isNew) { + wsjtxState.wspr.push(wsprDecode); + if (wsjtxState.wspr.length > 100) wsjtxState.wspr.shift(); + } + break; + } + + case WSJTX_MSG.CLOSE: { + delete wsjtxState.clients[msg.id]; + break; + } + } +} + +// Start UDP listener +let wsjtxSocket = null; +if (WSJTX_ENABLED) { + try { + wsjtxSocket = dgram.createSocket('udp4'); + + wsjtxSocket.on('message', (buf, rinfo) => { + const msg = parseWSJTXMessage(buf); + if (msg) handleWSJTXMessage(msg); + }); + + wsjtxSocket.on('error', (err) => { + logErrorOnce('WSJT-X UDP', err.message); + }); + + wsjtxSocket.on('listening', () => { + const addr = wsjtxSocket.address(); + console.log(`[WSJT-X] UDP listener on ${addr.address}:${addr.port}`); + }); + + wsjtxSocket.bind(WSJTX_UDP_PORT, '0.0.0.0'); + } catch (e) { + console.error(`[WSJT-X] Failed to start UDP listener: ${e.message}`); + } +} + +// API endpoint: get WSJT-X data +app.get('/api/wsjtx', (req, res) => { + const clients = {}; + for (const [id, client] of Object.entries(wsjtxState.clients)) { + // Only include clients seen in last 5 minutes + if (Date.now() - client.lastSeen < 5 * 60 * 1000) { + clients[id] = client; + } + } + + res.json({ + enabled: WSJTX_ENABLED, + port: WSJTX_UDP_PORT, + clients, + decodes: wsjtxState.decodes.slice(-100), // last 100 + qsos: wsjtxState.qsos.slice(-20), // last 20 + wspr: wsjtxState.wspr.slice(-50), // last 50 + stats: { + totalDecodes: wsjtxState.decodes.length, + totalQsos: wsjtxState.qsos.length, + totalWspr: wsjtxState.wspr.length, + activeClients: Object.keys(clients).length, + } + }); +}); + +// API endpoint: get just decodes (lightweight polling) +app.get('/api/wsjtx/decodes', (req, res) => { + const since = parseInt(req.query.since) || 0; + const decodes = since + ? wsjtxState.decodes.filter(d => d.timestamp > since) + : wsjtxState.decodes.slice(-100); + + res.json({ decodes, timestamp: Date.now() }); +}); + // ============================================ // CATCH-ALL FOR SPA // ============================================ @@ -3663,6 +4177,9 @@ app.listen(PORT, '0.0.0.0', () => { console.log(` πŸ”— Network access: http://:${PORT}`); } console.log(' πŸ“‘ API proxy enabled for NOAA, POTA, SOTA, DX Cluster'); + if (WSJTX_ENABLED) { + console.log(` πŸ”Š WSJT-X UDP listener on port ${WSJTX_UDP_PORT}`); + } console.log(' πŸ–₯️ Open your browser to start using OpenHamClock'); console.log(''); if (CONFIG.callsign !== 'N0CALL') { diff --git a/src/App.jsx b/src/App.jsx index 2aa0d9e..558acf2 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -34,7 +34,8 @@ import { useDXpeditions, useSatellites, useSolarIndices, - usePSKReporter + usePSKReporter, + useWSJTX } from './hooks'; // Utils @@ -104,9 +105,9 @@ const App = () => { const [mapLayers, setMapLayers] = useState(() => { try { const stored = localStorage.getItem('openhamclock_mapLayers'); - const defaults = { showDXPaths: true, showDXLabels: true, showPOTA: true, showSatellites: false, showPSKReporter: true }; + const defaults = { showDXPaths: true, showDXLabels: true, showPOTA: true, showSatellites: false, showPSKReporter: true, showWSJTX: true }; return stored ? { ...defaults, ...JSON.parse(stored) } : defaults; - } catch (e) { return { showDXPaths: true, showDXLabels: true, showPOTA: true, showSatellites: false, showPSKReporter: true }; } + } catch (e) { return { showDXPaths: true, showDXLabels: true, showPOTA: true, showSatellites: false, showPSKReporter: true, showWSJTX: true }; } }); useEffect(() => { @@ -122,6 +123,7 @@ const App = () => { 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 })), []); + const toggleWSJTX = useCallback(() => setMapLayers(prev => ({ ...prev, showWSJTX: !prev.showWSJTX })), []); // 12/24 hour format const [use12Hour, setUse12Hour] = useState(() => { @@ -208,6 +210,7 @@ const App = () => { const satellites = useSatellites(config.location); const localWeather = useLocalWeather(config.location); const pskReporter = usePSKReporter(config.callsign, { minutes: 15, enabled: config.callsign !== 'N0CALL' }); + const wsjtx = useWSJTX(); // Filter PSKReporter spots for map display const filteredPskSpots = useMemo(() => { @@ -228,6 +231,11 @@ const App = () => { }); }, [pskReporter.txReports, pskReporter.rxReports, pskFilters]); + // Filter WSJT-X decodes for map display (only those with lat/lon from grid) + const wsjtxMapSpots = useMemo(() => { + return wsjtx.decodes.filter(d => d.lat && d.lon && d.type === 'CQ'); + }, [wsjtx.decodes]); + // Computed values const deGrid = useMemo(() => calculateGridSquare(config.location.lat, config.location.lon), [config.location]); const dxGrid = useMemo(() => calculateGridSquare(dxLocation.lat, dxLocation.lon), [dxLocation]); @@ -503,6 +511,8 @@ const App = () => { showPOTA={mapLayers.showPOTA} showSatellites={mapLayers.showSatellites} showPSKReporter={mapLayers.showPSKReporter} + wsjtxSpots={wsjtxMapSpots} + showWSJTX={mapLayers.showWSJTX} onToggleSatellites={toggleSatellites} hoveredSpot={hoveredSpot} /> @@ -641,6 +651,8 @@ const App = () => { showPOTA={mapLayers.showPOTA} showSatellites={mapLayers.showSatellites} showPSKReporter={mapLayers.showPSKReporter} + wsjtxSpots={wsjtxMapSpots} + showWSJTX={mapLayers.showWSJTX} onToggleSatellites={toggleSatellites} hoveredSpot={hoveredSpot} /> @@ -677,7 +689,7 @@ const App = () => { /> - {/* PSKReporter - digital mode spots */} + {/* PSKReporter + WSJT-X - digital mode spots */}
{ setDxLocation({ lat: report.lat, lon: report.lon, call: report.receiver || report.sender }); } }} + wsjtxDecodes={wsjtx.decodes} + wsjtxClients={wsjtx.clients} + wsjtxQsos={wsjtx.qsos} + wsjtxStats={wsjtx.stats} + wsjtxLoading={wsjtx.loading} + wsjtxEnabled={wsjtx.enabled} + wsjtxPort={wsjtx.port} + showWSJTXOnMap={mapLayers.showWSJTX} + onToggleWSJTXMap={toggleWSJTX} />
diff --git a/src/components/PSKReporterPanel.jsx b/src/components/PSKReporterPanel.jsx index d7fff17..3c380d7 100644 --- a/src/components/PSKReporterPanel.jsx +++ b/src/components/PSKReporterPanel.jsx @@ -1,7 +1,7 @@ /** * PSKReporter Panel * Shows where your digital mode signals are being received - * Uses MQTT WebSocket for real-time data + * Toggles between PSKReporter (internet) and WSJT-X (local UDP) views */ import React, { useState, useMemo } from 'react'; import { usePSKReporter } from '../hooks/usePSKReporter.js'; @@ -13,9 +13,23 @@ const PSKReporterPanel = ({ showOnMap, onToggleMap, filters = {}, - onOpenFilters + onOpenFilters, + // WSJT-X props + wsjtxDecodes = [], + wsjtxClients = {}, + wsjtxQsos = [], + wsjtxStats = {}, + wsjtxLoading, + wsjtxEnabled, + wsjtxPort, + showWSJTXOnMap, + onToggleWSJTXMap }) => { - const [activeTab, setActiveTab] = useState('tx'); // Default to 'tx' (Being Heard) + const [panelMode, setPanelMode] = useState('psk'); // 'psk' | 'wsjtx' + const [activeTab, setActiveTab] = useState('tx'); // PSK: tx | rx + const [wsjtxTab, setWsjtxTab] = useState('decodes'); // WSJT-X: decodes | qsos + const [bandFilter, setBandFilter] = useState('all'); + const [showCQ, setShowCQ] = useState(false); const { txReports, @@ -32,23 +46,17 @@ const PSKReporterPanel = ({ enabled: callsign && callsign !== 'N0CALL' }); - // Filter reports by band, grid, and mode + // PSK filter logic 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; }); }; @@ -57,7 +65,6 @@ const PSKReporterPanel = ({ 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++; @@ -67,46 +74,66 @@ const PSKReporterPanel = ({ }; const filterCount = getActiveFilterCount(); - // Get band color from frequency const getFreqColor = (freqMHz) => { if (!freqMHz) return 'var(--text-muted)'; - const freq = parseFloat(freqMHz); - return getBandColor(freq); + return getBandColor(parseFloat(freqMHz)); }; - // Format age const formatAge = (minutes) => { if (minutes < 1) return 'now'; if (minutes < 60) return `${minutes}m`; return `${Math.floor(minutes/60)}h`; }; - // Get status indicator const getStatusIndicator = () => { - if (connected) { - return ● LIVE; - } - if (source === 'connecting' || source === 'reconnecting') { - return ◐ {source}; - } - if (error) { - return ● offline; - } + if (connected) return ● LIVE; + if (source === 'connecting' || source === 'reconnecting') return ◐ {source}; + if (error) return ● offline; return null; }; - if (!callsign || callsign === 'N0CALL') { - return ( -
-
- πŸ“‘ PSKReporter -
-
- Set callsign in Settings -
-
- ); - } + // WSJT-X helpers + const activeClients = Object.entries(wsjtxClients); + const primaryClient = activeClients.length > 0 ? activeClients[0][1] : null; + + const wsjtxBands = useMemo(() => { + const bands = new Set(wsjtxDecodes.map(d => d.band).filter(Boolean)); + return ['all', ...Array.from(bands).sort((a, b) => (parseInt(b) || 999) - (parseInt(a) || 999))]; + }, [wsjtxDecodes]); + + const filteredDecodes = useMemo(() => { + let filtered = [...wsjtxDecodes]; + if (bandFilter !== 'all') filtered = filtered.filter(d => d.band === bandFilter); + if (showCQ) filtered = filtered.filter(d => d.type === 'CQ'); + return filtered.reverse(); + }, [wsjtxDecodes, bandFilter, showCQ]); + + const getSnrColor = (snr) => { + if (snr === null || snr === undefined) return 'var(--text-muted)'; + if (snr >= 0) return '#4ade80'; + if (snr >= -10) return '#fbbf24'; + if (snr >= -18) return '#fb923c'; + return '#ef4444'; + }; + + const getMsgColor = (decode) => { + if (decode.type === 'CQ') return '#60a5fa'; + if (decode.exchange === 'RR73' || decode.exchange === '73' || decode.exchange === 'RRR') return '#4ade80'; + if (decode.exchange?.startsWith('R')) return '#fbbf24'; + return 'var(--text-primary)'; + }; + + // Mode switch button style + const modeBtn = (mode, color) => ({ + padding: '2px 8px', + background: panelMode === mode ? `${color}22` : 'transparent', + border: `1px solid ${panelMode === mode ? color : 'var(--border-color)'}`, + color: panelMode === mode ? color : 'var(--text-muted)', + borderRadius: '3px', + fontSize: '10px', + cursor: 'pointer', + fontWeight: panelMode === mode ? '700' : '400', + }); return (
- {/* Header */} + {/* Mode switcher header */}
- πŸ“‘ PSKReporter {getStatusIndicator()} -
- - {filteredReports.length}/{activeTab === 'tx' ? txCount : rxCount} - - - - {onToggleMap && ( - +
+ + {/* Controls row - differs per mode */} +
+ {panelMode === 'psk' && ( + <> + + {filteredReports.length}/{activeTab === 'tx' ? txCount : rxCount} + + {getStatusIndicator()} + + + {onToggleMap && ( + + )} + + )} + {panelMode === 'wsjtx' && ( + <> + {primaryClient && ( + + {primaryClient.mode || ''} {primaryClient.band || ''} + {primaryClient.transmitting && TX} + {primaryClient.decoding && RX} + + )} + + + {onToggleWSJTXMap && ( + + )} + )}
- - {/* Tabs */} -
- - -
- {/* Reports list */} - {error && !connected ? ( -
- ⚠️ Connection failed - click πŸ”„ to retry -
- ) : loading && filteredReports.length === 0 && filterCount === 0 ? ( -
-
- Connecting to MQTT... -
- ) : !connected && filteredReports.length === 0 && filterCount === 0 ? ( -
- Waiting for connection... -
- ) : filteredReports.length === 0 ? ( -
- {filterCount > 0 - ? 'No spots match filters' - : activeTab === 'tx' - ? 'Waiting for spots... (TX to see reports)' - : 'No stations heard yet'} -
- ) : ( -
- {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; - const grid = activeTab === 'tx' ? report.receiverGrid : report.senderGrid; - - return ( -
onShowOnMap && report.lat && report.lon && onShowOnMap(report)} + {/* === PSKReporter View === */} + {panelMode === 'psk' && ( + <> + {(!callsign || callsign === 'N0CALL') ? ( +
+ Set callsign in Settings +
+ ) : ( + <> + {/* PSK Tabs */} +
+ + +
+ + {/* PSK Reports list */} + {error && !connected ? ( +
+ ⚠️ Connection failed - click πŸ”„ to retry +
+ ) : loading && filteredReports.length === 0 && filterCount === 0 ? ( +
+
+ Connecting to MQTT... +
+ ) : !connected && filteredReports.length === 0 && filterCount === 0 ? ( +
+ Waiting for connection... +
+ ) : filteredReports.length === 0 ? ( +
+ {filterCount > 0 + ? 'No spots match filters' + : activeTab === 'tx' + ? 'Waiting for spots... (TX to see reports)' + : 'No stations heard yet'} +
+ ) : ( +
+ {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; + const grid = activeTab === 'tx' ? report.receiverGrid : report.senderGrid; + + return ( +
onShowOnMap && report.lat && report.lon && onShowOnMap(report)} + style={{ + display: 'grid', gridTemplateColumns: '55px 1fr auto', + gap: '6px', padding: '4px 6px', borderRadius: '3px', marginBottom: '2px', + background: i % 2 === 0 ? 'rgba(255,255,255,0.03)' : 'transparent', + cursor: report.lat && report.lon ? 'pointer' : 'default', + transition: 'background 0.15s', borderLeft: '2px solid transparent' + }} + onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(68, 136, 255, 0.15)'} + onMouseLeave={(e) => e.currentTarget.style.background = i % 2 === 0 ? 'rgba(255,255,255,0.03)' : 'transparent'} + > +
{freqMHz}
+
+ {displayCall} + {grid && {grid}} +
+
+ {report.mode} + {report.snr !== null && report.snr !== undefined && ( + = 0 ? '#4ade80' : report.snr >= -10 ? '#fbbf24' : '#f97316', fontWeight: '600' }}> + {report.snr > 0 ? '+' : ''}{report.snr} + + )} + {formatAge(report.age)} +
+
+ ); + })} +
+ )} + + )} + + )} + + {/* === WSJT-X View === */} + {panelMode === 'wsjtx' && ( + <> + {/* WSJT-X Tabs */} +
+ {[ + { key: 'decodes', label: `Decodes (${wsjtxDecodes.length})` }, + { key: 'qsos', label: `QSOs (${wsjtxQsos.length})` }, + ].map(tab => ( + + ))} +
+ + {/* No WSJT-X connected */} + {!wsjtxLoading && activeClients.length === 0 && wsjtxDecodes.length === 0 ? ( +
+
Waiting for WSJT-X...
+
+ Settings β†’ Reporting β†’ UDP Server +
+ Address: {'{server IP}'}   Port: {wsjtxPort || 2237} +
+
+ ) : ( + <> + {/* Decodes / QSOs content */} +
+ {wsjtxTab === 'decodes' && ( + <> + {filteredDecodes.length === 0 ? ( +
+ {wsjtxDecodes.length > 0 ? 'No decodes match filter' : 'Listening for decodes...'} +
+ ) : ( + filteredDecodes.map((d, i) => ( +
+ {d.time} + + {d.snr != null ? (d.snr >= 0 ? `+${d.snr}` : d.snr) : ''} + + {d.dt} + {d.freq} + {d.message} +
+ )) + )} + + )} + + {wsjtxTab === 'qsos' && ( + <> + {wsjtxQsos.length === 0 ? ( +
+ No QSOs logged yet +
+ ) : ( + [...wsjtxQsos].reverse().map((q, i) => ( +
+ + {q.dxCall} + + {q.band} + {q.mode} + {q.reportSent}/{q.reportRecv} + {q.dxGrid && {q.dxGrid}} +
+ )) + )} + + )} +
+ + {/* WSJT-X status bar */} + {activeClients.length > 0 && (
- {report.mode} - {report.snr !== null && report.snr !== undefined && ( - = 0 ? '#4ade80' : report.snr >= -10 ? '#fbbf24' : '#f97316', - fontWeight: '600' - }}> - {report.snr > 0 ? '+' : ''}{report.snr} + + {activeClients.map(([id, c]) => `${id}${c.version ? ` v${c.version}` : ''}`).join(', ')} + + {primaryClient?.dialFrequency && ( + + {(primaryClient.dialFrequency / 1000000).toFixed(6)} MHz )} - - {formatAge(report.age)} -
-
- ); - })} -
+ )} + + )} + )}
); diff --git a/src/components/WorldMap.jsx b/src/components/WorldMap.jsx index 89ad624..3924343 100644 --- a/src/components/WorldMap.jsx +++ b/src/components/WorldMap.jsx @@ -27,12 +27,14 @@ export const WorldMap = ({ dxFilters, satellites, pskReporterSpots, + wsjtxSpots, showDXPaths, showDXLabels, onToggleDXLabels, showPOTA, showSatellites, showPSKReporter, + showWSJTX, onToggleSatellites, hoveredSpot }) => { @@ -52,6 +54,7 @@ export const WorldMap = ({ const satMarkersRef = useRef([]); const satTracksRef = useRef([]); const pskMarkersRef = useRef([]); + const wsjtxMarkersRef = useRef([]); const countriesLayerRef = useRef(null); // Plugin system refs and state @@ -647,6 +650,87 @@ export const WorldMap = ({ } }, [pskReporterSpots, showPSKReporter, deLocation]); + // Update WSJT-X markers (CQ callers with grid locators) + useEffect(() => { + if (!mapInstanceRef.current) return; + const map = mapInstanceRef.current; + + wsjtxMarkersRef.current.forEach(m => map.removeLayer(m)); + wsjtxMarkersRef.current = []; + + const hasValidDE = deLocation && + typeof deLocation.lat === 'number' && !isNaN(deLocation.lat) && + typeof deLocation.lon === 'number' && !isNaN(deLocation.lon); + + if (showWSJTX && wsjtxSpots && wsjtxSpots.length > 0 && hasValidDE) { + // Deduplicate by callsign - keep most recent + const seen = new Map(); + wsjtxSpots.forEach(spot => { + const call = spot.caller || spot.dxCall || ''; + if (call && (!seen.has(call) || spot.timestamp > seen.get(call).timestamp)) { + seen.set(call, spot); + } + }); + + seen.forEach((spot, call) => { + let spotLat = parseFloat(spot.lat); + let spotLon = parseFloat(spot.lon); + + if (!isNaN(spotLat) && !isNaN(spotLon)) { + const freqMHz = spot.dialFrequency ? (spot.dialFrequency / 1000000) : 0; + const bandColor = freqMHz ? getBandColor(freqMHz) : '#a78bfa'; + + try { + // Draw line from DE to CQ caller + const points = getGreatCirclePoints( + deLocation.lat, deLocation.lon, + spotLat, spotLon, + 50 + ); + + if (points && Array.isArray(points) && points.length > 1 && + points.every(p => Array.isArray(p) && !isNaN(p[0]) && !isNaN(p[1]))) { + const line = L.polyline(points, { + color: '#a78bfa', + weight: 1.5, + opacity: 0.4, + dashArray: '2, 6' + }).addTo(map); + wsjtxMarkersRef.current.push(line); + + const endPoint = points[points.length - 1]; + spotLat = endPoint[0]; + spotLon = endPoint[1]; + } + + // Diamond-shaped marker to distinguish from PSK circles + const diamond = L.marker([spotLat, spotLon], { + icon: L.divIcon({ + className: '', + html: `
`, + iconSize: [8, 8], + iconAnchor: [4, 4] + }) + }).bindPopup(` + ${call} CQ
+ ${spot.grid || ''} ${spot.band || ''}
+ ${spot.mode || ''} SNR: ${spot.snr != null ? (spot.snr >= 0 ? '+' : '') + spot.snr : '?'} dB + `).addTo(map); + wsjtxMarkersRef.current.push(diamond); + } catch (err) { + // skip bad spots + } + } + }); + } + }, [wsjtxSpots, showWSJTX, deLocation]); + return (
diff --git a/src/hooks/index.js b/src/hooks/index.js index b07aec3..ef98876 100644 --- a/src/hooks/index.js +++ b/src/hooks/index.js @@ -16,3 +16,4 @@ export { useDXpeditions } from './useDXpeditions.js'; export { useSatellites } from './useSatellites.js'; export { useSolarIndices } from './useSolarIndices.js'; export { usePSKReporter } from './usePSKReporter.js'; +export { useWSJTX } from './useWSJTX.js'; diff --git a/src/hooks/useWSJTX.js b/src/hooks/useWSJTX.js new file mode 100644 index 0000000..47d58db --- /dev/null +++ b/src/hooks/useWSJTX.js @@ -0,0 +1,104 @@ +/** + * useWSJTX Hook + * Polls the server for WSJT-X UDP data (decoded messages, status, QSOs) + * + * WSJT-X sends decoded FT8/FT4/JT65/WSPR messages over UDP. + * The server listens on the configured port and this hook fetches the results. + */ +import { useState, useEffect, useCallback, useRef } from 'react'; + +const POLL_INTERVAL = 2000; // Poll every 2 seconds for near-real-time feel +const API_URL = '/api/wsjtx'; +const DECODES_URL = '/api/wsjtx/decodes'; + +export function useWSJTX(enabled = true) { + const [data, setData] = useState({ + clients: {}, + decodes: [], + qsos: [], + wspr: [], + stats: { totalDecodes: 0, totalQsos: 0, totalWspr: 0, activeClients: 0 }, + enabled: false, + port: 2237, + }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const lastTimestamp = useRef(0); + const fullFetchCounter = useRef(0); + + // Lightweight poll - just new decodes since last check + const pollDecodes = useCallback(async () => { + if (!enabled) return; + try { + const url = lastTimestamp.current + ? `${DECODES_URL}?since=${lastTimestamp.current}` + : DECODES_URL; + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const json = await res.json(); + + if (json.decodes?.length > 0) { + setData(prev => { + // Merge new decodes, dedup by id, keep last 200 + const existing = new Set(prev.decodes.map(d => d.id)); + const newDecodes = json.decodes.filter(d => !existing.has(d.id)); + if (newDecodes.length === 0) return prev; + + const merged = [...prev.decodes, ...newDecodes].slice(-200); + return { ...prev, decodes: merged, stats: { ...prev.stats, totalDecodes: merged.length } }; + }); + } + + lastTimestamp.current = json.timestamp || Date.now(); + setError(null); + } catch (e) { + // Silent fail for lightweight polls + } + }, [enabled]); + + // Full fetch - get everything including status, QSOs, clients + const fetchFull = useCallback(async () => { + if (!enabled) return; + try { + const res = await fetch(API_URL); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const json = await res.json(); + setData(json); + lastTimestamp.current = Date.now(); + setLoading(false); + setError(null); + } catch (e) { + setError(e.message); + setLoading(false); + } + }, [enabled]); + + // Initial full fetch + useEffect(() => { + if (enabled) fetchFull(); + }, [enabled, fetchFull]); + + // Polling - mostly lightweight, full refresh every 15s + useEffect(() => { + if (!enabled) return; + + const interval = setInterval(() => { + fullFetchCounter.current++; + if (fullFetchCounter.current >= 8) { // Every ~16 seconds + fullFetchCounter.current = 0; + fetchFull(); + } else { + pollDecodes(); + } + }, POLL_INTERVAL); + + return () => clearInterval(interval); + }, [enabled, fetchFull, pollDecodes]); + + return { + ...data, + loading, + error, + refresh: fetchFull, + }; +} From a94be88f0ed2a2000a1500b92ba3ca6568d51aa6 Mon Sep 17 00:00:00 2001 From: accius Date: Mon, 2 Feb 2026 21:28:26 -0500 Subject: [PATCH 2/4] rpi issue/ docker port --- .env.example | 7 + Dockerfile | 3 +- docker-compose.yml | 7 +- server.js | 3 + src/App.jsx | 13 +- src/components/PSKReporterPanel.jsx | 661 +++++++++++++--------------- src/components/SettingsPanel.jsx | 109 +++++ src/utils/config.js | 2 + 8 files changed, 454 insertions(+), 351 deletions(-) diff --git a/.env.example b/.env.example index 7f8f2a7..bd024ae 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,13 @@ THEME=dark # Layout: 'modern' or 'classic' LAYOUT=modern +# Timezone: IANA timezone identifier +# Set this if your local time shows incorrectly (e.g. same as UTC). +# This is common with privacy browsers like Librewolf that spoof timezone. +# Examples: America/New_York, America/Regina, Europe/London, Asia/Tokyo +# Leave blank or commented out to use browser default. +# TZ=America/New_York + # =========================================== # OPTIONAL - External Services # =========================================== diff --git a/Dockerfile b/Dockerfile index ee30b65..94a9377 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,8 +55,9 @@ RUN chown -R openhamclock:nodejs /app # Switch to non-root user USER openhamclock -# Expose port +# Expose ports (3000 = web, 2237 = WSJT-X UDP) EXPOSE 3000 +EXPOSE 2237/udp # Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ diff --git a/docker-compose.yml b/docker-compose.yml index 308dc5a..8784829 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,9 +6,13 @@ services: container_name: openhamclock ports: - "3000:3000" + - "2237:2237/udp" # WSJT-X UDP β€” point WSJT-X to 127.0.0.1:2237 environment: - NODE_ENV=production - PORT=3000 + # Uncomment and set your timezone (IANA format) + # This ensures correct local time display, especially with privacy browsers + # - TZ=America/New_York restart: unless-stopped healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"] @@ -16,9 +20,6 @@ services: timeout: 10s retries: 3 start_period: 10s - # Uncomment to set timezone - # environment: - # - TZ=America/Denver # For development with hot reload: # docker compose -f docker-compose.dev.yml up diff --git a/server.js b/server.js index 8dafe7b..a87c940 100644 --- a/server.js +++ b/server.js @@ -3600,6 +3600,9 @@ app.get('/api/config', (req, res) => { // Whether config is incomplete (show setup wizard) configIncomplete: CONFIG.callsign === 'N0CALL' || !CONFIG.gridSquare, + // Server timezone (from TZ env var or system) + timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || '', + // Feature availability features: { spaceWeather: true, diff --git a/src/App.jsx b/src/App.jsx index 558acf2..8b52cb8 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -259,11 +259,18 @@ const App = () => { setDxLocation({ lat: coords.lat, lon: coords.lon }); }, []); - // Format times + // Format times β€” use explicit timezone if configured (fixes privacy browsers like Librewolf + // that spoof timezone to UTC via privacy.resistFingerprinting) const utcTime = currentTime.toISOString().substr(11, 8); - const localTime = currentTime.toLocaleTimeString('en-US', { hour12: use12Hour }); const utcDate = currentTime.toISOString().substr(0, 10); - const localDate = currentTime.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); + const localTimeOpts = { hour12: use12Hour }; + const localDateOpts = { weekday: 'short', month: 'short', day: 'numeric' }; + if (config.timezone) { + localTimeOpts.timeZone = config.timezone; + localDateOpts.timeZone = config.timezone; + } + const localTime = currentTime.toLocaleTimeString('en-US', localTimeOpts); + const localDate = currentTime.toLocaleDateString('en-US', localDateOpts); // Scale for small screens const [scale, setScale] = useState(1); diff --git a/src/components/PSKReporterPanel.jsx b/src/components/PSKReporterPanel.jsx index 3c380d7..a092628 100644 --- a/src/components/PSKReporterPanel.jsx +++ b/src/components/PSKReporterPanel.jsx @@ -1,7 +1,11 @@ /** - * PSKReporter Panel - * Shows where your digital mode signals are being received - * Toggles between PSKReporter (internet) and WSJT-X (local UDP) views + * PSKReporter + WSJT-X Panel + * Digital mode spots β€” toggle between internet (PSKReporter) and local (WSJT-X UDP) + * + * Layout: + * Row 1: Segmented mode toggle | map + filter controls + * Row 2: Sub-tabs (Being Heard / Hearing or Decodes / QSOs) + * Content: Scrolling spot/decode list */ import React, { useState, useMemo } from 'react'; import { usePSKReporter } from '../hooks/usePSKReporter.js'; @@ -25,36 +29,28 @@ const PSKReporterPanel = ({ showWSJTXOnMap, onToggleWSJTXMap }) => { - const [panelMode, setPanelMode] = useState('psk'); // 'psk' | 'wsjtx' - const [activeTab, setActiveTab] = useState('tx'); // PSK: tx | rx - const [wsjtxTab, setWsjtxTab] = useState('decodes'); // WSJT-X: decodes | qsos - const [bandFilter, setBandFilter] = useState('all'); - const [showCQ, setShowCQ] = useState(false); + const [panelMode, setPanelMode] = useState('psk'); + const [activeTab, setActiveTab] = useState('tx'); + const [wsjtxTab, setWsjtxTab] = useState('decodes'); + const [wsjtxFilter, setWsjtxFilter] = useState('all'); // 'all' | 'cq' | band name + // PSKReporter hook const { - txReports, - txCount, - rxReports, - rxCount, - loading, - error, - connected, - source, - refresh + txReports, txCount, rxReports, rxCount, + loading, error, connected, source, refresh } = usePSKReporter(callsign, { minutes: 15, enabled: callsign && callsign !== 'N0CALL' }); - // PSK filter logic + // ── PSK filtering ── const filterReports = (reports) => { return reports.filter(r => { if (filters?.bands?.length && !filters.bands.includes(r.band)) return false; 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; + if (!filters.grids.includes(grid.substring(0, 2).toUpperCase())) return false; } if (filters?.modes?.length && !filters.modes.includes(r.mode)) return false; return true; @@ -64,401 +60,378 @@ const PSKReporterPanel = ({ const filteredTx = useMemo(() => filterReports(txReports), [txReports, filters, activeTab]); const filteredRx = useMemo(() => filterReports(rxReports), [rxReports, filters, activeTab]); const filteredReports = activeTab === 'tx' ? filteredTx : filteredRx; + const pskFilterCount = [filters?.bands?.length, filters?.grids?.length, filters?.modes?.length].filter(Boolean).length; - 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(); - - const getFreqColor = (freqMHz) => { - if (!freqMHz) return 'var(--text-muted)'; - return getBandColor(parseFloat(freqMHz)); - }; - - const formatAge = (minutes) => { - if (minutes < 1) return 'now'; - if (minutes < 60) return `${minutes}m`; - return `${Math.floor(minutes/60)}h`; - }; - - const getStatusIndicator = () => { - if (connected) return ● LIVE; - if (source === 'connecting' || source === 'reconnecting') return ◐ {source}; - if (error) return ● offline; - return null; - }; + const getFreqColor = (freqMHz) => !freqMHz ? 'var(--text-muted)' : getBandColor(parseFloat(freqMHz)); + const formatAge = (m) => m < 1 ? 'now' : m < 60 ? `${m}m` : `${Math.floor(m/60)}h`; - // WSJT-X helpers + // ── WSJT-X helpers ── const activeClients = Object.entries(wsjtxClients); - const primaryClient = activeClients.length > 0 ? activeClients[0][1] : null; + const primaryClient = activeClients[0]?.[1] || null; - const wsjtxBands = useMemo(() => { - const bands = new Set(wsjtxDecodes.map(d => d.band).filter(Boolean)); - return ['all', ...Array.from(bands).sort((a, b) => (parseInt(b) || 999) - (parseInt(a) || 999))]; + // Build unified filter options: All, CQ Only, then each available band + const wsjtxFilterOptions = useMemo(() => { + const bands = [...new Set(wsjtxDecodes.map(d => d.band).filter(Boolean))] + .sort((a, b) => (parseInt(b) || 999) - (parseInt(a) || 999)); + return [ + { value: 'all', label: 'All decodes' }, + { value: 'cq', label: 'CQ only' }, + ...bands.map(b => ({ value: b, label: b })) + ]; }, [wsjtxDecodes]); const filteredDecodes = useMemo(() => { let filtered = [...wsjtxDecodes]; - if (bandFilter !== 'all') filtered = filtered.filter(d => d.band === bandFilter); - if (showCQ) filtered = filtered.filter(d => d.type === 'CQ'); + if (wsjtxFilter === 'cq') { + filtered = filtered.filter(d => d.type === 'CQ'); + } else if (wsjtxFilter !== 'all') { + // Band filter + filtered = filtered.filter(d => d.band === wsjtxFilter); + } return filtered.reverse(); - }, [wsjtxDecodes, bandFilter, showCQ]); + }, [wsjtxDecodes, wsjtxFilter]); const getSnrColor = (snr) => { - if (snr === null || snr === undefined) return 'var(--text-muted)'; + if (snr == null) return 'var(--text-muted)'; if (snr >= 0) return '#4ade80'; if (snr >= -10) return '#fbbf24'; if (snr >= -18) return '#fb923c'; return '#ef4444'; }; - const getMsgColor = (decode) => { - if (decode.type === 'CQ') return '#60a5fa'; - if (decode.exchange === 'RR73' || decode.exchange === '73' || decode.exchange === 'RRR') return '#4ade80'; - if (decode.exchange?.startsWith('R')) return '#fbbf24'; + const getMsgColor = (d) => { + if (d.type === 'CQ') return '#60a5fa'; + if (['RR73', '73', 'RRR'].includes(d.exchange)) return '#4ade80'; + if (d.exchange?.startsWith('R')) return '#fbbf24'; return 'var(--text-primary)'; }; - // Mode switch button style - const modeBtn = (mode, color) => ({ - padding: '2px 8px', - background: panelMode === mode ? `${color}22` : 'transparent', - border: `1px solid ${panelMode === mode ? color : 'var(--border-color)'}`, - color: panelMode === mode ? color : 'var(--text-muted)', + // Active map toggle for current mode + const isMapOn = panelMode === 'psk' ? showOnMap : showWSJTXOnMap; + const handleMapToggle = panelMode === 'psk' ? onToggleMap : onToggleWSJTXMap; + + // Compact status dot + const statusDot = connected + ? { color: '#4ade80', char: '●' } + : (source === 'connecting' || source === 'reconnecting') + ? { color: '#fbbf24', char: '◐' } + : error ? { color: '#ef4444', char: '●' } : null; + + // ── Shared styles ── + const segBtn = (active, color) => ({ + padding: '3px 10px', + background: active ? `${color}18` : 'transparent', + color: active ? color : 'var(--text-muted)', + border: 'none', + borderBottom: active ? `2px solid ${color}` : '2px solid transparent', + fontSize: '11px', + fontWeight: active ? '700' : '400', + cursor: 'pointer', + letterSpacing: '0.02em', + }); + + const subTabBtn = (active, color) => ({ + flex: 1, + padding: '3px 4px', + background: active ? `${color}20` : 'transparent', + border: `1px solid ${active ? color + '66' : 'var(--border-color)'}`, + borderRadius: '3px', + color: active ? color : 'var(--text-muted)', + cursor: 'pointer', + fontSize: '10px', + fontWeight: active ? '600' : '400', + }); + + const iconBtn = (active, activeColor = '#4488ff') => ({ + background: active ? `${activeColor}30` : 'rgba(100,100,100,0.3)', + border: `1px solid ${active ? activeColor : '#555'}`, + color: active ? activeColor : '#777', + padding: '2px 6px', borderRadius: '3px', fontSize: '10px', cursor: 'pointer', - fontWeight: panelMode === mode ? '700' : '400', + lineHeight: 1, }); return (
- {/* Mode switcher header */} + {/* ── Row 1: Mode toggle + controls ── */}
-
- -
- - {/* Controls row - differs per mode */} + + {/* Right controls */}
+ {/* PSK: status dot + filter + refresh */} {panelMode === 'psk' && ( <> - - {filteredReports.length}/{activeTab === 'tx' ? txCount : rxCount} - - {getStatusIndicator()} - + {statusDot && ( + {statusDot.char} + )} + - {onToggleMap && ( - - )} )} + + {/* WSJT-X: mode/band info + unified filter */} {panelMode === 'wsjtx' && ( <> {primaryClient && ( - - {primaryClient.mode || ''} {primaryClient.band || ''} - {primaryClient.transmitting && TX} - {primaryClient.decoding && RX} + + {primaryClient.mode} {primaryClient.band} + {primaryClient.transmitting && TX} )} - setWsjtxFilter(e.target.value)} + style={{ + background: 'var(--bg-tertiary)', + color: wsjtxFilter !== 'all' ? '#a78bfa' : 'var(--text-primary)', + border: `1px solid ${wsjtxFilter !== 'all' ? '#a78bfa55' : 'var(--border-color)'}`, + borderRadius: '3px', + fontSize: '10px', + padding: '1px 4px', + cursor: 'pointer', + maxWidth: '90px', + }} + > + {wsjtxFilterOptions.map(o => ( + + ))} - - {onToggleWSJTXMap && ( - - )} )} + + {/* Map toggle (always visible) */} + {handleMapToggle && ( + + )}
- {/* === PSKReporter View === */} - {panelMode === 'psk' && ( - <> - {(!callsign || callsign === 'N0CALL') ? ( -
- Set callsign in Settings -
- ) : ( - <> - {/* PSK Tabs */} -
- - -
- - {/* PSK Reports list */} - {error && !connected ? ( -
- ⚠️ Connection failed - click πŸ”„ to retry -
- ) : loading && filteredReports.length === 0 && filterCount === 0 ? ( -
-
- Connecting to MQTT... -
- ) : !connected && filteredReports.length === 0 && filterCount === 0 ? ( -
- Waiting for connection... -
- ) : filteredReports.length === 0 ? ( -
- {filterCount > 0 - ? 'No spots match filters' - : activeTab === 'tx' - ? 'Waiting for spots... (TX to see reports)' - : 'No stations heard yet'} -
- ) : ( -
- {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; - const grid = activeTab === 'tx' ? report.receiverGrid : report.senderGrid; - - return ( -
onShowOnMap && report.lat && report.lon && onShowOnMap(report)} - style={{ - display: 'grid', gridTemplateColumns: '55px 1fr auto', - gap: '6px', padding: '4px 6px', borderRadius: '3px', marginBottom: '2px', - background: i % 2 === 0 ? 'rgba(255,255,255,0.03)' : 'transparent', - cursor: report.lat && report.lon ? 'pointer' : 'default', - transition: 'background 0.15s', borderLeft: '2px solid transparent' - }} - onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(68, 136, 255, 0.15)'} - onMouseLeave={(e) => e.currentTarget.style.background = i % 2 === 0 ? 'rgba(255,255,255,0.03)' : 'transparent'} - > -
{freqMHz}
-
- {displayCall} - {grid && {grid}} -
-
- {report.mode} - {report.snr !== null && report.snr !== undefined && ( - = 0 ? '#4ade80' : report.snr >= -10 ? '#fbbf24' : '#f97316', fontWeight: '600' }}> - {report.snr > 0 ? '+' : ''}{report.snr} - - )} - {formatAge(report.age)} -
-
- ); - })} -
- )} - - )} - - )} + {/* ── Row 2: Sub-tabs ── */} +
+ {panelMode === 'psk' ? ( + <> + + + + ) : ( + <> + + + + )} +
- {/* === WSJT-X View === */} - {panelMode === 'wsjtx' && ( - <> - {/* WSJT-X Tabs */} -
- {[ - { key: 'decodes', label: `Decodes (${wsjtxDecodes.length})` }, - { key: 'qsos', label: `QSOs (${wsjtxQsos.length})` }, - ].map(tab => ( - - ))} -
+ {/* ── Content area ── */} +
- {/* No WSJT-X connected */} - {!wsjtxLoading && activeClients.length === 0 && wsjtxDecodes.length === 0 ? ( -
-
Waiting for WSJT-X...
-
- Settings β†’ Reporting β†’ UDP Server -
- Address: {'{server IP}'}   Port: {wsjtxPort || 2237} + {/* === PSKReporter content === */} + {panelMode === 'psk' && ( + <> + {(!callsign || callsign === 'N0CALL') ? ( +
+ Set your callsign in Settings to see reports
-
- ) : ( - <> - {/* Decodes / QSOs content */} + ) : error && !connected ? ( +
+ Connection failed β€” tap πŸ”„ +
+ ) : loading && filteredReports.length === 0 && pskFilterCount === 0 ? ( +
+
+ Connecting... +
+ ) : filteredReports.length === 0 ? ( +
+ {pskFilterCount > 0 + ? 'No spots match filters' + : activeTab === 'tx' + ? 'Waiting for spots... (TX to see reports)' + : 'No stations heard yet'} +
+ ) : ( + filteredReports.slice(0, 25).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; + const grid = activeTab === 'tx' ? report.receiverGrid : report.senderGrid; + + return ( +
onShowOnMap?.(report)} + style={{ + display: 'grid', + gridTemplateColumns: '52px 1fr auto', + gap: '5px', + padding: '3px 4px', + borderRadius: '2px', + background: i % 2 === 0 ? 'rgba(255,255,255,0.02)' : 'transparent', + cursor: report.lat && report.lon ? 'pointer' : 'default', + }} + onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(68,136,255,0.12)'} + onMouseLeave={(e) => e.currentTarget.style.background = i % 2 === 0 ? 'rgba(255,255,255,0.02)' : 'transparent'} + > + {freqMHz} + + {displayCall} + {grid && {grid}} + + + {report.mode} + {report.snr != null && ( + = 0 ? '#4ade80' : report.snr >= -10 ? '#fbbf24' : '#f97316', fontWeight: '600' }}> + {report.snr > 0 ? '+' : ''}{report.snr} + + )} + {formatAge(report.age)} + +
+ ); + }) + )} + + )} + + {/* === WSJT-X content === */} + {panelMode === 'wsjtx' && ( + <> + {/* No client connected */} + {!wsjtxLoading && activeClients.length === 0 && wsjtxDecodes.length === 0 ? (
- {wsjtxTab === 'decodes' && ( - <> - {filteredDecodes.length === 0 ? ( -
- {wsjtxDecodes.length > 0 ? 'No decodes match filter' : 'Listening for decodes...'} -
- ) : ( - filteredDecodes.map((d, i) => ( -
- {d.time} - - {d.snr != null ? (d.snr >= 0 ? `+${d.snr}` : d.snr) : ''} - - {d.dt} - {d.freq} - {d.message} -
- )) - )} - +
Waiting for WSJT-X...
+
+ In WSJT-X: Settings β†’ Reporting β†’ UDP Server +
+ Address: 127.0.0.1   Port: {wsjtxPort || 2237} +
+
+ ) : wsjtxTab === 'decodes' ? ( + <> + {filteredDecodes.length === 0 ? ( +
+ {wsjtxDecodes.length > 0 ? 'No decodes match filter' : 'Listening...'} +
+ ) : ( + filteredDecodes.map((d, i) => ( +
+ {d.time} + + {d.snr != null ? (d.snr >= 0 ? `+${d.snr}` : d.snr) : ''} + + {d.dt} + {d.freq} + {d.message} +
+ )) )} - - {wsjtxTab === 'qsos' && ( - <> - {wsjtxQsos.length === 0 ? ( -
- No QSOs logged yet -
- ) : ( - [...wsjtxQsos].reverse().map((q, i) => ( -
- - {q.dxCall} - - {q.band} - {q.mode} - {q.reportSent}/{q.reportRecv} - {q.dxGrid && {q.dxGrid}} -
- )) - )} - + + ) : ( + /* QSOs tab */ + <> + {wsjtxQsos.length === 0 ? ( +
+ No QSOs logged yet +
+ ) : ( + [...wsjtxQsos].reverse().map((q, i) => ( +
+ {q.dxCall} + {q.band} + {q.mode} + {q.reportSent}/{q.reportRecv} + {q.dxGrid && {q.dxGrid}} +
+ )) )} -
+ + )} + + )} +
- {/* WSJT-X status bar */} - {activeClients.length > 0 && ( -
- - {activeClients.map(([id, c]) => `${id}${c.version ? ` v${c.version}` : ''}`).join(', ')} - - {primaryClient?.dialFrequency && ( - - {(primaryClient.dialFrequency / 1000000).toFixed(6)} MHz - - )} -
- )} - + {/* ── WSJT-X status footer ── */} + {panelMode === 'wsjtx' && activeClients.length > 0 && ( +
+ {activeClients.map(([id, c]) => `${id}${c.version ? ` v${c.version}` : ''}`).join(', ')} + {primaryClient?.dialFrequency && ( + {(primaryClient.dialFrequency / 1000000).toFixed(6)} MHz )} - +
)}
); }; export default PSKReporterPanel; - export { PSKReporterPanel }; diff --git a/src/components/SettingsPanel.jsx b/src/components/SettingsPanel.jsx index 63770fc..b0795bf 100644 --- a/src/components/SettingsPanel.jsx +++ b/src/components/SettingsPanel.jsx @@ -14,6 +14,7 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => { const [lon, setLon] = useState(config?.location?.lon || 0); const [theme, setTheme] = useState(config?.theme || 'dark'); const [layout, setLayout] = useState(config?.layout || 'modern'); + const [timezone, setTimezone] = useState(config?.timezone || ''); const [dxClusterSource, setDxClusterSource] = useState(config?.dxClusterSource || 'dxspider-proxy'); const { t, i18n } = useTranslation(); @@ -28,6 +29,7 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => { setLon(config.location?.lon || 0); setTheme(config.theme || 'dark'); setLayout(config.layout || 'modern'); + setTimezone(config.timezone || ''); setDxClusterSource(config.dxClusterSource || 'dxspider-proxy'); if (config.location?.lat && config.location?.lon) { setGridSquare(calculateGridSquare(config.location.lat, config.location.lon)); @@ -148,6 +150,7 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => { location: { lat: parseFloat(lat), lon: parseFloat(lon) }, theme, layout, + timezone, dxClusterSource }); onClose(); @@ -451,6 +454,112 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
{/* DX Cluster Source */} +
+ + +
+ Set this if your local time shows incorrectly (e.g. same as UTC). + Privacy browsers like Librewolf may spoof your timezone. + {timezone ? '' : ' Currently using browser default.'} +
+
+ + {/* DX Cluster Source - original */}
) : wsjtxTab === 'decodes' ? ( <> diff --git a/wsjtx-relay/README.md b/wsjtx-relay/README.md new file mode 100644 index 0000000..cb0a3cc --- /dev/null +++ b/wsjtx-relay/README.md @@ -0,0 +1,118 @@ +# OpenHamClock WSJT-X Relay Agent + +Bridges your local WSJT-X instance to a remote OpenHamClock server. + +WSJT-X sends decoded FT8/FT4/JT65/WSPR messages via UDP, which only works on the local network. This relay agent captures those UDP packets on your machine and forwards them to your cloud-hosted OpenHamClock instance (e.g. openhamclock.com) over HTTPS. + +## How It Works + +``` +WSJT-X ──UDP──► relay.js (your PC) ──HTTPS──► openhamclock.com + port 2237 /api/wsjtx/relay +``` + +## Quick Start + +### 1. Get Your Relay Key + +On your OpenHamClock server, set the `WSJTX_RELAY_KEY` environment variable: + +```bash +# In .env file or docker-compose environment: +WSJTX_RELAY_KEY=your-secret-key-here +``` + +Pick any strong random string. This authenticates the relay so only your agent can push decodes to your server. + +### 2. Run the Relay + +On the machine running WSJT-X: + +```bash +# Download just this folder (or copy it from the repo) +node relay.js --url https://openhamclock.com --key your-secret-key-here +``` + +Or with environment variables: + +```bash +export OPENHAMCLOCK_URL=https://openhamclock.com +export RELAY_KEY=your-secret-key-here +node relay.js +``` + +### 3. Configure WSJT-X + +In WSJT-X: + +1. Go to **Settings β†’ Reporting** +2. Under **UDP Server**: + - Address: `127.0.0.1` + - Port: `2237` + - β˜‘ Accept UDP requests + +That's it. The relay will show decoded messages as they come in. + +## Requirements + +- **Node.js 14+** (no npm install needed β€” zero dependencies) +- WSJT-X, JTDX, or any software that speaks the WSJT-X UDP protocol +- Network access to your OpenHamClock server + +## Options + +| Flag | Env Variable | Default | Description | +|------|-------------|---------|-------------| +| `--url` | `OPENHAMCLOCK_URL` | β€” | Server URL (required) | +| `--key` | `RELAY_KEY` | β€” | Auth key (required) | +| `--port` | `WSJTX_UDP_PORT` | `2237` | Local UDP port | +| `--interval` | `BATCH_INTERVAL` | `2000` | Batch send interval (ms) | +| `--verbose` | `VERBOSE=true` | off | Show all decoded messages | + +## Running as a Service + +### Linux (systemd) + +```ini +# /etc/systemd/system/wsjtx-relay.service +[Unit] +Description=OpenHamClock WSJT-X Relay +After=network.target + +[Service] +ExecStart=/usr/bin/node /path/to/relay.js +Environment=OPENHAMCLOCK_URL=https://openhamclock.com +Environment=RELAY_KEY=your-secret-key +Restart=always +RestartSec=5 +User=your-username + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl enable --now wsjtx-relay +``` + +### Windows (Task Scheduler) + +Create a batch file `start-relay.bat`: +```batch +@echo off +set OPENHAMCLOCK_URL=https://openhamclock.com +set RELAY_KEY=your-secret-key +node C:\path\to\relay.js +``` + +Add it to Task Scheduler to run at login. + +## Troubleshooting + +**Port already in use**: Another program is listening on 2237. Use `--port 2238` and update WSJT-X to match. + +**Authentication failed**: Double-check that `WSJTX_RELAY_KEY` in your server .env matches the `--key` you're passing to the relay. + +**Connection errors**: The relay automatically retries with backoff. Check that your server URL is correct and accessible. + +**No decodes showing**: Make sure WSJT-X is set to UDP address `127.0.0.1` port `2237`, and that the "Accept UDP requests" checkbox is enabled. diff --git a/wsjtx-relay/package.json b/wsjtx-relay/package.json new file mode 100644 index 0000000..fbf1954 --- /dev/null +++ b/wsjtx-relay/package.json @@ -0,0 +1,14 @@ +{ + "name": "openhamclock-wsjtx-relay", + "version": "1.0.0", + "description": "Relay WSJT-X UDP decodes to a remote OpenHamClock server", + "main": "relay.js", + "scripts": { + "start": "node relay.js" + }, + "keywords": ["wsjt-x", "ham-radio", "ft8", "relay", "openhamclock"], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } +} diff --git a/wsjtx-relay/relay.js b/wsjtx-relay/relay.js new file mode 100644 index 0000000..9a4b786 --- /dev/null +++ b/wsjtx-relay/relay.js @@ -0,0 +1,474 @@ +#!/usr/bin/env node +/** + * OpenHamClock WSJT-X Relay Agent + * + * Captures WSJT-X UDP datagrams on your local machine and relays + * decoded messages to a remote OpenHamClock instance (e.g. openhamclock.com). + * + * WSJT-X sends UDP only on the local network β€” this bridge lets your + * cloud-hosted dashboard see your decodes in real time. + * + * Zero dependencies β€” uses only Node.js built-in modules. + * + * Usage: + * node relay.js --url https://openhamclock.com --key YOUR_RELAY_KEY + * + * Or with environment variables: + * OPENHAMCLOCK_URL=https://openhamclock.com RELAY_KEY=abc123 node relay.js + * + * In WSJT-X: Settings β†’ Reporting β†’ UDP Server + * Address: 127.0.0.1 Port: 2237 + */ + +const dgram = require('dgram'); +const http = require('http'); +const https = require('https'); +const { URL } = require('url'); + +// ============================================ +// CONFIGURATION +// ============================================ + +function parseArgs() { + const args = process.argv.slice(2); + const config = { + url: process.env.OPENHAMCLOCK_URL || '', + key: process.env.RELAY_KEY || process.env.OPENHAMCLOCK_RELAY_KEY || '', + port: parseInt(process.env.WSJTX_UDP_PORT || '2237'), + batchInterval: parseInt(process.env.BATCH_INTERVAL || '2000'), + verbose: process.env.VERBOSE === 'true', + }; + + for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case '--url': case '-u': config.url = args[++i]; break; + case '--key': case '-k': config.key = args[++i]; break; + case '--port': case '-p': config.port = parseInt(args[++i]); break; + case '--interval': case '-i': config.batchInterval = parseInt(args[++i]); break; + case '--verbose': case '-v': config.verbose = true; break; + case '--help': case '-h': + console.log(` +OpenHamClock WSJT-X Relay Agent + +Captures WSJT-X UDP on your local machine and forwards decodes to +a remote OpenHamClock server. + +Options: + --url, -u OpenHamClock server URL (required) + --key, -k Relay authentication key (required) + --port, -p Local UDP port to listen on (default: 2237) + --interval, -i Batch send interval in ms (default: 2000) + --verbose, -v Show all decoded messages + --help, -h Show this help + +Environment variables: + OPENHAMCLOCK_URL Same as --url + RELAY_KEY Same as --key + WSJTX_UDP_PORT Same as --port + BATCH_INTERVAL Same as --interval + VERBOSE Set to 'true' for verbose output + +Example: + node relay.js --url https://openhamclock.com --key mySecretKey123 +`); + process.exit(0); + } + } + + return config; +} + +const config = parseArgs(); + +// Validate +if (!config.url) { + console.error('❌ Error: --url is required (e.g. --url https://openhamclock.com)'); + console.error(' Run with --help for usage info'); + process.exit(1); +} +if (!config.key) { + console.error('❌ Error: --key is required (set WSJTX_RELAY_KEY in your server .env)'); + console.error(' Run with --help for usage info'); + process.exit(1); +} + +// Normalize URL +const serverUrl = config.url.replace(/\/$/, ''); +const relayEndpoint = `${serverUrl}/api/wsjtx/relay`; + +// ============================================ +// WSJT-X BINARY PROTOCOL PARSER +// ============================================ + +const WSJTX_MAGIC = 0xADBCCBDA; + +const WSJTX_MSG = { + HEARTBEAT: 0, STATUS: 1, DECODE: 2, CLEAR: 3, + REPLY: 4, QSO_LOGGED: 5, CLOSE: 6, REPLAY: 7, + HALT_TX: 8, FREE_TEXT: 9, WSPR_DECODE: 10, + LOCATION: 11, LOGGED_ADIF: 12, +}; + +class WSJTXReader { + constructor(buffer) { this.buf = buffer; this.offset = 0; } + remaining() { return this.buf.length - this.offset; } + + readUInt8() { + if (this.remaining() < 1) return null; + return this.buf.readUInt8(this.offset++); + } + readInt32() { + if (this.remaining() < 4) return null; + const v = this.buf.readInt32BE(this.offset); this.offset += 4; return v; + } + readUInt32() { + if (this.remaining() < 4) return null; + const v = this.buf.readUInt32BE(this.offset); this.offset += 4; return v; + } + readUInt64() { + if (this.remaining() < 8) return null; + const hi = this.buf.readUInt32BE(this.offset); + const lo = this.buf.readUInt32BE(this.offset + 4); + this.offset += 8; + return hi * 0x100000000 + lo; + } + readBool() { const v = this.readUInt8(); return v === null ? null : v !== 0; } + readDouble() { + if (this.remaining() < 8) return null; + const v = this.buf.readDoubleBE(this.offset); this.offset += 8; return v; + } + readUtf8() { + const len = this.readUInt32(); + if (len === null || len === 0xFFFFFFFF) return null; + if (len === 0) return ''; + if (this.remaining() < len) return null; + const str = this.buf.toString('utf8', this.offset, this.offset + len); + this.offset += len; return str; + } + readQTime() { + const ms = this.readUInt32(); + if (ms === null) return null; + const h = Math.floor(ms / 3600000); + const m = Math.floor((ms % 3600000) / 60000); + const s = Math.floor((ms % 60000) / 1000); + return { ms, hours: h, minutes: m, seconds: s, + formatted: `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}` }; + } + readQDateTime() { + const julianDay = this.readUInt64(); + const time = this.readQTime(); + const timeSpec = this.readUInt8(); + if (timeSpec === 2) this.readInt32(); + return { julianDay, time, timeSpec }; + } +} + +function parseWSJTXMessage(buffer) { + const reader = new WSJTXReader(buffer); + const magic = reader.readUInt32(); + if (magic !== WSJTX_MAGIC) return null; + + const schema = reader.readUInt32(); + const type = reader.readUInt32(); + const id = reader.readUtf8(); + if (type === null || id === null) return null; + + const msg = { type, id, schema, timestamp: Date.now() }; + + try { + switch (type) { + case WSJTX_MSG.HEARTBEAT: + msg.maxSchema = reader.readUInt32(); + msg.version = reader.readUtf8(); + msg.revision = reader.readUtf8(); + break; + case WSJTX_MSG.STATUS: + msg.dialFrequency = reader.readUInt64(); + msg.mode = reader.readUtf8(); + msg.dxCall = reader.readUtf8(); + msg.report = reader.readUtf8(); + msg.txMode = reader.readUtf8(); + msg.txEnabled = reader.readBool(); + msg.transmitting = reader.readBool(); + msg.decoding = reader.readBool(); + msg.rxDF = reader.readUInt32(); + msg.txDF = reader.readUInt32(); + msg.deCall = reader.readUtf8(); + msg.deGrid = reader.readUtf8(); + msg.dxGrid = reader.readUtf8(); + msg.txWatchdog = reader.readBool(); + msg.subMode = reader.readUtf8(); + msg.fastMode = reader.readBool(); + msg.specialOp = reader.readUInt8(); + msg.freqTolerance = reader.readUInt32(); + msg.trPeriod = reader.readUInt32(); + msg.configName = reader.readUtf8(); + msg.txMessage = reader.readUtf8(); + break; + case WSJTX_MSG.DECODE: + msg.isNew = reader.readBool(); + msg.time = reader.readQTime(); + msg.snr = reader.readInt32(); + msg.deltaTime = reader.readDouble(); + msg.deltaFreq = reader.readUInt32(); + msg.mode = reader.readUtf8(); + msg.message = reader.readUtf8(); + msg.lowConfidence = reader.readBool(); + msg.offAir = reader.readBool(); + break; + case WSJTX_MSG.CLEAR: + msg.window = reader.readUInt8(); + break; + case WSJTX_MSG.QSO_LOGGED: + msg.dateTimeOff = reader.readQDateTime(); + msg.dxCall = reader.readUtf8(); + msg.dxGrid = reader.readUtf8(); + msg.txFrequency = reader.readUInt64(); + msg.mode = reader.readUtf8(); + msg.reportSent = reader.readUtf8(); + msg.reportRecv = reader.readUtf8(); + msg.txPower = reader.readUtf8(); + msg.comments = reader.readUtf8(); + msg.name = reader.readUtf8(); + msg.dateTimeOn = reader.readQDateTime(); + msg.operatorCall = reader.readUtf8(); + msg.myCall = reader.readUtf8(); + msg.myGrid = reader.readUtf8(); + msg.exchangeSent = reader.readUtf8(); + msg.exchangeRecv = reader.readUtf8(); + msg.adifPropMode = reader.readUtf8(); + break; + case WSJTX_MSG.WSPR_DECODE: + msg.isNew = reader.readBool(); + msg.time = reader.readQTime(); + msg.snr = reader.readInt32(); + msg.deltaTime = reader.readDouble(); + msg.frequency = reader.readUInt64(); + msg.drift = reader.readInt32(); + msg.callsign = reader.readUtf8(); + msg.grid = reader.readUtf8(); + msg.power = reader.readInt32(); + msg.offAir = reader.readBool(); + break; + case WSJTX_MSG.LOGGED_ADIF: + msg.adif = reader.readUtf8(); + break; + case WSJTX_MSG.CLOSE: + break; + default: + return null; + } + } catch (e) { return null; } + + return msg; +} + +// ============================================ +// MESSAGE QUEUE & RELAY +// ============================================ + +let messageQueue = []; +let sendInFlight = false; +let consecutiveErrors = 0; +let totalSent = 0; +let totalDecodes = 0; + +const MSG_TYPE_NAMES = { + 0: 'Heartbeat', 1: 'Status', 2: 'Decode', 3: 'Clear', + 5: 'QSO Logged', 6: 'Close', 10: 'WSPR', +}; + +function queueMessage(msg) { + messageQueue.push(msg); + + if (config.verbose && msg.type === WSJTX_MSG.DECODE) { + const snr = msg.snr != null ? (msg.snr >= 0 ? `+${msg.snr}` : msg.snr) : '?'; + console.log(` πŸ“» ${msg.time?.formatted || '??'} ${snr}dB ${msg.deltaFreq}Hz ${msg.message}`); + } +} + +function sendBatch() { + if (sendInFlight || messageQueue.length === 0) return; + + const batch = messageQueue.splice(0, messageQueue.length); + sendInFlight = true; + + const body = JSON.stringify({ messages: batch }); + const parsed = new URL(relayEndpoint); + const transport = parsed.protocol === 'https:' ? https : http; + + const reqOpts = { + hostname: parsed.hostname, + port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), + path: parsed.pathname, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + 'Authorization': `Bearer ${config.key}`, + 'X-Relay-Version': '1.0.0', + }, + timeout: 10000, + }; + + const req = transport.request(reqOpts, (res) => { + let data = ''; + res.on('data', (chunk) => data += chunk); + res.on('end', () => { + sendInFlight = false; + + if (res.statusCode === 200) { + consecutiveErrors = 0; + totalSent += batch.length; + const decodes = batch.filter(m => m.type === WSJTX_MSG.DECODE).length; + if (decodes > 0 || config.verbose) { + process.stdout.write(` βœ… Relayed ${batch.length} msg${batch.length > 1 ? 's' : ''} (${decodes} decode${decodes !== 1 ? 's' : ''}) β€” total: ${totalSent}\r`); + } + } else if (res.statusCode === 401 || res.statusCode === 403) { + console.error(`\n ❌ Authentication failed (${res.statusCode}) β€” check your relay key`); + console.error(` Server: ${serverUrl}`); + consecutiveErrors++; + } else { + console.error(`\n ⚠️ Server returned ${res.statusCode}: ${data.substring(0, 100)}`); + consecutiveErrors++; + // Re-queue on server error + if (res.statusCode >= 500) messageQueue.unshift(...batch); + } + }); + }); + + req.on('error', (err) => { + sendInFlight = false; + consecutiveErrors++; + // Re-queue on network error + messageQueue.unshift(...batch); + + if (consecutiveErrors <= 3 || consecutiveErrors % 10 === 0) { + console.error(`\n ⚠️ Connection error (attempt ${consecutiveErrors}): ${err.message}`); + } + }); + + req.on('timeout', () => { + req.destroy(); + sendInFlight = false; + consecutiveErrors++; + messageQueue.unshift(...batch); + }); + + req.write(body); + req.end(); +} + +// Adaptive batch interval β€” back off on errors +function getInterval() { + if (consecutiveErrors === 0) return config.batchInterval; + if (consecutiveErrors < 5) return config.batchInterval * 2; + if (consecutiveErrors < 20) return 10000; // 10s + return 30000; // 30s max backoff +} + +let batchTimer = null; +function scheduleBatch() { + if (batchTimer) clearTimeout(batchTimer); + batchTimer = setTimeout(() => { + sendBatch(); + scheduleBatch(); + }, getInterval()); +} + +// ============================================ +// UDP LISTENER +// ============================================ + +const socket = dgram.createSocket('udp4'); + +socket.on('message', (buf, rinfo) => { + const msg = parseWSJTXMessage(buf); + if (!msg) return; + + // Track decodes for local stats + if (msg.type === WSJTX_MSG.DECODE && msg.isNew) totalDecodes++; + + // Queue for relay β€” skip REPLAY type (bulk replay request) + if (msg.type !== 7) { // REPLAY + queueMessage(msg); + } +}); + +socket.on('error', (err) => { + if (err.code === 'EADDRINUSE') { + console.error(`\n❌ Port ${config.port} is already in use.`); + console.error(' Is another WSJT-X listener running? (e.g. local OpenHamClock, JTAlert)'); + console.error(' Try a different port: node relay.js --port 2238'); + console.error(' Then update WSJT-X to match.'); + } else { + console.error(`\n❌ UDP error: ${err.message}`); + } + process.exit(1); +}); + +socket.on('listening', () => { + const addr = socket.address(); + + console.log(''); + console.log('╔══════════════════════════════════════════════╗'); + console.log('β•‘ OpenHamClock WSJT-X Relay Agent v1.0.0 β•‘'); + console.log('β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•'); + console.log(''); + console.log(` 🎧 Listening for WSJT-X on UDP ${addr.address}:${addr.port}`); + console.log(` 🌐 Relaying to ${serverUrl}`); + console.log(` ⏱️ Batch interval: ${config.batchInterval}ms`); + console.log(''); + console.log(' Configure WSJT-X:'); + console.log(` Settings β†’ Reporting β†’ UDP Server`); + console.log(` Address: 127.0.0.1 Port: ${config.port}`); + console.log(' β˜‘ Accept UDP requests (check this box)'); + console.log(''); + console.log(' Waiting for WSJT-X packets...'); + console.log(''); + + // Start batch relay loop + scheduleBatch(); + + // Periodic health check β€” verify server is reachable + setInterval(() => { + const parsed = new URL(`${serverUrl}/api/wsjtx`); + const transport = parsed.protocol === 'https:' ? https : http; + const req = transport.get(parsed.href, { timeout: 5000 }, (res) => { + if (res.statusCode === 200 && consecutiveErrors > 0) { + console.log('\n βœ… Server connection restored'); + consecutiveErrors = 0; + } + res.resume(); // consume response + }); + req.on('error', () => {}); // silent + req.on('timeout', () => req.destroy()); + }, 60000); // every minute +}); + +// Bind to all interfaces so WSJT-X can reach it from any address +socket.bind(config.port, '0.0.0.0'); + +// ============================================ +// GRACEFUL SHUTDOWN +// ============================================ + +function shutdown(sig) { + console.log(`\n\n ${sig} received β€” sending final batch...`); + if (messageQueue.length > 0) { + sendBatch(); + // Give it a moment to flush + setTimeout(() => { + console.log(` πŸ“Š Session stats: ${totalDecodes} decodes, ${totalSent} messages relayed`); + console.log(' 73!'); + process.exit(0); + }, 2000); + } else { + console.log(` πŸ“Š Session stats: ${totalDecodes} decodes, ${totalSent} messages relayed`); + console.log(' 73!'); + process.exit(0); + } +} + +process.on('SIGINT', () => shutdown('SIGINT')); +process.on('SIGTERM', () => shutdown('SIGTERM')); From 88216daac0f9a60de45eeb7e8e7e6997c090916d Mon Sep 17 00:00:00 2001 From: accius Date: Mon, 2 Feb 2026 21:51:05 -0500 Subject: [PATCH 4/4] wsjtx relay --- server.js | 128 ++++++++++++++++++++++++++++ src/components/PSKReporterPanel.jsx | 35 ++++++-- 2 files changed, 155 insertions(+), 8 deletions(-) diff --git a/server.js b/server.js index 70e09fd..9121886 100644 --- a/server.js +++ b/server.js @@ -4178,6 +4178,134 @@ app.post('/api/wsjtx/relay', (req, res) => { res.json({ ok: true, processed, timestamp: Date.now() }); }); +// API endpoint: download pre-configured relay agent script +// Embeds relay.js + server URL + relay key into a one-file launcher +app.get('/api/wsjtx/relay/download/:platform', (req, res) => { + if (!WSJTX_RELAY_KEY) { + return res.status(503).json({ error: 'Relay not configured β€” set WSJTX_RELAY_KEY in .env' }); + } + + const platform = req.params.platform; // 'linux', 'mac', or 'windows' + const relayJsPath = path.join(__dirname, 'wsjtx-relay', 'relay.js'); + + let relayJs; + try { + relayJs = fs.readFileSync(relayJsPath, 'utf8'); + } catch (e) { + return res.status(500).json({ error: 'relay.js not found on server' }); + } + + // Detect server URL from request + const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http'; + const host = req.headers['x-forwarded-host'] || req.headers.host; + const serverURL = proto + '://' + host; + + if (platform === 'linux' || platform === 'mac') { + // Build bash script with relay.js embedded as heredoc + const lines = [ + '#!/bin/bash', + '# OpenHamClock WSJT-X Relay β€” Auto-configured', + '# Generated by ' + serverURL, + '#', + '# Usage: bash ' + (platform === 'mac' ? 'start-relay.command' : 'start-relay.sh'), + '# Stop: Ctrl+C', + '# Requires: Node.js 14+ (https://nodejs.org)', + '#', + '# In WSJT-X: Settings > Reporting > UDP Server', + '# Address: 127.0.0.1 Port: 2237', + '', + 'set -e', + '', + '# Check for Node.js', + 'if ! command -v node &> /dev/null; then', + ' echo ""', + ' echo "Node.js is not installed."', + ' echo "Install from https://nodejs.org (LTS recommended)"', + ' echo ""', + ' echo "Quick install:"', + ' echo " Ubuntu/Debian: sudo apt install nodejs"', + ' echo " Mac (Homebrew): brew install node"', + ' echo " Fedora: sudo dnf install nodejs"', + ' echo ""', + ' exit 1', + 'fi', + '', + '# Write relay agent to temp file', + 'RELAY_FILE=$(mktemp /tmp/ohc-relay-XXXXXX.js)', + 'trap "rm -f $RELAY_FILE" EXIT', + '', + "cat > \"$RELAY_FILE\" << 'OPENHAMCLOCK_RELAY_EOF'", + relayJs, + 'OPENHAMCLOCK_RELAY_EOF', + '', + '# Run relay', + 'exec node "$RELAY_FILE" \\', + ' --url "' + serverURL + '" \\', + ' --key "' + WSJTX_RELAY_KEY + '"', + ]; + + const script = lines.join('\n') + '\n'; + const filename = platform === 'mac' ? 'start-relay.command' : 'start-relay.sh'; + res.setHeader('Content-Type', 'application/x-sh'); + res.setHeader('Content-Disposition', 'attachment; filename="' + filename + '"'); + return res.send(script); + + } else if (platform === 'windows') { + // Build PowerShell script with relay.js embedded + const escapedJs = relayJs.replace(/'/g, "''"); + + const lines = [ + '# OpenHamClock WSJT-X Relay - Auto-configured', + '# Generated by ' + serverURL, + '# Right-click > "Run with PowerShell" or run from terminal', + '# Requires: Node.js 14+ (https://nodejs.org)', + '#', + '# In WSJT-X: Settings > Reporting > UDP Server', + '# Address: 127.0.0.1 Port: 2237', + '', + '# Check for Node.js', + 'try {', + ' $nv = (node -v 2>$null)', + ' if (-not $nv) { throw "missing" }', + ' Write-Host "Found Node.js $nv" -ForegroundColor Green', + '} catch {', + ' Write-Host "Node.js is not installed." -ForegroundColor Red', + ' Write-Host "Download from https://nodejs.org (LTS version)" -ForegroundColor Yellow', + ' Read-Host "Press Enter to exit"', + ' exit 1', + '}', + '', + '# Write relay agent to temp file', + '$relayFile = Join-Path $env:TEMP "ohc-relay.js"', + '', + "$relayCode = @'", + escapedJs, + "'@", + '', + '$relayCode | Out-File -FilePath $relayFile -Encoding UTF8', + '', + 'Write-Host "Starting WSJT-X relay agent..." -ForegroundColor Cyan', + 'Write-Host "Press Ctrl+C to stop" -ForegroundColor DarkGray', + 'Write-Host ""', + '', + '# Run relay', + 'try {', + ' node $relayFile --url "' + serverURL + '" --key "' + WSJTX_RELAY_KEY + '"', + '} finally {', + ' Remove-Item $relayFile -ErrorAction SilentlyContinue', + '}', + ]; + + const script = lines.join('\r\n') + '\r\n'; + res.setHeader('Content-Type', 'application/x-powershell'); + res.setHeader('Content-Disposition', 'attachment; filename="start-relay.ps1"'); + return res.send(script); + + } else { + return res.status(400).json({ error: 'Invalid platform. Use: linux, mac, or windows' }); + } +}); + // ============================================ // CATCH-ALL FOR SPA // ============================================ diff --git a/src/components/PSKReporterPanel.jsx b/src/components/PSKReporterPanel.jsx index 7755499..5b5aac9 100644 --- a/src/components/PSKReporterPanel.jsx +++ b/src/components/PSKReporterPanel.jsx @@ -347,14 +347,33 @@ const PSKReporterPanel = ({ }}>
Waiting for WSJT-X...
{wsjtxRelayEnabled ? ( -
- Relay mode β€” run the relay agent locally: -
- - node relay.js --url {'{this server}'} --key {'{key}'} - -
- See wsjtx-relay/README.md +
+
+ Download the relay agent for your PC: +
+ +
+ Requires Node.js Β· Run the script, then start WSJT-X +
) : (