Merge pull request #49 from accius/Modular-Staging

Modular staging
pull/65/head
accius 2 days ago committed by GitHub
commit a9bae8a7be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -48,6 +48,13 @@ THEME=dark
# Layout: 'modern' or 'classic'
LAYOUT=modern
# Timezone: IANA timezone identifier
# Set this if your local time shows incorrectly (e.g. same as UTC).
# This is common with privacy browsers like Librewolf that spoof timezone.
# Examples: America/New_York, America/Regina, Europe/London, Asia/Tokyo
# Leave blank or commented out to use browser default.
# TZ=America/New_York
# ===========================================
# OPTIONAL - External Services
# ===========================================
@ -77,6 +84,24 @@ SHOW_SATELLITES=true
# Show DX paths on map (true/false)
SHOW_DX_PATHS=true
# ===========================================
# WSJT-X / JTDX UDP INTEGRATION
# ===========================================
# Enable WSJT-X UDP listener (true/false)
# Listens for decoded FT8/FT4/JT65/WSPR messages from WSJT-X, JTDX, etc.
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
# ===========================================

@ -55,8 +55,9 @@ RUN chown -R openhamclock:nodejs /app
# Switch to non-root user
USER openhamclock
# Expose port
# Expose ports (3000 = web, 2237 = WSJT-X UDP)
EXPOSE 3000
EXPOSE 2237/udp
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \

@ -6,9 +6,15 @@ services:
container_name: openhamclock
ports:
- "3000:3000"
- "2237:2237/udp" # WSJT-X UDP — point WSJT-X to 127.0.0.1:2237
environment:
- NODE_ENV=production
- PORT=3000
# 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"]
@ -16,9 +22,6 @@ services:
timeout: 10s
retries: 3
start_period: 10s
# Uncomment to set timezone
# environment:
# - TZ=America/Denver
# For development with hot reload:
# docker compose -f docker-compose.dev.yml up

