From 344cff675823b3ee7fb057273d4b00f7dac7e35f Mon Sep 17 00:00:00 2001 From: accius Date: Mon, 2 Feb 2026 22:24:12 -0500 Subject: [PATCH] wsjtx support enabled --- server.js | 134 ++++++++++++++++++++-------- src/App.jsx | 1 + src/components/PSKReporterPanel.jsx | 11 ++- src/hooks/useWSJTX.js | 32 ++++++- wsjtx-relay/relay.js | 13 ++- 5 files changed, 143 insertions(+), 48 deletions(-) diff --git a/server.js b/server.js index 01c9c76..fc0db61 100644 --- a/server.js +++ b/server.js @@ -3661,15 +3661,51 @@ const WSJTX_MSG = { CONFIGURE: 15, }; -// In-memory store +// In-memory store (for local UDP — no session) const wsjtxState = { clients: {}, // clientId -> { status, lastSeen } decodes: [], // decoded messages (ring buffer) qsos: [], // logged QSOs wspr: [], // WSPR decodes - relay: null, // { lastSeen, version, port } — set by relay heartbeat + relay: null, // not used for local UDP }; +// Per-session relay storage — each browser gets its own isolated data +const wsjtxRelaySessions = {}; // sessionId -> { clients, decodes, qsos, wspr, relay, lastAccess } +const WSJTX_SESSION_MAX_AGE = 60 * 60 * 1000; // 1 hour inactive expiry +const WSJTX_MAX_SESSIONS = 50; // prevent memory abuse + +function getRelaySession(sessionId) { + if (!sessionId) return null; + if (!wsjtxRelaySessions[sessionId]) { + // Check session limit + if (Object.keys(wsjtxRelaySessions).length >= WSJTX_MAX_SESSIONS) { + // Evict oldest session + let oldestId = null, oldestTime = Infinity; + for (const [id, s] of Object.entries(wsjtxRelaySessions)) { + if (s.lastAccess < oldestTime) { oldestTime = s.lastAccess; oldestId = id; } + } + if (oldestId) delete wsjtxRelaySessions[oldestId]; + } + wsjtxRelaySessions[sessionId] = { + clients: {}, decodes: [], qsos: [], wspr: [], + relay: null, lastAccess: Date.now() + }; + } + wsjtxRelaySessions[sessionId].lastAccess = Date.now(); + return wsjtxRelaySessions[sessionId]; +} + +// Cleanup expired sessions every 5 minutes +setInterval(() => { + const now = Date.now(); + for (const [id, session] of Object.entries(wsjtxRelaySessions)) { + if (now - session.lastAccess > WSJTX_SESSION_MAX_AGE) { + delete wsjtxRelaySessions[id]; + } + } +}, 5 * 60 * 1000); + /** * QDataStream binary reader for WSJT-X protocol * Reads big-endian Qt-serialized data types @@ -3938,14 +3974,17 @@ function freqToBand(freqHz) { /** * Handle incoming WSJT-X messages + * @param {Object} msg - parsed WSJT-X message + * @param {Object} state - state object to update (wsjtxState for local, session for relay) */ -function handleWSJTXMessage(msg) { +function handleWSJTXMessage(msg, state) { if (!msg) return; + if (!state) state = wsjtxState; switch (msg.type) { case WSJTX_MSG.HEARTBEAT: { - wsjtxState.clients[msg.id] = { - ...(wsjtxState.clients[msg.id] || {}), + state.clients[msg.id] = { + ...(state.clients[msg.id] || {}), version: msg.version, lastSeen: msg.timestamp }; @@ -3953,8 +3992,8 @@ function handleWSJTXMessage(msg) { } case WSJTX_MSG.STATUS: { - wsjtxState.clients[msg.id] = { - ...(wsjtxState.clients[msg.id] || {}), + state.clients[msg.id] = { + ...(state.clients[msg.id] || {}), lastSeen: msg.timestamp, dialFrequency: msg.dialFrequency, mode: msg.mode, @@ -3973,7 +4012,7 @@ function handleWSJTXMessage(msg) { } case WSJTX_MSG.DECODE: { - const clientStatus = wsjtxState.clients[msg.id] || {}; + const clientStatus = state.clients[msg.id] || {}; const parsed = parseDecodeMessage(msg.message); const decode = { @@ -4006,13 +4045,13 @@ function handleWSJTXMessage(msg) { // Only keep new decodes (not replays) if (msg.isNew) { - wsjtxState.decodes.push(decode); + state.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(); + while (state.decodes.length > WSJTX_MAX_DECODES || + (state.decodes.length > 0 && state.decodes[0].timestamp < cutoff)) { + state.decodes.shift(); } } break; @@ -4020,12 +4059,12 @@ function handleWSJTXMessage(msg) { 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); + state.decodes = state.decodes.filter(d => d.clientId !== msg.id); break; } case WSJTX_MSG.QSO_LOGGED: { - const clientStatus = wsjtxState.clients[msg.id] || {}; + const clientStatus = state.clients[msg.id] || {}; const qso = { clientId: msg.id, dxCall: msg.dxCall, @@ -4044,9 +4083,9 @@ function handleWSJTXMessage(msg) { const coords = gridToLatLon(msg.dxGrid); if (coords) { qso.lat = coords.latitude; qso.lon = coords.longitude; } } - wsjtxState.qsos.push(qso); + state.qsos.push(qso); // Keep last 50 QSOs - if (wsjtxState.qsos.length > 50) wsjtxState.qsos.shift(); + if (state.qsos.length > 50) state.qsos.shift(); break; } @@ -4065,14 +4104,14 @@ function handleWSJTXMessage(msg) { timestamp: msg.timestamp, }; if (msg.isNew) { - wsjtxState.wspr.push(wsprDecode); - if (wsjtxState.wspr.length > 100) wsjtxState.wspr.shift(); + state.wspr.push(wsprDecode); + if (state.wspr.length > 100) state.wspr.shift(); } break; } case WSJTX_MSG.CLOSE: { - delete wsjtxState.clients[msg.id]; + delete state.clients[msg.id]; break; } } @@ -4106,16 +4145,21 @@ if (WSJTX_ENABLED) { // API endpoint: get WSJT-X data app.get('/api/wsjtx', (req, res) => { + const sessionId = req.query.session || ''; + + // Use session-specific state for relay mode, or global state for local UDP + const state = (sessionId && WSJTX_RELAY_KEY) ? (wsjtxRelaySessions[sessionId] || { clients: {}, decodes: [], qsos: [], wspr: [], relay: null }) : wsjtxState; + const clients = {}; - for (const [id, client] of Object.entries(wsjtxState.clients)) { + for (const [id, client] of Object.entries(state.clients)) { // Only include clients seen in last 5 minutes if (Date.now() - client.lastSeen < 5 * 60 * 1000) { clients[id] = client; } } - // Relay is "connected" if seen in last 60 seconds - const relayConnected = wsjtxState.relay && (Date.now() - wsjtxState.relay.lastSeen < 60000); + // Relay is "connected" if this session's relay was seen in last 60 seconds + const relayConnected = state.relay && (Date.now() - state.relay.lastSeen < 60000); res.json({ enabled: WSJTX_ENABLED, @@ -4123,13 +4167,13 @@ app.get('/api/wsjtx', (req, res) => { relayEnabled: !!WSJTX_RELAY_KEY, relayConnected: !!relayConnected, clients, - decodes: wsjtxState.decodes.slice(-100), // last 100 - qsos: wsjtxState.qsos.slice(-20), // last 20 - wspr: wsjtxState.wspr.slice(-50), // last 50 + decodes: state.decodes.slice(-100), // last 100 + qsos: state.qsos.slice(-20), // last 20 + wspr: state.wspr.slice(-50), // last 50 stats: { - totalDecodes: wsjtxState.decodes.length, - totalQsos: wsjtxState.qsos.length, - totalWspr: wsjtxState.wspr.length, + totalDecodes: state.decodes.length, + totalQsos: state.qsos.length, + totalWspr: state.wspr.length, activeClients: Object.keys(clients).length, } }); @@ -4137,10 +4181,13 @@ app.get('/api/wsjtx', (req, res) => { // API endpoint: get just decodes (lightweight polling) app.get('/api/wsjtx/decodes', (req, res) => { + const sessionId = req.query.session || ''; + const state = (sessionId && WSJTX_RELAY_KEY) ? (wsjtxRelaySessions[sessionId] || { decodes: [] }) : wsjtxState; + const since = parseInt(req.query.since) || 0; const decodes = since - ? wsjtxState.decodes.filter(d => d.timestamp > since) - : wsjtxState.decodes.slice(-100); + ? state.decodes.filter(d => d.timestamp > since) + : state.decodes.slice(-100); res.json({ decodes, timestamp: Date.now() }); }); @@ -4160,9 +4207,17 @@ app.post('/api/wsjtx/relay', (req, res) => { return res.status(401).json({ error: 'Invalid relay key' }); } - // Relay heartbeat — just registers the relay as alive + // Session ID is required for relay — isolates data per browser + const sessionId = req.body.session || req.headers['x-relay-session'] || ''; + if (!sessionId) { + return res.status(400).json({ error: 'Session ID required' }); + } + + const session = getRelaySession(sessionId); + + // Relay heartbeat — just registers the relay as alive for this session if (req.body && req.body.relay === true) { - wsjtxState.relay = { + session.relay = { lastSeen: Date.now(), version: req.body.version || '1.0.0', port: req.body.port || 2237, @@ -4177,7 +4232,7 @@ app.post('/api/wsjtx/relay', (req, res) => { } // Update relay last seen on every batch too - wsjtxState.relay = { ...(wsjtxState.relay || {}), lastSeen: Date.now() }; + session.relay = { ...(session.relay || {}), lastSeen: Date.now() }; // Rate limit: max 100 messages per request const batch = messages.slice(0, 100); @@ -4189,7 +4244,7 @@ app.post('/api/wsjtx/relay', (req, res) => { if (!msg.timestamp || Math.abs(Date.now() - msg.timestamp) > 5 * 60 * 1000) { msg.timestamp = Date.now(); } - handleWSJTXMessage(msg); + handleWSJTXMessage(msg, session); processed++; } } @@ -4231,6 +4286,12 @@ app.get('/api/wsjtx/relay/download/:platform', (req, res) => { const host = req.headers['x-forwarded-host'] || req.headers.host; const serverURL = proto + '://' + host; + // Session ID from query param — ties this relay to the downloading browser + const sessionId = req.query.session || ''; + if (!sessionId) { + return res.status(400).json({ error: 'Session ID required — download from the OpenHamClock dashboard' }); + } + if (platform === 'linux' || platform === 'mac') { // Build bash script with relay.js embedded as heredoc const lines = [ @@ -4272,7 +4333,8 @@ app.get('/api/wsjtx/relay/download/:platform', (req, res) => { '# Run relay', 'exec node "$RELAY_FILE" \\', ' --url "' + serverURL + '" \\', - ' --key "' + WSJTX_RELAY_KEY + '"', + ' --key "' + WSJTX_RELAY_KEY + '" \\', + ' --session "' + sessionId + '"', ]; const script = lines.join('\n') + '\n'; @@ -4371,7 +4433,7 @@ app.get('/api/wsjtx/relay/download/:platform', (req, res) => { 'echo.', '', ':: Run relay', - '%NODE_EXE% "%TEMP%\\ohc-relay.js" --url "' + serverURL + '" --key "' + WSJTX_RELAY_KEY + '"', + '%NODE_EXE% "%TEMP%\\ohc-relay.js" --url "' + serverURL + '" --key "' + WSJTX_RELAY_KEY + '" --session "' + sessionId + '"', '', 'echo.', 'echo Relay stopped.', diff --git a/src/App.jsx b/src/App.jsx index 38f8170..bfa70f0 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -718,6 +718,7 @@ const App = () => { wsjtxPort={wsjtx.port} wsjtxRelayEnabled={wsjtx.relayEnabled} wsjtxRelayConnected={wsjtx.relayConnected} + wsjtxSessionId={wsjtx.sessionId} showWSJTXOnMap={mapLayers.showWSJTX} onToggleWSJTXMap={toggleWSJTX} /> diff --git a/src/components/PSKReporterPanel.jsx b/src/components/PSKReporterPanel.jsx index c96d657..c4193a6 100644 --- a/src/components/PSKReporterPanel.jsx +++ b/src/components/PSKReporterPanel.jsx @@ -28,6 +28,7 @@ const PSKReporterPanel = ({ wsjtxPort, wsjtxRelayEnabled, wsjtxRelayConnected, + wsjtxSessionId, showWSJTXOnMap, onToggleWSJTXMap }) => { @@ -355,9 +356,7 @@ const PSKReporterPanel = ({ Relay connected
- Waiting for WSJT-X decodes... -
- In WSJT-X: Settings → Reporting → UDP → 127.0.0.1:2237 + WSJT-X decodes will appear here when the station is active
) : ( @@ -366,19 +365,19 @@ const PSKReporterPanel = ({ Download the relay agent for your PC:
- 🐧 Linux - 🍎 Mac - = 16) return id; + // Generate a random ID + id = (typeof crypto !== 'undefined' && crypto.randomUUID) + ? crypto.randomUUID() + : Math.random().toString(36).substring(2) + Date.now().toString(36) + Math.random().toString(36).substring(2); + localStorage.setItem(KEY, id); + return id; + } catch { + // Fallback for privacy browsers that block localStorage + return Math.random().toString(36).substring(2) + Date.now().toString(36) + Math.random().toString(36).substring(2); + } +} + export function useWSJTX(enabled = true) { + const [sessionId] = useState(getSessionId); const [data, setData] = useState({ clients: {}, decodes: [], @@ -30,9 +51,11 @@ export function useWSJTX(enabled = true) { const pollDecodes = useCallback(async () => { if (!enabled) return; try { - const url = lastTimestamp.current + const base = lastTimestamp.current ? `${DECODES_URL}?since=${lastTimestamp.current}` : DECODES_URL; + const sep = base.includes('?') ? '&' : '?'; + const url = `${base}${sep}session=${sessionId}`; const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json = await res.json(); @@ -54,13 +77,13 @@ export function useWSJTX(enabled = true) { } catch (e) { // Silent fail for lightweight polls } - }, [enabled]); + }, [enabled, sessionId]); // Full fetch - get everything including status, QSOs, clients const fetchFull = useCallback(async () => { if (!enabled) return; try { - const res = await fetch(API_URL); + const res = await fetch(`${API_URL}?session=${sessionId}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json = await res.json(); setData(json); @@ -71,7 +94,7 @@ export function useWSJTX(enabled = true) { setError(e.message); setLoading(false); } - }, [enabled]); + }, [enabled, sessionId]); // Initial full fetch useEffect(() => { @@ -99,6 +122,7 @@ export function useWSJTX(enabled = true) { ...data, loading, error, + sessionId, refresh: fetchFull, }; } diff --git a/wsjtx-relay/relay.js b/wsjtx-relay/relay.js index bfc818c..dfff1f9 100644 --- a/wsjtx-relay/relay.js +++ b/wsjtx-relay/relay.js @@ -34,6 +34,7 @@ function parseArgs() { const config = { url: process.env.OPENHAMCLOCK_URL || '', key: process.env.RELAY_KEY || process.env.OPENHAMCLOCK_RELAY_KEY || '', + session: process.env.RELAY_SESSION || '', port: parseInt(process.env.WSJTX_UDP_PORT || '2237'), batchInterval: parseInt(process.env.BATCH_INTERVAL || '2000'), verbose: process.env.VERBOSE === 'true', @@ -43,6 +44,7 @@ function parseArgs() { switch (args[i]) { case '--url': case '-u': config.url = args[++i]; break; case '--key': case '-k': config.key = args[++i]; break; + case '--session': case '-s': config.session = 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; @@ -56,6 +58,7 @@ a remote OpenHamClock server. Options: --url, -u OpenHamClock server URL (required) --key, -k Relay authentication key (required) + --session, -s Browser session ID (required for per-user isolation) --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 @@ -64,6 +67,7 @@ Options: Environment variables: OPENHAMCLOCK_URL Same as --url RELAY_KEY Same as --key + RELAY_SESSION Same as --session WSJTX_UDP_PORT Same as --port BATCH_INTERVAL Same as --interval VERBOSE Set to 'true' for verbose output @@ -91,6 +95,11 @@ if (!config.key) { console.error(' Run with --help for usage info'); process.exit(1); } +if (!config.session) { + console.error('❌ Error: --session is required (auto-generated by the download script)'); + console.error(' Re-download the relay from your OpenHamClock dashboard'); + process.exit(1); +} // Normalize URL const serverUrl = config.url.replace(/\/$/, ''); @@ -293,7 +302,7 @@ function sendBatch() { const batch = messageQueue.splice(0, messageQueue.length); sendInFlight = true; - const body = JSON.stringify({ messages: batch }); + const body = JSON.stringify({ messages: batch, session: config.session }); const parsed = new URL(relayEndpoint); const transport = parsed.protocol === 'https:' ? https : http; @@ -433,7 +442,7 @@ socket.on('listening', () => { // Send relay heartbeat immediately, then every 30s // This tells the server the relay is alive even before WSJT-X sends any packets function sendHeartbeat() { - const body = JSON.stringify({ relay: true, version: '1.0.0', port: config.port }); + const body = JSON.stringify({ relay: true, version: '1.0.0', port: config.port, session: config.session }); const parsed = new URL(relayEndpoint); const transport = parsed.protocol === 'https:' ? https : http;