parent
f6ea975bad
commit
675c4c9495
@ -0,0 +1,22 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm install --production
|
||||||
|
|
||||||
|
# Copy source
|
||||||
|
COPY server.js ./
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 3001
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
||||||
|
CMD wget --no-verbose --tries=1 --spider http://localhost:3001/health || exit 1
|
||||||
|
|
||||||
|
# Start
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@ -0,0 +1,189 @@
|
|||||||
|
# DX Spider Telnet Proxy
|
||||||
|
|
||||||
|
A microservice that maintains a persistent telnet connection to DX Spider cluster nodes and exposes the spots via a simple HTTP REST API.
|
||||||
|
|
||||||
|
## Why This Exists
|
||||||
|
|
||||||
|
Many cloud hosting platforms (including Railway) don't support outbound telnet connections. This proxy service solves that by:
|
||||||
|
|
||||||
|
1. Maintaining a persistent telnet connection to DX Spider
|
||||||
|
2. Accumulating spots in memory (30-minute retention)
|
||||||
|
3. Exposing spots via HTTP API that any app can consume
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Auto-reconnect** - Automatically reconnects on disconnect
|
||||||
|
- **Multi-node failover** - Cycles through multiple DX Spider nodes if one fails
|
||||||
|
- **Keepalive** - Sends periodic keepalive to maintain connection
|
||||||
|
- **Spot deduplication** - Prevents duplicate spots within 2-minute window
|
||||||
|
- **30-minute retention** - Accumulates spots for richer data
|
||||||
|
- **Mode detection** - Automatically detects CW, SSB, FT8, etc. from comments
|
||||||
|
- **CORS enabled** - Can be called from any frontend
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `PORT` | 3001 | HTTP server port |
|
||||||
|
| `CALLSIGN` | OPENHAMCLOCK | Callsign used for DX Spider login |
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### `GET /health`
|
||||||
|
Health check endpoint.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"connected": true,
|
||||||
|
"currentNode": "DX Spider UK",
|
||||||
|
"spotsInMemory": 142,
|
||||||
|
"totalSpotsReceived": 1847,
|
||||||
|
"lastSpotTime": "2025-01-31T12:34:56.789Z",
|
||||||
|
"connectionUptime": "3600s",
|
||||||
|
"uptime": "7200s"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/spots`
|
||||||
|
Get accumulated spots with full details.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
- `limit` (default: 50, max: 200) - Number of spots to return
|
||||||
|
- `since` (timestamp) - Only return spots after this timestamp
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"spots": [
|
||||||
|
{
|
||||||
|
"spotter": "W3ABC",
|
||||||
|
"freq": "14.025",
|
||||||
|
"freqKhz": 14025,
|
||||||
|
"call": "JA1XYZ",
|
||||||
|
"comment": "CW 599",
|
||||||
|
"time": "12:34z",
|
||||||
|
"mode": "CW",
|
||||||
|
"timestamp": 1706704496789,
|
||||||
|
"source": "DX Spider"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 142,
|
||||||
|
"connected": true,
|
||||||
|
"source": "DX Spider UK",
|
||||||
|
"timestamp": 1706704500000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/dxcluster/spots`
|
||||||
|
Get spots in simplified format (compatible with OpenHamClock).
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
- `limit` (default: 25, max: 100)
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"spotter": "W3ABC",
|
||||||
|
"freq": "14.025",
|
||||||
|
"call": "JA1XYZ",
|
||||||
|
"comment": "CW 599",
|
||||||
|
"time": "12:34z",
|
||||||
|
"mode": "CW",
|
||||||
|
"source": "DX Spider Proxy"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/stats`
|
||||||
|
Get statistics about spots.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"connected": true,
|
||||||
|
"currentNode": "DX Spider UK",
|
||||||
|
"totalSpots": 142,
|
||||||
|
"totalReceived": 1847,
|
||||||
|
"lastSpotTime": "2025-01-31T12:34:56.789Z",
|
||||||
|
"retentionMinutes": 30,
|
||||||
|
"bandCounts": {
|
||||||
|
"20m": 45,
|
||||||
|
"40m": 32,
|
||||||
|
"15m": 28,
|
||||||
|
"10m": 20
|
||||||
|
},
|
||||||
|
"modeCounts": {
|
||||||
|
"FT8": 67,
|
||||||
|
"CW": 35,
|
||||||
|
"SSB": 25
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/nodes`
|
||||||
|
List available DX Spider nodes.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nodes": [
|
||||||
|
{ "index": 0, "name": "DX Spider UK", "host": "dxspider.co.uk", "port": 7300, "active": true },
|
||||||
|
{ "index": 1, "name": "W6KK", "host": "w6kk.no-ip.org", "port": 7300, "active": false }
|
||||||
|
],
|
||||||
|
"currentIndex": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /api/reconnect`
|
||||||
|
Force reconnection to current node.
|
||||||
|
|
||||||
|
### `POST /api/switch-node`
|
||||||
|
Switch to a different node.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "index": 1 }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Railway
|
||||||
|
|
||||||
|
1. Create a new project in Railway
|
||||||
|
2. Connect your GitHub repo or upload files
|
||||||
|
3. Set environment variable: `CALLSIGN=YOURCALL`
|
||||||
|
4. Deploy!
|
||||||
|
|
||||||
|
The service will automatically start and connect to DX Spider.
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t dxspider-proxy .
|
||||||
|
docker run -p 3001:3001 -e CALLSIGN=YOURCALL dxspider-proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
CALLSIGN=YOURCALL npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using with OpenHamClock
|
||||||
|
|
||||||
|
Once deployed, update your OpenHamClock configuration to use this proxy as a DX cluster source:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://your-proxy-url.railway.app/api/dxcluster/spots
|
||||||
|
```
|
||||||
|
|
||||||
|
## DX Spider Nodes
|
||||||
|
|
||||||
|
The proxy cycles through these nodes on failure:
|
||||||
|
|
||||||
|
1. dxspider.co.uk:7300 (UK)
|
||||||
|
2. w6kk.no-ip.org:7300 (California)
|
||||||
|
3. dxc.nc7j.com:7373 (NC7J)
|
||||||
|
4. dx.k3lr.com:7300 (K3LR)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "dxspider-proxy",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "DX Spider Telnet to HTTP Proxy - Maintains persistent telnet connection and serves spots via REST API",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "node server.js"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"ham-radio",
|
||||||
|
"dx-cluster",
|
||||||
|
"dxspider",
|
||||||
|
"amateur-radio",
|
||||||
|
"telnet-proxy"
|
||||||
|
],
|
||||||
|
"author": "OpenHamClock",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^4.18.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
[build]
|
||||||
|
builder = "nixpacks"
|
||||||
|
|
||||||
|
[deploy]
|
||||||
|
startCommand = "node server.js"
|
||||||
|
healthcheckPath = "/health"
|
||||||
|
healthcheckTimeout = 30
|
||||||
|
restartPolicyType = "always"
|
||||||
@ -0,0 +1,435 @@
|
|||||||
|
/**
|
||||||
|
* DX Spider Telnet Proxy Service
|
||||||
|
*
|
||||||
|
* A microservice that maintains a persistent telnet connection to DX Spider,
|
||||||
|
* accumulates spots, and serves them via HTTP API.
|
||||||
|
*
|
||||||
|
* Designed to run on Railway as a standalone service.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const net = require('net');
|
||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
const CONFIG = {
|
||||||
|
// DX Spider nodes to try (in order)
|
||||||
|
nodes: [
|
||||||
|
{ host: 'dxspider.co.uk', port: 7300, name: 'DX Spider UK' },
|
||||||
|
{ host: 'w6kk.no-ip.org', port: 7300, name: 'W6KK' },
|
||||||
|
{ host: 'dxc.nc7j.com', port: 7373, name: 'NC7J' },
|
||||||
|
{ host: 'dx.k3lr.com', port: 7300, name: 'K3LR' }
|
||||||
|
],
|
||||||
|
callsign: process.env.CALLSIGN || 'OPENHAMCLOCK',
|
||||||
|
spotRetentionMs: 30 * 60 * 1000, // 30 minutes
|
||||||
|
reconnectDelayMs: 5000, // 5 seconds between reconnect attempts
|
||||||
|
maxReconnectAttempts: 5,
|
||||||
|
cleanupIntervalMs: 60000, // 1 minute
|
||||||
|
keepAliveIntervalMs: 120000 // 2 minutes - send keepalive
|
||||||
|
};
|
||||||
|
|
||||||
|
// State
|
||||||
|
let spots = [];
|
||||||
|
let client = null;
|
||||||
|
let connected = false;
|
||||||
|
let currentNode = null;
|
||||||
|
let currentNodeIndex = 0;
|
||||||
|
let reconnectAttempts = 0;
|
||||||
|
let lastSpotTime = null;
|
||||||
|
let totalSpotsReceived = 0;
|
||||||
|
let connectionStartTime = null;
|
||||||
|
let buffer = '';
|
||||||
|
let reconnectTimer = null;
|
||||||
|
let keepAliveTimer = null;
|
||||||
|
|
||||||
|
// Logging helper
|
||||||
|
const log = (level, message, data = null) => {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const logLine = `[${timestamp}] [${level}] ${message}`;
|
||||||
|
if (data) {
|
||||||
|
console.log(logLine, typeof data === 'object' ? JSON.stringify(data) : data);
|
||||||
|
} else {
|
||||||
|
console.log(logLine);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse a DX spot line from telnet
|
||||||
|
// Format: DX de SPOTTER: FREQ DXCALL comment time
|
||||||
|
const parseSpotLine = (line) => {
|
||||||
|
try {
|
||||||
|
// Match: DX de W3ABC: 14025.0 JA1XYZ CW 599 1234Z
|
||||||
|
const match = line.match(/^DX de\s+([A-Z0-9/]+):\s+(\d+\.?\d*)\s+([A-Z0-9/]+)\s+(.*)$/i);
|
||||||
|
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const spotter = match[1].toUpperCase();
|
||||||
|
const freqKhz = parseFloat(match[2]);
|
||||||
|
const dxCall = match[3].toUpperCase();
|
||||||
|
let comment = match[4].trim();
|
||||||
|
|
||||||
|
// Extract time from end of comment (format: 1234Z or 1234z)
|
||||||
|
let time = '';
|
||||||
|
const timeMatch = comment.match(/(\d{4})[Zz]\s*$/);
|
||||||
|
if (timeMatch) {
|
||||||
|
time = timeMatch[1].substring(0, 2) + ':' + timeMatch[1].substring(2, 4) + 'z';
|
||||||
|
comment = comment.replace(/\d{4}[Zz]\s*$/, '').trim();
|
||||||
|
} else {
|
||||||
|
// Use current UTC time
|
||||||
|
const now = new Date();
|
||||||
|
time = String(now.getUTCHours()).padStart(2, '0') + ':' +
|
||||||
|
String(now.getUTCMinutes()).padStart(2, '0') + 'z';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect mode from comment
|
||||||
|
let mode = null;
|
||||||
|
const upperComment = comment.toUpperCase();
|
||||||
|
if (upperComment.includes('FT8')) mode = 'FT8';
|
||||||
|
else if (upperComment.includes('FT4')) mode = 'FT4';
|
||||||
|
else if (upperComment.includes('CW')) mode = 'CW';
|
||||||
|
else if (upperComment.includes('SSB') || upperComment.includes('USB') || upperComment.includes('LSB')) mode = 'SSB';
|
||||||
|
else if (upperComment.includes('RTTY')) mode = 'RTTY';
|
||||||
|
else if (upperComment.includes('PSK')) mode = 'PSK';
|
||||||
|
else if (upperComment.includes('FM')) mode = 'FM';
|
||||||
|
else if (upperComment.includes('AM')) mode = 'AM';
|
||||||
|
|
||||||
|
return {
|
||||||
|
spotter,
|
||||||
|
freq: (freqKhz / 1000).toFixed(3), // Convert kHz to MHz string
|
||||||
|
freqKhz,
|
||||||
|
call: dxCall,
|
||||||
|
comment,
|
||||||
|
time,
|
||||||
|
mode,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
source: 'DX Spider'
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
log('ERROR', 'Failed to parse spot line', { line, error: err.message });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add a spot to the accumulator
|
||||||
|
const addSpot = (spot) => {
|
||||||
|
if (!spot) return;
|
||||||
|
|
||||||
|
// Check for duplicate (same call + freq within 2 minutes)
|
||||||
|
const isDuplicate = spots.some(existing =>
|
||||||
|
existing.call === spot.call &&
|
||||||
|
existing.freq === spot.freq &&
|
||||||
|
(spot.timestamp - existing.timestamp) < 120000
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isDuplicate) {
|
||||||
|
spots.unshift(spot); // Add to beginning (newest first)
|
||||||
|
totalSpotsReceived++;
|
||||||
|
lastSpotTime = new Date();
|
||||||
|
log('SPOT', `${spot.call} on ${spot.freq} MHz by ${spot.spotter}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clean up old spots
|
||||||
|
const cleanupSpots = () => {
|
||||||
|
const cutoff = Date.now() - CONFIG.spotRetentionMs;
|
||||||
|
const before = spots.length;
|
||||||
|
spots = spots.filter(s => s.timestamp > cutoff);
|
||||||
|
const removed = before - spots.length;
|
||||||
|
if (removed > 0) {
|
||||||
|
log('CLEANUP', `Removed ${removed} expired spots, ${spots.length} remaining`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Connect to DX Spider
|
||||||
|
const connect = () => {
|
||||||
|
if (client) {
|
||||||
|
try {
|
||||||
|
client.destroy();
|
||||||
|
} catch (e) {}
|
||||||
|
client = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reconnectTimer) {
|
||||||
|
clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = CONFIG.nodes[currentNodeIndex];
|
||||||
|
currentNode = node;
|
||||||
|
|
||||||
|
log('CONNECT', `Attempting connection to ${node.name} (${node.host}:${node.port})`);
|
||||||
|
|
||||||
|
client = new net.Socket();
|
||||||
|
client.setTimeout(30000);
|
||||||
|
|
||||||
|
client.connect(node.port, node.host, () => {
|
||||||
|
connected = true;
|
||||||
|
reconnectAttempts = 0;
|
||||||
|
connectionStartTime = new Date();
|
||||||
|
buffer = '';
|
||||||
|
log('CONNECT', `Connected to ${node.name}`);
|
||||||
|
|
||||||
|
// Send login after short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
if (client && connected) {
|
||||||
|
client.write(CONFIG.callsign + '\r\n');
|
||||||
|
log('AUTH', `Sent callsign: ${CONFIG.callsign}`);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// Start keepalive
|
||||||
|
startKeepAlive();
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('data', (data) => {
|
||||||
|
buffer += data.toString();
|
||||||
|
|
||||||
|
// Process complete lines
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
|
||||||
|
// Check if it's a DX spot
|
||||||
|
if (trimmed.startsWith('DX de ')) {
|
||||||
|
const spot = parseSpotLine(trimmed);
|
||||||
|
if (spot) {
|
||||||
|
addSpot(spot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('timeout', () => {
|
||||||
|
log('TIMEOUT', 'Connection timed out');
|
||||||
|
handleDisconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('error', (err) => {
|
||||||
|
log('ERROR', `Connection error: ${err.message}`);
|
||||||
|
handleDisconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('close', () => {
|
||||||
|
log('CLOSE', 'Connection closed');
|
||||||
|
handleDisconnect();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start keepalive timer
|
||||||
|
const startKeepAlive = () => {
|
||||||
|
if (keepAliveTimer) {
|
||||||
|
clearInterval(keepAliveTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
keepAliveTimer = setInterval(() => {
|
||||||
|
if (client && connected) {
|
||||||
|
try {
|
||||||
|
// Send a harmless command to keep connection alive
|
||||||
|
client.write('\r\n');
|
||||||
|
log('KEEPALIVE', 'Sent keepalive');
|
||||||
|
} catch (e) {
|
||||||
|
log('ERROR', 'Keepalive failed', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, CONFIG.keepAliveIntervalMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle disconnection and reconnection
|
||||||
|
const handleDisconnect = () => {
|
||||||
|
connected = false;
|
||||||
|
|
||||||
|
if (keepAliveTimer) {
|
||||||
|
clearInterval(keepAliveTimer);
|
||||||
|
keepAliveTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnectAttempts++;
|
||||||
|
|
||||||
|
if (reconnectAttempts >= CONFIG.maxReconnectAttempts) {
|
||||||
|
// Try next node
|
||||||
|
currentNodeIndex = (currentNodeIndex + 1) % CONFIG.nodes.length;
|
||||||
|
reconnectAttempts = 0;
|
||||||
|
log('FAILOVER', `Switching to node: ${CONFIG.nodes[currentNodeIndex].name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
log('RECONNECT', `Attempting reconnect in ${CONFIG.reconnectDelayMs}ms (attempt ${reconnectAttempts + 1})`);
|
||||||
|
|
||||||
|
reconnectTimer = setTimeout(() => {
|
||||||
|
connect();
|
||||||
|
}, CONFIG.reconnectDelayMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// HTTP API ENDPOINTS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
connected,
|
||||||
|
currentNode: currentNode?.name || 'none',
|
||||||
|
spotsInMemory: spots.length,
|
||||||
|
totalSpotsReceived,
|
||||||
|
lastSpotTime: lastSpotTime?.toISOString() || null,
|
||||||
|
connectionUptime: connectionStartTime ?
|
||||||
|
Math.floor((Date.now() - connectionStartTime.getTime()) / 1000) + 's' : null,
|
||||||
|
uptime: process.uptime() + 's'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get spots
|
||||||
|
app.get('/api/spots', (req, res) => {
|
||||||
|
const limit = Math.min(parseInt(req.query.limit) || 50, 200);
|
||||||
|
const since = parseInt(req.query.since) || 0; // Timestamp filter
|
||||||
|
|
||||||
|
let filteredSpots = spots;
|
||||||
|
|
||||||
|
// Filter by timestamp if provided
|
||||||
|
if (since > 0) {
|
||||||
|
filteredSpots = spots.filter(s => s.timestamp > since);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
spots: filteredSpots.slice(0, limit),
|
||||||
|
total: filteredSpots.length,
|
||||||
|
connected,
|
||||||
|
source: currentNode?.name || 'disconnected',
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get spots in simple format (for compatibility with existing DX cluster endpoint)
|
||||||
|
app.get('/api/dxcluster/spots', (req, res) => {
|
||||||
|
const limit = Math.min(parseInt(req.query.limit) || 25, 100);
|
||||||
|
|
||||||
|
const formattedSpots = spots.slice(0, limit).map(s => ({
|
||||||
|
spotter: s.spotter,
|
||||||
|
freq: s.freq,
|
||||||
|
call: s.call,
|
||||||
|
comment: s.comment,
|
||||||
|
time: s.time,
|
||||||
|
mode: s.mode,
|
||||||
|
source: 'DX Spider Proxy'
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(formattedSpots);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stats endpoint
|
||||||
|
app.get('/api/stats', (req, res) => {
|
||||||
|
// Calculate spots per band
|
||||||
|
const bandCounts = {};
|
||||||
|
spots.forEach(s => {
|
||||||
|
const freq = s.freqKhz;
|
||||||
|
let band = 'other';
|
||||||
|
if (freq >= 1800 && freq <= 2000) band = '160m';
|
||||||
|
else if (freq >= 3500 && freq <= 4000) band = '80m';
|
||||||
|
else if (freq >= 7000 && freq <= 7300) band = '40m';
|
||||||
|
else if (freq >= 10100 && freq <= 10150) band = '30m';
|
||||||
|
else if (freq >= 14000 && freq <= 14350) band = '20m';
|
||||||
|
else if (freq >= 18068 && freq <= 18168) band = '17m';
|
||||||
|
else if (freq >= 21000 && freq <= 21450) band = '15m';
|
||||||
|
else if (freq >= 24890 && freq <= 24990) band = '12m';
|
||||||
|
else if (freq >= 28000 && freq <= 29700) band = '10m';
|
||||||
|
else if (freq >= 50000 && freq <= 54000) band = '6m';
|
||||||
|
|
||||||
|
bandCounts[band] = (bandCounts[band] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate spots per mode
|
||||||
|
const modeCounts = {};
|
||||||
|
spots.forEach(s => {
|
||||||
|
const mode = s.mode || 'unknown';
|
||||||
|
modeCounts[mode] = (modeCounts[mode] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
connected,
|
||||||
|
currentNode: currentNode?.name || 'none',
|
||||||
|
totalSpots: spots.length,
|
||||||
|
totalReceived: totalSpotsReceived,
|
||||||
|
lastSpotTime: lastSpotTime?.toISOString() || null,
|
||||||
|
retentionMinutes: CONFIG.spotRetentionMs / 60000,
|
||||||
|
bandCounts,
|
||||||
|
modeCounts
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force reconnect
|
||||||
|
app.post('/api/reconnect', (req, res) => {
|
||||||
|
log('API', 'Force reconnect requested');
|
||||||
|
handleDisconnect();
|
||||||
|
res.json({ status: 'reconnecting' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Switch node
|
||||||
|
app.post('/api/switch-node', (req, res) => {
|
||||||
|
const { index } = req.body;
|
||||||
|
if (typeof index === 'number' && index >= 0 && index < CONFIG.nodes.length) {
|
||||||
|
currentNodeIndex = index;
|
||||||
|
reconnectAttempts = 0;
|
||||||
|
log('API', `Switching to node index ${index}: ${CONFIG.nodes[index].name}`);
|
||||||
|
handleDisconnect();
|
||||||
|
res.json({ status: 'switching', node: CONFIG.nodes[index].name });
|
||||||
|
} else {
|
||||||
|
res.status(400).json({ error: 'Invalid node index', availableNodes: CONFIG.nodes.map(n => n.name) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// List available nodes
|
||||||
|
app.get('/api/nodes', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
nodes: CONFIG.nodes.map((n, i) => ({
|
||||||
|
index: i,
|
||||||
|
name: n.name,
|
||||||
|
host: n.host,
|
||||||
|
port: n.port,
|
||||||
|
active: i === currentNodeIndex
|
||||||
|
})),
|
||||||
|
currentIndex: currentNodeIndex
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// STARTUP
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
|
// Start cleanup interval
|
||||||
|
setInterval(cleanupSpots, CONFIG.cleanupIntervalMs);
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
log('START', `DX Spider Proxy listening on port ${PORT}`);
|
||||||
|
log('CONFIG', `Callsign: ${CONFIG.callsign}`);
|
||||||
|
log('CONFIG', `Spot retention: ${CONFIG.spotRetentionMs / 60000} minutes`);
|
||||||
|
log('CONFIG', `Available nodes: ${CONFIG.nodes.map(n => n.name).join(', ')}`);
|
||||||
|
|
||||||
|
// Connect to DX Spider
|
||||||
|
connect();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle graceful shutdown
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
log('SHUTDOWN', 'Received SIGTERM, shutting down...');
|
||||||
|
if (client) {
|
||||||
|
client.destroy();
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
log('SHUTDOWN', 'Received SIGINT, shutting down...');
|
||||||
|
if (client) {
|
||||||
|
client.destroy();
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
Loading…
Reference in new issue