@ -22,6 +22,7 @@ const compression = require('compression');
const path = require('path');
const fetch = require('node-fetch');
const net = require('net');
const dgram = require('dgram');
const fs = require('fs');
// Auto-create .env from .env.example on first run
@ -3599,6 +3600,9 @@ app.get('/api/config', (req, res) => {
// Whether config is incomplete (show setup wizard)
configIncomplete: CONFIG.callsign === 'N0CALL' || !CONFIG.gridSquare,
// Server timezone (from TZ env var or system)
timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || '',
// Feature availability
features: {
spaceWeather: true,
@ -3607,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)
@ -3620,6 +3625,687 @@ app.get('/api/config', (req, res) => {
});
});
// ============================================
// WSJT-X UDP LISTENER
// ============================================
// Receives decoded messages from WSJT-X, JTDX, etc.
// Configure WSJT-X: Settings > Reporting > UDP Server > address/port
// Protocol: QDataStream binary format per NetworkMessage.hpp
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
// WSJT-X protocol magic number
const WSJTX_MAGIC = 0xADBCCBDA;
// Message types
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,
HIGHLIGHT_CALLSIGN: 13,
SWITCH_CONFIG: 14,
CONFIGURE: 15,
};
// In-memory store
const wsjtxState = {
clients: {}, // clientId -> { status, lastSeen }
decodes: [], // decoded messages (ring buffer)
qsos: [], // logged QSOs
wspr: [], // WSPR decodes
};
/**
* QDataStream binary reader for WSJT-X protocol
* Reads big-endian Qt-serialized data types
*/
class WSJTXReader {
constructor(buffer) {
this.buf = buffer;
this.offset = 0;
}
remaining() { return this.buf.length - this.offset; }
readUInt8() {
if (this.remaining() < 1) return null;
const v = this.buf.readUInt8(this.offset);
this.offset += 1;
return v;
}
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;
// JavaScript can't do 64-bit ints natively, use BigInt or approximate
const high = this.buf.readUInt32BE(this.offset);
const low = this.buf.readUInt32BE(this.offset + 4);
this.offset += 8;
return high * 0x100000000 + low;
}
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;
}
// Qt utf8 string: uint32 length + bytes (0xFFFFFFFF = null)
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;
}
// QTime: uint32 milliseconds since midnight
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')}` };
}
// QDateTime: QDate (int64 julian day) + QTime (uint32 ms) + timespec
readQDateTime() {
const julianDay = this.readUInt64();
const time = this.readQTime();
const timeSpec = this.readUInt8();
if (timeSpec === 2) this.readInt32(); // UTC offset
return { julianDay, time, timeSpec };
}
}
/**
* Parse a WSJT-X UDP datagram
*/
function parseWSJTXMessage(buffer) {
const reader = new WSJTXReader(buffer);
// Header
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:
// Unknown message type - ignore per protocol spec
return null;
}
} catch (e) {
// Malformed packet - ignore
return null;
}
return msg;
}
/**
* Parse decoded message text to extract callsigns and grid
* FT8/FT4 messages follow a standard format
*/
function parseDecodeMessage(text) {
if (!text) return {};
const result = {};
// CQ message: "CQ DX K1ABC FN42" or "CQ K1ABC FN42"
const cqMatch = text.match(/^CQ\s+(?:(\S+)\s+)?([A-Z0-9/]+)\s+([A-Z]{2}\d{2}[a-z]{0,2})?/i);
if (cqMatch) {
result.type = 'CQ';
result.modifier = cqMatch[1] && !cqMatch[1].match(/^[A-Z0-9/]{3,}$/) ? cqMatch[1] : null;
result.caller = cqMatch[2] || cqMatch[1];
result.grid = cqMatch[3] || null;
return result;
}
// Standard QSO exchange: "K1ABC W2DEF +05" or "K1ABC W2DEF R-12" or "K1ABC W2DEF RR73"
const qsoMatch = text.match(/^([A-Z0-9/]+)\s+([A-Z0-9/]+)\s+(.*)/i);
if (qsoMatch) {
result.type = 'QSO';
result.dxCall = qsoMatch[1];
result.deCall = qsoMatch[2];
result.exchange = qsoMatch[3].trim();
// Check for grid in exchange
const gridMatch = result.exchange.match(/^([A-Z]{2}\d{2}[a-z]{0,2})$/i);
if (gridMatch) result.grid = gridMatch[1];
return result;
}
return result;
}
/**
* Convert frequency in Hz to band name
*/
function freqToBand(freqHz) {
const mhz = freqHz / 1000000;
if (mhz >= 1.8 && mhz < 2.0) return '160m';
if (mhz >= 3.5 && mhz < 4.0) return '80m';
if (mhz >= 5.3 && mhz < 5.4) return '60m';
if (mhz >= 7.0 && mhz < 7.3) return '40m';
if (mhz >= 10.1 && mhz < 10.15) return '30m';
if (mhz >= 14.0 && mhz < 14.35) return '20m';
if (mhz >= 18.068 && mhz < 18.168) return '17m';
if (mhz >= 21.0 && mhz < 21.45) return '15m';
if (mhz >= 24.89 && mhz < 24.99) return '12m';
if (mhz >= 28.0 && mhz < 29.7) return '10m';
if (mhz >= 50.0 && mhz < 54.0) return '6m';
if (mhz >= 144.0 && mhz < 148.0) return '2m';
if (mhz >= 420.0 && mhz < 450.0) return '70cm';
return `${mhz.toFixed(3)} MHz`;
}
/**
* Handle incoming WSJT-X messages
*/
function handleWSJTXMessage(msg) {
if (!msg) return;
switch (msg.type) {
case WSJTX_MSG.HEARTBEAT: {
wsjtxState.clients[msg.id] = {
...(wsjtxState.clients[msg.id] || {}),
version: msg.version,
lastSeen: msg.timestamp
};
break;
}
case WSJTX_MSG.STATUS: {
wsjtxState.clients[msg.id] = {
...(wsjtxState.clients[msg.id] || {}),
lastSeen: msg.timestamp,
dialFrequency: msg.dialFrequency,
mode: msg.mode,
dxCall: msg.dxCall,
deCall: msg.deCall,
deGrid: msg.deGrid,
txEnabled: msg.txEnabled,
transmitting: msg.transmitting,
decoding: msg.decoding,
subMode: msg.subMode,
band: msg.dialFrequency ? freqToBand(msg.dialFrequency) : null,
configName: msg.configName,
txMessage: msg.txMessage,
};
break;
}
case WSJTX_MSG.DECODE: {
const clientStatus = wsjtxState.clients[msg.id] || {};
const parsed = parseDecodeMessage(msg.message);
const decode = {
id: `${msg.id}-${msg.timestamp}-${msg.deltaFreq}`,
clientId: msg.id,
isNew: msg.isNew,
time: msg.time?.formatted || '',
timeMs: msg.time?.ms || 0,
snr: msg.snr,
dt: msg.deltaTime ? msg.deltaTime.toFixed(1) : '0.0',
freq: msg.deltaFreq,
mode: msg.mode || clientStatus.mode || '',
message: msg.message,
lowConfidence: msg.lowConfidence,
offAir: msg.offAir,
dialFrequency: clientStatus.dialFrequency || 0,
band: clientStatus.band || '',
...parsed,
timestamp: msg.timestamp,
};
// Resolve grid to lat/lon for map plotting
if (parsed.grid) {
const coords = gridToLatLon(parsed.grid);
if (coords) {
decode.lat = coords.latitude;
decode.lon = coords.longitude;
}
}
// Only keep new decodes (not replays)
if (msg.isNew) {
wsjtxState.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();
}
}
break;
}
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);
break;
}
case WSJTX_MSG.QSO_LOGGED: {
const clientStatus = wsjtxState.clients[msg.id] || {};
const qso = {
clientId: msg.id,
dxCall: msg.dxCall,
dxGrid: msg.dxGrid,
frequency: msg.txFrequency,
band: msg.txFrequency ? freqToBand(msg.txFrequency) : '',
mode: msg.mode,
reportSent: msg.reportSent,
reportRecv: msg.reportRecv,
myCall: msg.myCall || clientStatus.deCall,
myGrid: msg.myGrid || clientStatus.deGrid,
timestamp: msg.timestamp,
};
// Resolve grid to lat/lon
if (msg.dxGrid) {
const coords = gridToLatLon(msg.dxGrid);
if (coords) { qso.lat = coords.latitude; qso.lon = coords.longitude; }
}
wsjtxState.qsos.push(qso);
// Keep last 50 QSOs
if (wsjtxState.qsos.length > 50) wsjtxState.qsos.shift();
break;
}
case WSJTX_MSG.WSPR_DECODE: {
const wsprDecode = {
clientId: msg.id,
isNew: msg.isNew,
time: msg.time?.formatted || '',
snr: msg.snr,
dt: msg.deltaTime ? msg.deltaTime.toFixed(1) : '0.0',
frequency: msg.frequency,
drift: msg.drift,
callsign: msg.callsign,
grid: msg.grid,
power: msg.power,
timestamp: msg.timestamp,
};
if (msg.isNew) {
wsjtxState.wspr.push(wsprDecode);
if (wsjtxState.wspr.length > 100) wsjtxState.wspr.shift();
}
break;
}
case WSJTX_MSG.CLOSE: {
delete wsjtxState.clients[msg.id];
break;
}
}
}
// Start UDP listener
let wsjtxSocket = null;
if (WSJTX_ENABLED) {
try {
wsjtxSocket = dgram.createSocket('udp4');
wsjtxSocket.on('message', (buf, rinfo) => {
const msg = parseWSJTXMessage(buf);
if (msg) handleWSJTXMessage(msg);
});
wsjtxSocket.on('error', (err) => {
logErrorOnce('WSJT-X UDP', err.message);
});
wsjtxSocket.on('listening', () => {
const addr = wsjtxSocket.address();
console.log(`[WSJT-X] UDP listener on ${addr.address}:${addr.port}`);
});
wsjtxSocket.bind(WSJTX_UDP_PORT, '0.0.0.0');
} catch (e) {
console.error(`[WSJT-X] Failed to start UDP listener: ${e.message}`);
}
}
// API endpoint: get WSJT-X data
app.get('/api/wsjtx', (req, res) => {
const clients = {};
for (const [id, client] of Object.entries(wsjtxState.clients)) {
// Only include clients seen in last 5 minutes
if (Date.now() - client.lastSeen < 5 * 60 * 1000) {
clients[id] = client;
}
}
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
wspr: wsjtxState.wspr.slice(-50), // last 50
stats: {
totalDecodes: wsjtxState.decodes.length,
totalQsos: wsjtxState.qsos.length,
totalWspr: wsjtxState.wspr.length,
activeClients: Object.keys(clients).length,
}
});
});
// API endpoint: get just decodes (lightweight polling)
app.get('/api/wsjtx/decodes', (req, res) => {
const since = parseInt(req.query.since) || 0;
const decodes = since
? wsjtxState.decodes.filter(d => d.timestamp > since)
: wsjtxState.decodes.slice(-100);
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() });
});
// API endpoint: download pre-configured relay agent script
// Embeds relay.js + server URL + relay key into a one-file launcher
app.get('/api/wsjtx/relay/download/:platform', (req, res) => {
if (!WSJTX_RELAY_KEY) {
return res.status(503).json({ error: 'Relay not configured — set WSJTX_RELAY_KEY in .env' });
}
const platform = req.params.platform; // 'linux', 'mac', or 'windows'
const relayJsPath = path.join(__dirname, 'wsjtx-relay', 'relay.js');
let relayJs;
try {
relayJs = fs.readFileSync(relayJsPath, 'utf8');
} catch (e) {
return res.status(500).json({ error: 'relay.js not found on server' });
}
// Detect server URL from request
const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http';
const host = req.headers['x-forwarded-host'] || req.headers.host;
const serverURL = proto + '://' + host;
if (platform === 'linux' || platform === 'mac') {
// Build bash script with relay.js embedded as heredoc
const lines = [
'#!/bin/bash',
'# OpenHamClock WSJT-X Relay — Auto-configured',
'# Generated by ' + serverURL,
'#',
'# Usage: bash ' + (platform === 'mac' ? 'start-relay.command' : 'start-relay.sh'),
'# Stop: Ctrl+C',
'# Requires: Node.js 14+ (https://nodejs.org)',
'#',
'# In WSJT-X: Settings > Reporting > UDP Server',
'# Address: 127.0.0.1 Port: 2237',
'',
'set -e',
'',
'# Check for Node.js',
'if ! command -v node &> /dev/null; then',
' echo ""',
' echo "Node.js is not installed."',
' echo "Install from https://nodejs.org (LTS recommended)"',
' echo ""',
' echo "Quick install:"',
' echo " Ubuntu/Debian: sudo apt install nodejs"',
' echo " Mac (Homebrew): brew install node"',
' echo " Fedora: sudo dnf install nodejs"',
' echo ""',
' exit 1',
'fi',
'',
'# Write relay agent to temp file',
'RELAY_FILE=$(mktemp /tmp/ohc-relay-XXXXXX.js)',
'trap "rm -f $RELAY_FILE" EXIT',
'',
"cat > \"$RELAY_FILE\" << 'OPENHAMCLOCK_RELAY_EOF'",
relayJs,
'OPENHAMCLOCK_RELAY_EOF',
'',
'# Run relay',
'exec node "$RELAY_FILE" \\',
' --url "' + serverURL + '" \\',
' --key "' + WSJTX_RELAY_KEY + '"',
];
const script = lines.join('\n') + '\n';
const filename = platform === 'mac' ? 'start-relay.command' : 'start-relay.sh';
res.setHeader('Content-Type', 'application/x-sh');
res.setHeader('Content-Disposition', 'attachment; filename="' + filename + '"');
return res.send(script);
} else if (platform === 'windows') {
// Build PowerShell script with relay.js embedded
const escapedJs = relayJs.replace(/'/g, "''");
const lines = [
'# OpenHamClock WSJT-X Relay - Auto-configured',
'# Generated by ' + serverURL,
'# Right-click > "Run with PowerShell" or run from terminal',
'# Requires: Node.js 14+ (https://nodejs.org)',
'#',
'# In WSJT-X: Settings > Reporting > UDP Server',
'# Address: 127.0.0.1 Port: 2237',
'',
'# Check for Node.js',
'try {',
' $nv = (node -v 2>$null)',
' if (-not $nv) { throw "missing" }',
' Write-Host "Found Node.js $nv" -ForegroundColor Green',
'} catch {',
' Write-Host "Node.js is not installed." -ForegroundColor Red',
' Write-Host "Download from https://nodejs.org (LTS version)" -ForegroundColor Yellow',
' Read-Host "Press Enter to exit"',
' exit 1',
'}',
'',
'# Write relay agent to temp file',
'$relayFile = Join-Path $env:TEMP "ohc-relay.js"',
'',
"$relayCode = @'",
escapedJs,
"'@",
'',
'$relayCode | Out-File -FilePath $relayFile -Encoding UTF8',
'',
'Write-Host "Starting WSJT-X relay agent..." -ForegroundColor Cyan',
'Write-Host "Press Ctrl+C to stop" -ForegroundColor DarkGray',
'Write-Host ""',
'',
'# Run relay',
'try {',
' node $relayFile --url "' + serverURL + '" --key "' + WSJTX_RELAY_KEY + '"',
'} finally {',
' Remove-Item $relayFile -ErrorAction SilentlyContinue',
'}',
];
const script = lines.join('\r\n') + '\r\n';
res.setHeader('Content-Type', 'application/x-powershell');
res.setHeader('Content-Disposition', 'attachment; filename="start-relay.ps1"');
return res.send(script);
} else {
return res.status(400).json({ error: 'Invalid platform. Use: linux, mac, or windows' });
}
});
// ============================================
// CATCH-ALL FOR SPA
// ============================================
@ -3663,6 +4349,12 @@ app.listen(PORT, '0.0.0.0', () => {
console.log(` 🔗 Network access: http://<your-ip>:${PORT}`);
}
console.log(' 📡 API proxy enabled for NOAA, POTA, SOTA, DX Cluster');
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') {

@ -34,7 +34,8 @@ import {
useDXpeditions,
useSatellites,
useSolarIndices,
usePSKReporter
usePSKReporter,
useWSJTX
} from './hooks';
// Utils
@ -104,9 +105,9 @@ const App = () => {
const [mapLayers, setMapLayers] = useState(() => {
try {
const stored = localStorage.getItem('openhamclock_mapLayers');
const defaults = { showDXPaths: true, showDXLabels: true, showPOTA: true, showSatellites: false, showPSKReporter: true };
const defaults = { showDXPaths: true, showDXLabels: true, showPOTA: true, showSatellites: false, showPSKReporter: true, showWSJTX: true };
return stored ? { ...defaults, ...JSON.parse(stored) } : defaults;
} catch (e) { return { showDXPaths: true, showDXLabels: true, showPOTA: true, showSatellites: false, showPSKReporter: true }; }
} catch (e) { return { showDXPaths: true, showDXLabels: true, showPOTA: true, showSatellites: false, showPSKReporter: true, showWSJTX: true }; }
});
useEffect(() => {
@ -122,6 +123,7 @@ const App = () => {
const togglePOTA = useCallback(() => setMapLayers(prev => ({ ...prev, showPOTA: !prev.showPOTA })), []);
const toggleSatellites = useCallback(() => setMapLayers(prev => ({ ...prev, showSatellites: !prev.showSatellites })), []);
const togglePSKReporter = useCallback(() => setMapLayers(prev => ({ ...prev, showPSKReporter: !prev.showPSKReporter })), []);
const toggleWSJTX = useCallback(() => setMapLayers(prev => ({ ...prev, showWSJTX: !prev.showWSJTX })), []);
// 12/24 hour format
const [use12Hour, setUse12Hour] = useState(() => {
@ -208,6 +210,7 @@ const App = () => {
const satellites = useSatellites(config.location);
const localWeather = useLocalWeather(config.location);
const pskReporter = usePSKReporter(config.callsign, { minutes: 15, enabled: config.callsign !== 'N0CALL' });
const wsjtx = useWSJTX();
// Filter PSKReporter spots for map display
const filteredPskSpots = useMemo(() => {
@ -228,6 +231,11 @@ const App = () => {
});
}, [pskReporter.txReports, pskReporter.rxReports, pskFilters]);
// Filter WSJT-X decodes for map display (only those with lat/lon from grid)
const wsjtxMapSpots = useMemo(() => {
return wsjtx.decodes.filter(d => d.lat && d.lon && d.type === 'CQ');
}, [wsjtx.decodes]);
// Computed values
const deGrid = useMemo(() => calculateGridSquare(config.location.lat, config.location.lon), [config.location]);
const dxGrid = useMemo(() => calculateGridSquare(dxLocation.lat, dxLocation.lon), [dxLocation]);
@ -251,11 +259,18 @@ const App = () => {
setDxLocation({ lat: coords.lat, lon: coords.lon });
}, []);
// Format times
// Format times use explicit timezone if configured (fixes privacy browsers like Librewolf
// that spoof timezone to UTC via privacy.resistFingerprinting)
const utcTime = currentTime.toISOString().substr(11, 8);
const localTime = currentTime.toLocaleTimeString('en-US', { hour12: use12Hour });
const utcDate = currentTime.toISOString().substr(0, 10);
const localDate = currentTime.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
const localTimeOpts = { hour12: use12Hour };
const localDateOpts = { weekday: 'short', month: 'short', day: 'numeric' };
if (config.timezone) {
localTimeOpts.timeZone = config.timezone;
localDateOpts.timeZone = config.timezone;
}
const localTime = currentTime.toLocaleTimeString('en-US', localTimeOpts);
const localDate = currentTime.toLocaleDateString('en-US', localDateOpts);
// Scale for small screens
const [scale, setScale] = useState(1);
@ -503,6 +518,8 @@ const App = () => {
showPOTA={mapLayers.showPOTA}
showSatellites={mapLayers.showSatellites}
showPSKReporter={mapLayers.showPSKReporter}
wsjtxSpots={wsjtxMapSpots}
showWSJTX={mapLayers.showWSJTX}
onToggleSatellites={toggleSatellites}
hoveredSpot={hoveredSpot}
/>
@ -641,6 +658,8 @@ const App = () => {
showPOTA={mapLayers.showPOTA}
showSatellites={mapLayers.showSatellites}
showPSKReporter={mapLayers.showPSKReporter}
wsjtxSpots={wsjtxMapSpots}
showWSJTX={mapLayers.showWSJTX}
onToggleSatellites={toggleSatellites}
hoveredSpot={hoveredSpot}
/>
@ -677,7 +696,7 @@ const App = () => {
/>
</div>
{/* PSKReporter - digital mode spots */}
{/* PSKReporter + WSJT-X - digital mode spots */}
<div style={{ flex: '1 1 auto', minHeight: '140px', overflow: 'hidden' }}>
<PSKReporterPanel
callsign={config.callsign}
@ -690,6 +709,16 @@ const App = () => {
setDxLocation({ lat: report.lat, lon: report.lon, call: report.receiver || report.sender });
}
}}
wsjtxDecodes={wsjtx.decodes}
wsjtxClients={wsjtx.clients}
wsjtxQsos={wsjtx.qsos}
wsjtxStats={wsjtx.stats}
wsjtxLoading={wsjtx.loading}
wsjtxEnabled={wsjtx.enabled}
wsjtxPort={wsjtx.port}
wsjtxRelayEnabled={wsjtx.relayEnabled}
showWSJTXOnMap={mapLayers.showWSJTX}
onToggleWSJTXMap={toggleWSJTX}
/>
</div>

@ -1,7 +1,11 @@
/**
* PSKReporter Panel
* Shows where your digital mode signals are being received
* Uses MQTT WebSocket for real-time data
* PSKReporter + WSJT-X Panel
* Digital mode spots toggle between internet (PSKReporter) and local (WSJT-X UDP)
*
* Layout:
* Row 1: Segmented mode toggle | map + filter controls
* Row 2: Sub-tabs (Being Heard / Hearing or Decodes / QSOs)
* Content: Scrolling spot/decode list
*/
import React, { useState, useMemo } from 'react';
import { usePSKReporter } from '../hooks/usePSKReporter.js';
@ -13,42 +17,43 @@ const PSKReporterPanel = ({
showOnMap,
onToggleMap,
filters = {},
onOpenFilters
onOpenFilters,
// WSJT-X props
wsjtxDecodes = [],
wsjtxClients = {},
wsjtxQsos = [],
wsjtxStats = {},
wsjtxLoading,
wsjtxEnabled,
wsjtxPort,
wsjtxRelayEnabled,
showWSJTXOnMap,
onToggleWSJTXMap
}) => {
const [activeTab, setActiveTab] = useState('tx'); // Default to 'tx' (Being Heard)
const [panelMode, setPanelMode] = useState('psk');
const [activeTab, setActiveTab] = useState('tx');
const [wsjtxTab, setWsjtxTab] = useState('decodes');
const [wsjtxFilter, setWsjtxFilter] = useState('all'); // 'all' | 'cq' | band name
// PSKReporter hook
const {
txReports,
txCount,
rxReports,
rxCount,
loading,
error,
connected,
source,
refresh
txReports, txCount, rxReports, rxCount,
loading, error, connected, source, refresh
} = usePSKReporter(callsign, {
minutes: 15,
enabled: callsign && callsign !== 'N0CALL'
});
// Filter reports by band, grid, and mode
// PSK filtering
const filterReports = (reports) => {
return reports.filter(r => {
// Band filter
if (filters?.bands?.length && !filters.bands.includes(r.band)) return false;
// Grid filter (prefix match)
if (filters?.grids?.length) {
const grid = activeTab === 'tx' ? r.receiverGrid : r.senderGrid;
if (!grid) return false;
const gridPrefix = grid.substring(0, 2).toUpperCase();
if (!filters.grids.includes(gridPrefix)) return false;
if (!filters.grids.includes(grid.substring(0, 2).toUpperCase())) return false;
}
// Mode filter
if (filters?.modes?.length && !filters.modes.includes(r.mode)) return false;
return true;
});
};
@ -56,263 +61,404 @@ const PSKReporterPanel = ({
const filteredTx = useMemo(() => filterReports(txReports), [txReports, filters, activeTab]);
const filteredRx = useMemo(() => filterReports(rxReports), [rxReports, filters, activeTab]);
const filteredReports = activeTab === 'tx' ? filteredTx : filteredRx;
const pskFilterCount = [filters?.bands?.length, filters?.grids?.length, filters?.modes?.length].filter(Boolean).length;
// Count active filters
const getActiveFilterCount = () => {
let count = 0;
if (filters?.bands?.length) count++;
if (filters?.grids?.length) count++;
if (filters?.modes?.length) count++;
return count;
};
const filterCount = getActiveFilterCount();
const getFreqColor = (freqMHz) => !freqMHz ? 'var(--text-muted)' : getBandColor(parseFloat(freqMHz));
const formatAge = (m) => m < 1 ? 'now' : m < 60 ? `${m}m` : `${Math.floor(m/60)}h`;
// Get band color from frequency
const getFreqColor = (freqMHz) => {
if (!freqMHz) return 'var(--text-muted)';
const freq = parseFloat(freqMHz);
return getBandColor(freq);
};
// WSJT-X helpers
const activeClients = Object.entries(wsjtxClients);
const primaryClient = activeClients[0]?.[1] || null;
// Format age
const formatAge = (minutes) => {
if (minutes < 1) return 'now';
if (minutes < 60) return `${minutes}m`;
return `${Math.floor(minutes/60)}h`;
};
// Build unified filter options: All, CQ Only, then each available band
const wsjtxFilterOptions = useMemo(() => {
const bands = [...new Set(wsjtxDecodes.map(d => d.band).filter(Boolean))]
.sort((a, b) => (parseInt(b) || 999) - (parseInt(a) || 999));
return [
{ value: 'all', label: 'All decodes' },
{ value: 'cq', label: 'CQ only' },
...bands.map(b => ({ value: b, label: b }))
];
}, [wsjtxDecodes]);
// Get status indicator
const getStatusIndicator = () => {
if (connected) {
return <span style={{ color: '#4ade80', fontSize: '10px' }}> LIVE</span>;
}
if (source === 'connecting' || source === 'reconnecting') {
return <span style={{ color: '#fbbf24', fontSize: '10px' }}> {source}</span>;
}
if (error) {
return <span style={{ color: '#ef4444', fontSize: '10px' }}> offline</span>;
const filteredDecodes = useMemo(() => {
let filtered = [...wsjtxDecodes];
if (wsjtxFilter === 'cq') {
filtered = filtered.filter(d => d.type === 'CQ');
} else if (wsjtxFilter !== 'all') {
// Band filter
filtered = filtered.filter(d => d.band === wsjtxFilter);
}
return null;
return filtered.reverse();
}, [wsjtxDecodes, wsjtxFilter]);
const getSnrColor = (snr) => {
if (snr == null) return 'var(--text-muted)';
if (snr >= 0) return '#4ade80';
if (snr >= -10) return '#fbbf24';
if (snr >= -18) return '#fb923c';
return '#ef4444';
};
if (!callsign || callsign === 'N0CALL') {
return (
<div className="panel" style={{ padding: '10px' }}>
<div style={{ fontSize: '12px', color: 'var(--accent-primary)', fontWeight: '700', marginBottom: '6px' }}>
📡 PSKReporter
</div>
<div style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '10px', fontSize: '11px' }}>
Set callsign in Settings
</div>
</div>
);
}
const getMsgColor = (d) => {
if (d.type === 'CQ') return '#60a5fa';
if (['RR73', '73', 'RRR'].includes(d.exchange)) return '#4ade80';
if (d.exchange?.startsWith('R')) return '#fbbf24';
return 'var(--text-primary)';
};
// Active map toggle for current mode
const isMapOn = panelMode === 'psk' ? showOnMap : showWSJTXOnMap;
const handleMapToggle = panelMode === 'psk' ? onToggleMap : onToggleWSJTXMap;
// Compact status dot
const statusDot = connected
? { color: '#4ade80', char: '●' }
: (source === 'connecting' || source === 'reconnecting')
? { color: '#fbbf24', char: '◐' }
: error ? { color: '#ef4444', char: '●' } : null;
// Shared styles
const segBtn = (active, color) => ({
padding: '3px 10px',
background: active ? `${color}18` : 'transparent',
color: active ? color : 'var(--text-muted)',
border: 'none',
borderBottom: active ? `2px solid ${color}` : '2px solid transparent',
fontSize: '11px',
fontWeight: active ? '700' : '400',
cursor: 'pointer',
letterSpacing: '0.02em',
});
const subTabBtn = (active, color) => ({
flex: 1,
padding: '3px 4px',
background: active ? `${color}20` : 'transparent',
border: `1px solid ${active ? color + '66' : 'var(--border-color)'}`,
borderRadius: '3px',
color: active ? color : 'var(--text-muted)',
cursor: 'pointer',
fontSize: '10px',
fontWeight: active ? '600' : '400',
});
const iconBtn = (active, activeColor = '#4488ff') => ({
background: active ? `${activeColor}30` : 'rgba(100,100,100,0.3)',
border: `1px solid ${active ? activeColor : '#555'}`,
color: active ? activeColor : '#777',
padding: '2px 6px',
borderRadius: '3px',
fontSize: '10px',
cursor: 'pointer',
lineHeight: 1,
});
return (
<div className="panel" style={{
padding: '10px',
padding: '8px 10px',
display: 'flex',
flexDirection: 'column',
height: '100%',
overflow: 'hidden'
}}>
{/* Header */}
{/* ── Row 1: Mode toggle + controls ── */}
<div style={{
fontSize: '12px',
color: 'var(--accent-primary)',
fontWeight: '700',
marginBottom: '6px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '5px',
flexShrink: 0,
}}>
<span>📡 PSKReporter {getStatusIndicator()}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ fontSize: '9px', color: 'var(--text-muted)' }}>
{filteredReports.length}/{activeTab === 'tx' ? txCount : rxCount}
</span>
<button
onClick={onOpenFilters}
style={{
background: filterCount > 0 ? 'rgba(255, 170, 0, 0.3)' : 'rgba(100, 100, 100, 0.3)',
border: `1px solid ${filterCount > 0 ? '#ffaa00' : '#666'}`,
color: filterCount > 0 ? '#ffaa00' : '#888',
padding: '2px 8px',
borderRadius: '4px',
fontSize: '10px',
fontFamily: 'JetBrains Mono',
cursor: 'pointer'
}}
>
🔍 Filters
{/* Mode toggle */}
<div style={{ display: 'flex' }}>
<button onClick={() => setPanelMode('psk')} style={segBtn(panelMode === 'psk', 'var(--accent-primary)')}>
PSKReporter
</button>
<button
onClick={refresh}
disabled={loading}
title={connected ? 'Reconnect' : 'Connect'}
style={{
background: 'rgba(100, 100, 100, 0.3)',
border: '1px solid #666',
color: '#888',
padding: '2px 6px',
borderRadius: '4px',
fontSize: '10px',
cursor: loading ? 'not-allowed' : 'pointer',
opacity: loading ? 0.5 : 1
}}
>
🔄
<button onClick={() => setPanelMode('wsjtx')} style={segBtn(panelMode === 'wsjtx', '#a78bfa')}>
WSJT-X
</button>
{onToggleMap && (
<button
onClick={onToggleMap}
style={{
background: showOnMap ? 'rgba(68, 136, 255, 0.3)' : 'rgba(100, 100, 100, 0.3)',
border: `1px solid ${showOnMap ? '#4488ff' : '#666'}`,
color: showOnMap ? '#4488ff' : '#888',
padding: '2px 8px',
borderRadius: '4px',
fontSize: '10px',
fontFamily: 'JetBrains Mono',
cursor: 'pointer'
}}
>
🗺 {showOnMap ? 'ON' : 'OFF'}
</button>
)}
</div>
</div>
{/* Tabs */}
<div style={{
display: 'flex',
gap: '4px',
marginBottom: '6px'
}}>
<button
onClick={() => setActiveTab('tx')}
style={{
flex: 1,
padding: '4px 6px',
background: activeTab === 'tx' ? 'rgba(74, 222, 128, 0.2)' : 'rgba(100, 100, 100, 0.2)',
border: `1px solid ${activeTab === 'tx' ? '#4ade80' : '#555'}`,
borderRadius: '3px',
color: activeTab === 'tx' ? '#4ade80' : '#888',
cursor: 'pointer',
fontSize: '10px',
fontFamily: 'JetBrains Mono'
}}
>
📤 Being Heard ({filterCount > 0 ? `${filteredTx.length}` : txCount})
</button>
<button
onClick={() => setActiveTab('rx')}
style={{
flex: 1,
padding: '4px 6px',
background: activeTab === 'rx' ? 'rgba(96, 165, 250, 0.2)' : 'rgba(100, 100, 100, 0.2)',
border: `1px solid ${activeTab === 'rx' ? '#60a5fa' : '#555'}`,
borderRadius: '3px',
color: activeTab === 'rx' ? '#60a5fa' : '#888',
cursor: 'pointer',
fontSize: '10px',
fontFamily: 'JetBrains Mono'
}}
>
📥 Hearing ({filterCount > 0 ? `${filteredRx.length}` : rxCount})
</button>
</div>
{/* Reports list */}
{error && !connected ? (
<div style={{ textAlign: 'center', padding: '10px', color: 'var(--text-muted)', fontSize: '11px' }}>
Connection failed - click 🔄 to retry
</div>
) : loading && filteredReports.length === 0 && filterCount === 0 ? (
<div style={{ textAlign: 'center', padding: '15px', color: 'var(--text-muted)', fontSize: '11px' }}>
<div className="loading-spinner" style={{ margin: '0 auto 8px' }} />
Connecting to MQTT...
</div>
) : !connected && filteredReports.length === 0 && filterCount === 0 ? (
<div style={{ textAlign: 'center', padding: '10px', color: 'var(--text-muted)', fontSize: '11px' }}>
Waiting for connection...
</div>
) : filteredReports.length === 0 ? (
<div style={{ textAlign: 'center', padding: '10px', color: 'var(--text-muted)', fontSize: '11px' }}>
{filterCount > 0
? 'No spots match filters'
: activeTab === 'tx'
? 'Waiting for spots... (TX to see reports)'
: 'No stations heard yet'}
</div>
) : (
<div style={{
flex: 1,
overflow: 'auto',
fontSize: '12px',
fontFamily: 'JetBrains Mono, monospace'
}}>
{filteredReports.slice(0, 20).map((report, i) => {
const freqMHz = report.freqMHz || (report.freq ? (report.freq / 1000000).toFixed(3) : '?');
const color = getFreqColor(freqMHz);
const displayCall = activeTab === 'tx' ? report.receiver : report.sender;
const grid = activeTab === 'tx' ? report.receiverGrid : report.senderGrid;
return (
<div
key={`${displayCall}-${report.freq}-${i}`}
onClick={() => onShowOnMap && report.lat && report.lon && onShowOnMap(report)}
{/* Right controls */}
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
{/* PSK: status dot + filter + refresh */}
{panelMode === 'psk' && (
<>
{statusDot && (
<span style={{ color: statusDot.color, fontSize: '10px', lineHeight: 1 }}>{statusDot.char}</span>
)}
<button onClick={onOpenFilters} style={iconBtn(pskFilterCount > 0, '#ffaa00')}>
{pskFilterCount > 0 ? `🔍${pskFilterCount}` : '🔍'}
</button>
<button onClick={refresh} disabled={loading} style={{
...iconBtn(false),
opacity: loading ? 0.4 : 1,
cursor: loading ? 'not-allowed' : 'pointer',
}}>🔄</button>
</>
)}
{/* WSJT-X: mode/band info + unified filter */}
{panelMode === 'wsjtx' && (
<>
{primaryClient && (
<span style={{ fontSize: '9px', color: 'var(--text-muted)' }}>
{primaryClient.mode} {primaryClient.band}
{primaryClient.transmitting && <span style={{ color: '#ef4444', marginLeft: '2px' }}>TX</span>}
</span>
)}
<select
value={wsjtxFilter}
onChange={(e) => setWsjtxFilter(e.target.value)}
style={{
display: 'grid',
gridTemplateColumns: '55px 1fr auto',
gap: '6px',
padding: '4px 6px',
background: 'var(--bg-tertiary)',
color: wsjtxFilter !== 'all' ? '#a78bfa' : 'var(--text-primary)',
border: `1px solid ${wsjtxFilter !== 'all' ? '#a78bfa55' : 'var(--border-color)'}`,
borderRadius: '3px',
marginBottom: '2px',
background: i % 2 === 0 ? 'rgba(255,255,255,0.03)' : 'transparent',
cursor: report.lat && report.lon ? 'pointer' : 'default',
transition: 'background 0.15s',
borderLeft: '2px solid transparent'
fontSize: '10px',
padding: '1px 4px',
cursor: 'pointer',
maxWidth: '90px',
}}
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(68, 136, 255, 0.15)'}
onMouseLeave={(e) => e.currentTarget.style.background = i % 2 === 0 ? 'rgba(255,255,255,0.03)' : 'transparent'}
>
<div style={{ color, fontWeight: '600', fontSize: '11px' }}>
{freqMHz}
</div>
<div style={{
color: 'var(--text-primary)',
fontWeight: '600',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: '11px'
}}>
{displayCall}
{grid && <span style={{ color: 'var(--text-muted)', fontWeight: '400', marginLeft: '4px', fontSize: '9px' }}>{grid}</span>}
</div>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
fontSize: '10px'
}}>
<span style={{ color: 'var(--text-muted)' }}>{report.mode}</span>
{report.snr !== null && report.snr !== undefined && (
{wsjtxFilterOptions.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</>
)}
{/* Map toggle (always visible) */}
{handleMapToggle && (
<button onClick={handleMapToggle} style={iconBtn(isMapOn, panelMode === 'psk' ? '#4488ff' : '#a78bfa')}>
🗺
</button>
)}
</div>
</div>
{/* ── Row 2: Sub-tabs ── */}
<div style={{ display: 'flex', gap: '4px', marginBottom: '5px', flexShrink: 0 }}>
{panelMode === 'psk' ? (
<>
<button onClick={() => setActiveTab('tx')} style={subTabBtn(activeTab === 'tx', '#4ade80')}>
Heard ({pskFilterCount > 0 ? filteredTx.length : txCount})
</button>
<button onClick={() => setActiveTab('rx')} style={subTabBtn(activeTab === 'rx', '#60a5fa')}>
Hearing ({pskFilterCount > 0 ? filteredRx.length : rxCount})
</button>
</>
) : (
<>
<button onClick={() => setWsjtxTab('decodes')} style={subTabBtn(wsjtxTab === 'decodes', '#a78bfa')}>
Decodes ({filteredDecodes.length})
</button>
<button onClick={() => setWsjtxTab('qsos')} style={subTabBtn(wsjtxTab === 'qsos', '#a78bfa')}>
QSOs ({wsjtxQsos.length})
</button>
</>
)}
</div>
{/* ── Content area ── */}
<div style={{ flex: 1, overflow: 'auto', fontSize: '11px', fontFamily: "'JetBrains Mono', monospace" }}>
{/* === PSKReporter content === */}
{panelMode === 'psk' && (
<>
{(!callsign || callsign === 'N0CALL') ? (
<div style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '16px', fontSize: '11px' }}>
Set your callsign in Settings to see reports
</div>
) : error && !connected ? (
<div style={{ textAlign: 'center', padding: '12px', color: 'var(--text-muted)', fontSize: '11px' }}>
Connection failed tap 🔄
</div>
) : loading && filteredReports.length === 0 && pskFilterCount === 0 ? (
<div style={{ textAlign: 'center', padding: '16px', color: 'var(--text-muted)', fontSize: '11px' }}>
<div className="loading-spinner" style={{ margin: '0 auto 8px' }} />
Connecting...
</div>
) : filteredReports.length === 0 ? (
<div style={{ textAlign: 'center', padding: '12px', color: 'var(--text-muted)', fontSize: '11px' }}>
{pskFilterCount > 0
? 'No spots match filters'
: activeTab === 'tx'
? 'Waiting for spots... (TX to see reports)'
: 'No stations heard yet'}
</div>
) : (
filteredReports.slice(0, 25).map((report, i) => {
const freqMHz = report.freqMHz || (report.freq ? (report.freq / 1000000).toFixed(3) : '?');
const color = getFreqColor(freqMHz);
const displayCall = activeTab === 'tx' ? report.receiver : report.sender;
const grid = activeTab === 'tx' ? report.receiverGrid : report.senderGrid;
return (
<div
key={`${displayCall}-${report.freq}-${i}`}
onClick={() => onShowOnMap?.(report)}
style={{
display: 'grid',
gridTemplateColumns: '52px 1fr auto',
gap: '5px',
padding: '3px 4px',
borderRadius: '2px',
background: i % 2 === 0 ? 'rgba(255,255,255,0.02)' : 'transparent',
cursor: report.lat && report.lon ? 'pointer' : 'default',
}}
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(68,136,255,0.12)'}
onMouseLeave={(e) => e.currentTarget.style.background = i % 2 === 0 ? 'rgba(255,255,255,0.02)' : 'transparent'}
>
<span style={{ color, fontWeight: '600', fontSize: '10px' }}>{freqMHz}</span>
<span style={{
color: report.snr >= 0 ? '#4ade80' : report.snr >= -10 ? '#fbbf24' : '#f97316',
fontWeight: '600'
color: 'var(--text-primary)', fontWeight: '600', fontSize: '11px',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap'
}}>
{report.snr > 0 ? '+' : ''}{report.snr}
{displayCall}
{grid && <span style={{ color: 'var(--text-muted)', fontWeight: '400', marginLeft: '4px', fontSize: '9px' }}>{grid}</span>}
</span>
)}
<span style={{ color: 'var(--text-muted)', fontSize: '9px' }}>
{formatAge(report.age)}
</span>
</div>
<span style={{ display: 'flex', alignItems: 'center', gap: '3px', fontSize: '9px' }}>
<span style={{ color: 'var(--text-muted)' }}>{report.mode}</span>
{report.snr != null && (
<span style={{ color: report.snr >= 0 ? '#4ade80' : report.snr >= -10 ? '#fbbf24' : '#f97316', fontWeight: '600' }}>
{report.snr > 0 ? '+' : ''}{report.snr}
</span>
)}
<span style={{ color: 'var(--text-muted)' }}>{formatAge(report.age)}</span>
</span>
</div>
);
})
)}
</>
)}
{/* === WSJT-X content === */}
{panelMode === 'wsjtx' && (
<>
{/* No client connected */}
{!wsjtxLoading && activeClients.length === 0 && wsjtxDecodes.length === 0 ? (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexDirection: 'column', gap: '8px', color: 'var(--text-muted)',
fontSize: '11px', textAlign: 'center', padding: '16px 8px', height: '100%'
}}>
<div style={{ fontSize: '12px' }}>Waiting for WSJT-X...</div>
{wsjtxRelayEnabled ? (
<div style={{ fontSize: '10px', opacity: 0.8, lineHeight: 1.6 }}>
<div style={{ marginBottom: '8px' }}>
Download the relay agent for your PC:
</div>
<div style={{ display: 'flex', gap: '4px', justifyContent: 'center', flexWrap: 'wrap' }}>
<a href="/api/wsjtx/relay/download/linux"
style={{
padding: '4px 10px', borderRadius: '4px', fontSize: '10px', fontWeight: '600',
background: 'rgba(167,139,250,0.2)', border: '1px solid #a78bfa55',
color: '#a78bfa', textDecoration: 'none', cursor: 'pointer',
}}>🐧 Linux</a>
<a href="/api/wsjtx/relay/download/mac"
style={{
padding: '4px 10px', borderRadius: '4px', fontSize: '10px', fontWeight: '600',
background: 'rgba(167,139,250,0.2)', border: '1px solid #a78bfa55',
color: '#a78bfa', textDecoration: 'none', cursor: 'pointer',
}}>🍎 Mac</a>
<a href="/api/wsjtx/relay/download/windows"
style={{
padding: '4px 10px', borderRadius: '4px', fontSize: '10px', fontWeight: '600',
background: 'rgba(167,139,250,0.2)', border: '1px solid #a78bfa55',
color: '#a78bfa', textDecoration: 'none', cursor: 'pointer',
}}>🪟 Windows</a>
</div>
<div style={{ fontSize: '9px', opacity: 0.5, marginTop: '6px' }}>
Requires Node.js · Run the script, then start WSJT-X
</div>
</div>
) : (
<div style={{ fontSize: '10px', opacity: 0.6, lineHeight: 1.5 }}>
In WSJT-X: Settings Reporting UDP Server
<br />
Address: 127.0.0.1 &nbsp; Port: {wsjtxPort || 2237}
</div>
)}
</div>
);
})}
) : wsjtxTab === 'decodes' ? (
<>
{filteredDecodes.length === 0 ? (
<div style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '16px', fontSize: '11px' }}>
{wsjtxDecodes.length > 0 ? 'No decodes match filter' : 'Listening...'}
</div>
) : (
filteredDecodes.map((d, i) => (
<div
key={d.id || i}
style={{
display: 'flex', gap: '5px', padding: '2px 2px',
borderBottom: '1px solid var(--border-color)',
alignItems: 'baseline',
opacity: d.lowConfidence ? 0.5 : 1,
}}
>
<span style={{ color: 'var(--text-muted)', minWidth: '44px', fontSize: '10px' }}>{d.time}</span>
<span style={{ color: getSnrColor(d.snr), minWidth: '26px', textAlign: 'right', fontSize: '10px' }}>
{d.snr != null ? (d.snr >= 0 ? `+${d.snr}` : d.snr) : ''}
</span>
<span style={{ color: 'var(--text-muted)', minWidth: '24px', textAlign: 'right', fontSize: '10px' }}>{d.dt}</span>
<span style={{
color: d.band ? getBandColor(d.dialFrequency / 1000000) : 'var(--text-muted)',
minWidth: '32px', textAlign: 'right', fontSize: '10px'
}}>{d.freq}</span>
<span style={{
color: getMsgColor(d), flex: 1, whiteSpace: 'nowrap',
overflow: 'hidden', textOverflow: 'ellipsis',
}}>{d.message}</span>
</div>
))
)}
</>
) : (
/* QSOs tab */
<>
{wsjtxQsos.length === 0 ? (
<div style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '16px', fontSize: '11px' }}>
No QSOs logged yet
</div>
) : (
[...wsjtxQsos].reverse().map((q, i) => (
<div key={i} style={{
display: 'flex', gap: '5px', padding: '3px 2px',
borderBottom: '1px solid var(--border-color)', alignItems: 'baseline',
}}>
<span style={{
color: q.band ? getBandColor(q.frequency / 1000000) : 'var(--accent-green)',
fontWeight: '600', minWidth: '65px'
}}>{q.dxCall}</span>
<span style={{ color: 'var(--text-muted)', fontSize: '10px' }}>{q.band}</span>
<span style={{ color: 'var(--text-muted)', fontSize: '10px' }}>{q.mode}</span>
<span style={{ color: 'var(--text-muted)', fontSize: '10px' }}>{q.reportSent}/{q.reportRecv}</span>
{q.dxGrid && <span style={{ color: '#a78bfa', fontSize: '10px' }}>{q.dxGrid}</span>}
</div>
))
)}
</>
)}
</>
)}
</div>
{/* ── WSJT-X status footer ── */}
{panelMode === 'wsjtx' && activeClients.length > 0 && (
<div style={{
fontSize: '9px', color: 'var(--text-muted)',
borderTop: '1px solid var(--border-color)',
paddingTop: '2px', marginTop: '2px',
display: 'flex', justifyContent: 'space-between', flexShrink: 0
}}>
<span>{activeClients.map(([id, c]) => `${id}${c.version ? ` v${c.version}` : ''}`).join(', ')}</span>
{primaryClient?.dialFrequency && (
<span style={{ color: '#a78bfa' }}>{(primaryClient.dialFrequency / 1000000).toFixed(6)} MHz</span>
)}
</div>
)}
</div>
@ -320,5 +466,4 @@ const PSKReporterPanel = ({
};
export default PSKReporterPanel;
export { PSKReporterPanel };

@ -14,6 +14,7 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
const [lon, setLon] = useState(config?.location?.lon || 0);
const [theme, setTheme] = useState(config?.theme || 'dark');
const [layout, setLayout] = useState(config?.layout || 'modern');
const [timezone, setTimezone] = useState(config?.timezone || '');
const [dxClusterSource, setDxClusterSource] = useState(config?.dxClusterSource || 'dxspider-proxy');
const { t, i18n } = useTranslation();
@ -28,6 +29,7 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
setLon(config.location?.lon || 0);
setTheme(config.theme || 'dark');
setLayout(config.layout || 'modern');
setTimezone(config.timezone || '');
setDxClusterSource(config.dxClusterSource || 'dxspider-proxy');
if (config.location?.lat && config.location?.lon) {
setGridSquare(calculateGridSquare(config.location.lat, config.location.lon));
@ -148,6 +150,7 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
location: { lat: parseFloat(lat), lon: parseFloat(lon) },
theme,
layout,
timezone,
dxClusterSource
});
onClose();
@ -451,6 +454,112 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
</div>
{/* DX Cluster Source */}
<div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '8px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}>
🕐 Timezone
</label>
<select
value={timezone}
onChange={(e) => setTimezone(e.target.value)}
style={{
width: '100%',
padding: '12px',
background: 'var(--bg-tertiary)',
border: '1px solid var(--border-color)',
borderRadius: '6px',
color: timezone ? 'var(--accent-green)' : 'var(--text-muted)',
fontSize: '14px',
fontFamily: 'JetBrains Mono, monospace',
cursor: 'pointer'
}}
>
<option value="">Auto (browser default)</option>
<optgroup label="North America">
<option value="America/New_York">Eastern (New York)</option>
<option value="America/Chicago">Central (Chicago)</option>
<option value="America/Denver">Mountain (Denver)</option>
<option value="America/Los_Angeles">Pacific (Los Angeles)</option>
<option value="America/Anchorage">Alaska</option>
<option value="Pacific/Honolulu">Hawaii</option>
<option value="America/Phoenix">Arizona (no DST)</option>
<option value="America/Regina">Saskatchewan (no DST)</option>
<option value="America/Halifax">Atlantic (Halifax)</option>
<option value="America/St_Johns">Newfoundland</option>
<option value="America/Toronto">Ontario (Toronto)</option>
<option value="America/Winnipeg">Manitoba (Winnipeg)</option>
<option value="America/Edmonton">Alberta (Edmonton)</option>
<option value="America/Vancouver">BC (Vancouver)</option>
<option value="America/Mexico_City">Mexico City</option>
</optgroup>
<optgroup label="Europe">
<option value="Europe/London">UK (London)</option>
<option value="Europe/Dublin">Ireland (Dublin)</option>
<option value="Europe/Paris">Central Europe (Paris)</option>
<option value="Europe/Berlin">Germany (Berlin)</option>
<option value="Europe/Rome">Italy (Rome)</option>
<option value="Europe/Madrid">Spain (Madrid)</option>
<option value="Europe/Amsterdam">Netherlands (Amsterdam)</option>
<option value="Europe/Brussels">Belgium (Brussels)</option>
<option value="Europe/Stockholm">Sweden (Stockholm)</option>
<option value="Europe/Helsinki">Finland (Helsinki)</option>
<option value="Europe/Athens">Greece (Athens)</option>
<option value="Europe/Bucharest">Romania (Bucharest)</option>
<option value="Europe/Moscow">Russia (Moscow)</option>
<option value="Europe/Warsaw">Poland (Warsaw)</option>
<option value="Europe/Zurich">Switzerland (Zurich)</option>
<option value="Europe/Lisbon">Portugal (Lisbon)</option>
</optgroup>
<optgroup label="Asia & Pacific">
<option value="Asia/Tokyo">Japan (Tokyo)</option>
<option value="Asia/Seoul">Korea (Seoul)</option>
<option value="Asia/Shanghai">China (Shanghai)</option>
<option value="Asia/Hong_Kong">Hong Kong</option>
<option value="Asia/Taipei">Taiwan (Taipei)</option>
<option value="Asia/Singapore">Singapore</option>
<option value="Asia/Kolkata">India (Kolkata)</option>
<option value="Asia/Dubai">UAE (Dubai)</option>
<option value="Asia/Riyadh">Saudi Arabia (Riyadh)</option>
<option value="Asia/Tehran">Iran (Tehran)</option>
<option value="Asia/Bangkok">Thailand (Bangkok)</option>
<option value="Asia/Jakarta">Indonesia (Jakarta)</option>
<option value="Asia/Manila">Philippines (Manila)</option>
<option value="Australia/Sydney">Australia Eastern (Sydney)</option>
<option value="Australia/Adelaide">Australia Central (Adelaide)</option>
<option value="Australia/Perth">Australia Western (Perth)</option>
<option value="Pacific/Auckland">New Zealand (Auckland)</option>
<option value="Pacific/Fiji">Fiji</option>
</optgroup>
<optgroup label="South America">
<option value="America/Sao_Paulo">Brazil (São Paulo)</option>
<option value="America/Argentina/Buenos_Aires">Argentina (Buenos Aires)</option>
<option value="America/Santiago">Chile (Santiago)</option>
<option value="America/Bogota">Colombia (Bogotá)</option>
<option value="America/Lima">Peru (Lima)</option>
<option value="America/Caracas">Venezuela (Caracas)</option>
</optgroup>
<optgroup label="Africa">
<option value="Africa/Cairo">Egypt (Cairo)</option>
<option value="Africa/Johannesburg">South Africa (Johannesburg)</option>
<option value="Africa/Lagos">Nigeria (Lagos)</option>
<option value="Africa/Nairobi">Kenya (Nairobi)</option>
<option value="Africa/Casablanca">Morocco (Casablanca)</option>
</optgroup>
<optgroup label="Other">
<option value="UTC">UTC</option>
<option value="Atlantic/Reykjavik">Iceland (Reykjavik)</option>
<option value="Atlantic/Azores">Azores</option>
<option value="Indian/Maldives">Maldives</option>
<option value="Indian/Mauritius">Mauritius</option>
</optgroup>
</select>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '6px' }}>
Set this if your local time shows incorrectly (e.g. same as UTC).
Privacy browsers like Librewolf may spoof your timezone.
{timezone ? '' : ' Currently using browser default.'}
</div>
</div>
{/* DX Cluster Source - original */}
<div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '8px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}>
{t('station.settings.dx.title')}

