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'));