From 35b0daa2d17c3826b55a8881ce53e6087264e1fa Mon Sep 17 00:00:00 2001 From: accius Date: Mon, 2 Feb 2026 21:40:26 -0500 Subject: [PATCH] wsjtx support --- .env.example | 7 + docker-compose.yml | 2 + server.js | 46 ++- src/App.jsx | 1 + src/components/PSKReporterPanel.jsx | 23 +- wsjtx-relay/README.md | 118 +++++++ wsjtx-relay/package.json | 14 + wsjtx-relay/relay.js | 474 ++++++++++++++++++++++++++++ 8 files changed, 679 insertions(+), 6 deletions(-) create mode 100644 wsjtx-relay/README.md create mode 100644 wsjtx-relay/package.json create mode 100644 wsjtx-relay/relay.js diff --git a/.env.example b/.env.example index bd024ae..e3495bf 100644 --- a/.env.example +++ b/.env.example @@ -95,6 +95,13 @@ WSJTX_ENABLED=true # UDP port to listen on (must match WSJT-X Settings > Reporting > UDP Server port) WSJTX_UDP_PORT=2237 +# Relay key for remote WSJT-X relay agent (cloud deployments) +# If you're running OpenHamClock on a cloud server (e.g. Railway, openhamclock.com), +# WSJT-X UDP can't reach it directly. Set this key and run the relay agent +# (wsjtx-relay/relay.js) on your local machine to bridge the gap. +# Pick any strong random string — it must match on both sides. +# WSJTX_RELAY_KEY=your-secret-relay-key-here + # =========================================== # DX CLUSTER SETTINGS # =========================================== diff --git a/docker-compose.yml b/docker-compose.yml index 8784829..f0ddec3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,8 @@ services: # Uncomment and set your timezone (IANA format) # This ensures correct local time display, especially with privacy browsers # - TZ=America/New_York + # Uncomment to enable WSJT-X relay from local machine (cloud deployments) + # - WSJTX_RELAY_KEY=your-secret-relay-key-here restart: unless-stopped healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"] diff --git a/server.js b/server.js index a87c940..70e09fd 100644 --- a/server.js +++ b/server.js @@ -3611,7 +3611,8 @@ app.get('/api/config', (req, res) => { dxCluster: true, satellites: true, contests: true, - dxpeditions: true + dxpeditions: true, + wsjtxRelay: !!WSJTX_RELAY_KEY, }, // Refresh intervals (ms) @@ -3633,6 +3634,7 @@ app.get('/api/config', (req, res) => { const WSJTX_UDP_PORT = parseInt(process.env.WSJTX_UDP_PORT || '2237'); const WSJTX_ENABLED = process.env.WSJTX_ENABLED !== 'false'; // enabled by default +const WSJTX_RELAY_KEY = process.env.WSJTX_RELAY_KEY || ''; // auth key for remote relay agent const WSJTX_MAX_DECODES = 200; // max decodes to keep in memory const WSJTX_MAX_AGE = 30 * 60 * 1000; // 30 minutes @@ -4114,6 +4116,7 @@ app.get('/api/wsjtx', (req, res) => { res.json({ enabled: WSJTX_ENABLED, port: WSJTX_UDP_PORT, + relayEnabled: !!WSJTX_RELAY_KEY, clients, decodes: wsjtxState.decodes.slice(-100), // last 100 qsos: wsjtxState.qsos.slice(-20), // last 20 @@ -4137,6 +4140,44 @@ app.get('/api/wsjtx/decodes', (req, res) => { res.json({ decodes, timestamp: Date.now() }); }); +// API endpoint: relay — receive messages from remote relay agent +// The relay agent runs on the same machine as WSJT-X and forwards +// parsed messages over HTTPS for cloud-hosted instances. +app.post('/api/wsjtx/relay', (req, res) => { + // Auth check + if (!WSJTX_RELAY_KEY) { + return res.status(503).json({ error: 'Relay not configured — set WSJTX_RELAY_KEY in .env' }); + } + + const authHeader = req.headers.authorization || ''; + const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : ''; + if (token !== WSJTX_RELAY_KEY) { + return res.status(401).json({ error: 'Invalid relay key' }); + } + + const { messages } = req.body || {}; + if (!Array.isArray(messages) || messages.length === 0) { + return res.status(400).json({ error: 'No messages provided' }); + } + + // Rate limit: max 100 messages per request + const batch = messages.slice(0, 100); + let processed = 0; + + for (const msg of batch) { + if (msg && typeof msg.type === 'number' && msg.id) { + // Ensure timestamp is reasonable (within last 5 minutes or use server time) + if (!msg.timestamp || Math.abs(Date.now() - msg.timestamp) > 5 * 60 * 1000) { + msg.timestamp = Date.now(); + } + handleWSJTXMessage(msg); + processed++; + } + } + + res.json({ ok: true, processed, timestamp: Date.now() }); +}); + // ============================================ // CATCH-ALL FOR SPA // ============================================ @@ -4183,6 +4224,9 @@ app.listen(PORT, '0.0.0.0', () => { if (WSJTX_ENABLED) { console.log(` 🔊 WSJT-X UDP listener on port ${WSJTX_UDP_PORT}`); } + if (WSJTX_RELAY_KEY) { + console.log(` 🔁 WSJT-X relay endpoint enabled (POST /api/wsjtx/relay)`); + } 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 8b52cb8..d84ee42 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -716,6 +716,7 @@ const App = () => { wsjtxLoading={wsjtx.loading} wsjtxEnabled={wsjtx.enabled} wsjtxPort={wsjtx.port} + wsjtxRelayEnabled={wsjtx.relayEnabled} showWSJTXOnMap={mapLayers.showWSJTX} onToggleWSJTXMap={toggleWSJTX} /> diff --git a/src/components/PSKReporterPanel.jsx b/src/components/PSKReporterPanel.jsx index a092628..7755499 100644 --- a/src/components/PSKReporterPanel.jsx +++ b/src/components/PSKReporterPanel.jsx @@ -26,6 +26,7 @@ const PSKReporterPanel = ({ wsjtxLoading, wsjtxEnabled, wsjtxPort, + wsjtxRelayEnabled, showWSJTXOnMap, onToggleWSJTXMap }) => { @@ -345,11 +346,23 @@ const PSKReporterPanel = ({ fontSize: '11px', textAlign: 'center', padding: '16px 8px', height: '100%' }}>
Waiting for WSJT-X...
-
- In WSJT-X: Settings → Reporting → UDP Server -
- Address: 127.0.0.1   Port: {wsjtxPort || 2237} -
+ {wsjtxRelayEnabled ? ( +
+ Relay mode — run the relay agent locally: +
+ + node relay.js --url {'{this server}'} --key {'{key}'} + +
+ See wsjtx-relay/README.md +
+ ) : ( +
+ In WSJT-X: Settings → Reporting → UDP Server +
+ Address: 127.0.0.1   Port: {wsjtxPort || 2237} +
+ )} ) : 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'));