@ -27,12 +27,14 @@ export const WorldMap = ({
dxFilters,
satellites,
pskReporterSpots,
wsjtxSpots,
showDXPaths,
showDXLabels,
onToggleDXLabels,
showPOTA,
showSatellites,
showPSKReporter,
showWSJTX,
onToggleSatellites,
hoveredSpot
}) => {
@ -52,6 +54,7 @@ export const WorldMap = ({
const satMarkersRef = useRef([]);
const satTracksRef = useRef([]);
const pskMarkersRef = useRef([]);
const wsjtxMarkersRef = useRef([]);
const countriesLayerRef = useRef(null);
// Plugin system refs and state
@ -647,6 +650,87 @@ export const WorldMap = ({
}
}, [pskReporterSpots, showPSKReporter, deLocation]);
// Update WSJT-X markers (CQ callers with grid locators)
useEffect(() => {
if (!mapInstanceRef.current) return;
const map = mapInstanceRef.current;
wsjtxMarkersRef.current.forEach(m => map.removeLayer(m));
wsjtxMarkersRef.current = [];
const hasValidDE = deLocation &&
typeof deLocation.lat === 'number' && !isNaN(deLocation.lat) &&
typeof deLocation.lon === 'number' && !isNaN(deLocation.lon);
if (showWSJTX && wsjtxSpots && wsjtxSpots.length > 0 && hasValidDE) {
// Deduplicate by callsign - keep most recent
const seen = new Map();
wsjtxSpots.forEach(spot => {
const call = spot.caller || spot.dxCall || '';
if (call && (!seen.has(call) || spot.timestamp > seen.get(call).timestamp)) {
seen.set(call, spot);
}
});
seen.forEach((spot, call) => {
let spotLat = parseFloat(spot.lat);
let spotLon = parseFloat(spot.lon);
if (!isNaN(spotLat) && !isNaN(spotLon)) {
const freqMHz = spot.dialFrequency ? (spot.dialFrequency / 1000000) : 0;
const bandColor = freqMHz ? getBandColor(freqMHz) : '#a78bfa';
try {
// Draw line from DE to CQ caller
const points = getGreatCirclePoints(
deLocation.lat, deLocation.lon,
spotLat, spotLon,
50
);
if (points && Array.isArray(points) && points.length > 1 &&
points.every(p => Array.isArray(p) && !isNaN(p[0]) && !isNaN(p[1]))) {
const line = L.polyline(points, {
color: '#a78bfa',
weight: 1.5,
opacity: 0.4,
dashArray: '2, 6'
}).addTo(map);
wsjtxMarkersRef.current.push(line);
const endPoint = points[points.length - 1];
spotLat = endPoint[0];
spotLon = endPoint[1];
}
// Diamond-shaped marker to distinguish from PSK circles
const diamond = L.marker([spotLat, spotLon], {
icon: L.divIcon({
className: '',
html: `<div style="
width: 8px; height: 8px;
background: ${bandColor};
border: 1px solid #fff;
transform: rotate(45deg);
opacity: 0.9;
"></div>`,
iconSize: [8, 8],
iconAnchor: [4, 4]
})
}).bindPopup(`
<b>${call}</b> CQ<br>
${spot.grid || ''} ${spot.band || ''}<br>
${spot.mode || ''} SNR: ${spot.snr != null ? (spot.snr >= 0 ? '+' : '') + spot.snr : '?'} dB
`).addTo(map);
wsjtxMarkersRef.current.push(diamond);
} catch (err) {
// skip bad spots
}
}
});
}
}, [wsjtxSpots, showWSJTX, deLocation]);
return (
<div style={{ position: 'relative', height: '100%', minHeight: '200px' }}>
<div ref={mapRef} style={{ height: '100%', width: '100%', borderRadius: '8px', background: mapStyle === 'countries' ? '#4a90d9' : undefined }} />

@ -16,3 +16,4 @@ export { useDXpeditions } from './useDXpeditions.js';
export { useSatellites } from './useSatellites.js';
export { useSolarIndices } from './useSolarIndices.js';
export { usePSKReporter } from './usePSKReporter.js';
export { useWSJTX } from './useWSJTX.js';

@ -0,0 +1,104 @@
/**
* useWSJTX Hook
* Polls the server for WSJT-X UDP data (decoded messages, status, QSOs)
*
* WSJT-X sends decoded FT8/FT4/JT65/WSPR messages over UDP.
* The server listens on the configured port and this hook fetches the results.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
const POLL_INTERVAL = 2000; // Poll every 2 seconds for near-real-time feel
const API_URL = '/api/wsjtx';
const DECODES_URL = '/api/wsjtx/decodes';
export function useWSJTX(enabled = true) {
const [data, setData] = useState({
clients: {},
decodes: [],
qsos: [],
wspr: [],
stats: { totalDecodes: 0, totalQsos: 0, totalWspr: 0, activeClients: 0 },
enabled: false,
port: 2237,
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const lastTimestamp = useRef(0);
const fullFetchCounter = useRef(0);
// Lightweight poll - just new decodes since last check
const pollDecodes = useCallback(async () => {
if (!enabled) return;
try {
const url = lastTimestamp.current
? `${DECODES_URL}?since=${lastTimestamp.current}`
: DECODES_URL;
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
if (json.decodes?.length > 0) {
setData(prev => {
// Merge new decodes, dedup by id, keep last 200
const existing = new Set(prev.decodes.map(d => d.id));
const newDecodes = json.decodes.filter(d => !existing.has(d.id));
if (newDecodes.length === 0) return prev;
const merged = [...prev.decodes, ...newDecodes].slice(-200);
return { ...prev, decodes: merged, stats: { ...prev.stats, totalDecodes: merged.length } };
});
}
lastTimestamp.current = json.timestamp || Date.now();
setError(null);
} catch (e) {
// Silent fail for lightweight polls
}
}, [enabled]);
// Full fetch - get everything including status, QSOs, clients
const fetchFull = useCallback(async () => {
if (!enabled) return;
try {
const res = await fetch(API_URL);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
setData(json);
lastTimestamp.current = Date.now();
setLoading(false);
setError(null);
} catch (e) {
setError(e.message);
setLoading(false);
}
}, [enabled]);
// Initial full fetch
useEffect(() => {
if (enabled) fetchFull();
}, [enabled, fetchFull]);
// Polling - mostly lightweight, full refresh every 15s
useEffect(() => {
if (!enabled) return;
const interval = setInterval(() => {
fullFetchCounter.current++;
if (fullFetchCounter.current >= 8) { // Every ~16 seconds
fullFetchCounter.current = 0;
fetchFull();
} else {
pollDecodes();
}
}, POLL_INTERVAL);
return () => clearInterval(interval);
}, [enabled, fetchFull, pollDecodes]);
return {
...data,
loading,
error,
refresh: fetchFull,
};
}

@ -16,6 +16,7 @@ export const DEFAULT_CONFIG = {
units: 'imperial', // 'imperial' or 'metric'
theme: 'dark', // 'dark', 'light', 'legacy', or 'retro'
layout: 'modern', // 'modern' or 'classic'
timezone: '', // IANA timezone (e.g. 'America/Regina') — empty = browser default
use12Hour: true,
showSatellites: true,
showPota: true,
@ -104,6 +105,7 @@ export const loadConfig = () => {
units: serverConfig.units || config.units,
theme: serverConfig.theme || config.theme,
layout: serverConfig.layout || config.layout,
timezone: serverConfig.timezone || config.timezone,
use12Hour: serverConfig.timeFormat === '12',
showSatellites: serverConfig.showSatellites ?? config.showSatellites,
showPota: serverConfig.showPota ?? config.showPota,

@ -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…
Cancel
Save

Powered by TurnKey Linux.