#!/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 || '', 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', }; 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 '--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; 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) --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 --help, -h Show this help 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 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); } 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(/\/$/, ''); 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, session: config.session }); 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(); // 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, session: config.session }); 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-Heartbeat': 'true', }, timeout: 10000, }; const req = transport.request(reqOpts, (res) => { res.resume(); if (res.statusCode === 200 && consecutiveErrors > 0) { console.log('\n ✅ Server connection restored'); consecutiveErrors = 0; } }); req.on('error', () => {}); req.on('timeout', () => req.destroy()); req.write(body); req.end(); } sendHeartbeat(); setInterval(sendHeartbeat, 30000); // 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'));