parent
a94be88f0e
commit
35b0daa2d1
@ -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.
|
||||||
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 <url> OpenHamClock server URL (required)
|
||||||
|
--key, -k <key> Relay authentication key (required)
|
||||||
|
--port, -p <port> Local UDP port to listen on (default: 2237)
|
||||||
|
--interval, -i <ms> 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'));
|
||||||
Loading…
Reference in new issue