- 🌐 DX CLUSTER
● LIVE
+
+ 🌐 DX CLUSTER ● LIVE
+ {dxCluster.activeSource && {dxCluster.activeSource}}
{dxCluster.data.slice(0, 8).map((s, i) => (
diff --git a/server.js b/server.js
index 71d2105..b6c8d09 100644
--- a/server.js
+++ b/server.js
@@ -15,6 +15,7 @@ const express = require('express');
const cors = require('cors');
const path = require('path');
const fetch = require('node-fetch');
+const net = require('net');
const app = express();
const PORT = process.env.PORT || 3000;
@@ -115,163 +116,307 @@ app.get('/api/hamqsl/conditions', async (req, res) => {
}
});
-// DX Cluster proxy - fetches from multiple sources
+// DX Cluster proxy - fetches from selectable sources
+// Query param: ?source=hamqth|dxheat|dxsummit|dxspider|auto (default: auto)
+
+// Cache for DX Spider telnet spots (to avoid too many connections)
+let dxSpiderCache = { spots: [], timestamp: 0 };
+const DXSPIDER_CACHE_TTL = 60000; // 60 seconds cache
+
app.get('/api/dxcluster/spots', async (req, res) => {
- // Source 1: HamQTH (uses ^ delimiter!)
- try {
+ const source = (req.query.source || 'auto').toLowerCase();
+
+ // Helper function for HamQTH
+ async function fetchHamQTH() {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
- const response = await fetch('https://www.hamqth.com/dxc_csv.php', {
- headers: { 'User-Agent': 'OpenHamClock/3.3' },
- signal: controller.signal
- });
- clearTimeout(timeout);
-
- if (response.ok) {
- const text = await response.text();
-
- const lines = text.trim().split('\n').filter(line => line.trim() && !line.startsWith('#'));
+ try {
+ const response = await fetch('https://www.hamqth.com/dxc_csv.php', {
+ headers: { 'User-Agent': 'OpenHamClock/3.4' },
+ signal: controller.signal
+ });
+ clearTimeout(timeout);
- if (lines.length > 0) {
- const spots = [];
- for (const line of lines.slice(0, 25)) {
- // HamQTH format uses ^ delimiter:
- // spotter^freq^dx_call^comment^time date^^^continent^band^country^id
- // Example: F5PAC^7022.0^KP5/NP3VI^Up 2^0610 2026-01-30^^^NA^40M^Desecheo Island^43
- const parts = line.split('^');
-
- if (parts.length >= 5) {
- const spotter = parts[0] || '';
- const freqKhz = parts[1] || '';
- const dxCall = parts[2] || '';
- const comment = parts[3] || '';
- const timeDate = parts[4] || ''; // "0610 2026-01-30"
- const band = parts[9] || '';
+ if (response.ok) {
+ const text = await response.text();
+ const lines = text.trim().split('\n').filter(line => line.trim() && !line.startsWith('#'));
+
+ if (lines.length > 0) {
+ const spots = [];
+ for (const line of lines.slice(0, 25)) {
+ const parts = line.split('^');
- // Parse frequency (already in kHz, convert to MHz)
- const freqNum = parseFloat(freqKhz);
- if (!isNaN(freqNum) && freqNum > 0 && dxCall) {
- // Convert kHz to MHz for display (7022.0 -> 7.022)
- const freqMhz = (freqNum / 1000).toFixed(3);
+ if (parts.length >= 5) {
+ const spotter = parts[0] || '';
+ const freqKhz = parts[1] || '';
+ const dxCall = parts[2] || '';
+ const comment = parts[3] || '';
+ const timeDate = parts[4] || '';
+ const band = parts[9] || '';
- // Extract time from "0610 2026-01-30" -> "06:10z"
- let time = '';
- if (timeDate && timeDate.length >= 4) {
- const timeStr = timeDate.substring(0, 4);
- time = timeStr.substring(0, 2) + ':' + timeStr.substring(2, 4) + 'z';
+ const freqNum = parseFloat(freqKhz);
+ if (!isNaN(freqNum) && freqNum > 0 && dxCall) {
+ const freqMhz = (freqNum / 1000).toFixed(3);
+ let time = '';
+ if (timeDate && timeDate.length >= 4) {
+ const timeStr = timeDate.substring(0, 4);
+ time = timeStr.substring(0, 2) + ':' + timeStr.substring(2, 4) + 'z';
+ }
+
+ spots.push({
+ freq: freqMhz,
+ call: dxCall,
+ comment: comment + (band ? ' ' + band : ''),
+ time: time,
+ spotter: spotter,
+ source: 'HamQTH'
+ });
}
-
- spots.push({
- freq: freqMhz,
- call: dxCall,
- comment: comment + (band ? ' ' + band : ''),
- time: time,
- spotter: spotter
- });
}
}
+
+ if (spots.length > 0) {
+ console.log('[DX Cluster] HamQTH:', spots.length, 'spots');
+ return spots;
+ }
}
+ }
+ } catch (error) {
+ clearTimeout(timeout);
+ if (error.name !== 'AbortError') {
+ console.error('[DX Cluster] HamQTH error:', error.message);
+ }
+ }
+ return null;
+ }
+
+ // Helper function for DXHeat
+ async function fetchDXHeat() {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 8000);
+
+ try {
+ const response = await fetch('https://dxheat.com/dxc/data.php', {
+ headers: {
+ 'User-Agent': 'OpenHamClock/3.4',
+ 'Accept': 'application/json'
+ },
+ signal: controller.signal
+ });
+ clearTimeout(timeout);
+
+ if (response.ok) {
+ const text = await response.text();
+ const data = JSON.parse(text);
+ const spots = data.spots || data;
- console.log('[DX Cluster] HamQTH:', spots.length, 'spots');
- if (spots.length > 0) {
- return res.json(spots);
+ if (Array.isArray(spots) && spots.length > 0) {
+ const mapped = spots.slice(0, 25).map(spot => ({
+ freq: spot.f || spot.frequency || '0.000',
+ call: spot.c || spot.dx || spot.callsign || 'UNKNOWN',
+ comment: spot.i || spot.info || '',
+ time: spot.t ? String(spot.t).substring(11, 16) + 'z' : '',
+ spotter: spot.s || spot.spotter || '',
+ source: 'DXHeat'
+ }));
+ console.log('[DX Cluster] DXHeat:', mapped.length, 'spots');
+ return mapped;
}
}
+ } catch (error) {
+ clearTimeout(timeout);
+ if (error.name !== 'AbortError') {
+ console.error('[DX Cluster] DXHeat error:', error.message);
+ }
}
- } catch (error) {
- if (error.name === 'AbortError') {
- // Timeout - normal if API is slow, try next source
- } else {
- console.error('[DX Cluster] HamQTH error:', error.message);
- }
+ return null;
}
-
- // Source 2: DX Summit
- try {
+
+ // Helper function for DX Summit
+ async function fetchDXSummit() {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
- const response = await fetch('https://www.dxsummit.fi/api/v1/spots?limit=25', {
- headers: {
- 'User-Agent': 'OpenHamClock/3.3 (Amateur Radio Dashboard)',
- 'Accept': 'application/json'
- },
- signal: controller.signal
- });
- clearTimeout(timeout);
-
- if (response.ok) {
- const text = await response.text();
+ try {
+ const response = await fetch('https://www.dxsummit.fi/api/v1/spots?limit=25', {
+ headers: {
+ 'User-Agent': 'OpenHamClock/3.4 (Amateur Radio Dashboard)',
+ 'Accept': 'application/json'
+ },
+ signal: controller.signal
+ });
+ clearTimeout(timeout);
- try {
+ if (response.ok) {
+ const text = await response.text();
const data = JSON.parse(text);
if (Array.isArray(data) && data.length > 0) {
- const spots = data.slice(0, 20).map(spot => ({
+ const spots = data.slice(0, 25).map(spot => ({
freq: spot.frequency ? String(spot.frequency) : '0.000',
call: spot.dx_call || spot.dxcall || spot.callsign || 'UNKNOWN',
comment: spot.info || spot.comment || '',
time: spot.time ? String(spot.time).substring(0, 5) + 'z' : '',
- spotter: spot.spotter || spot.de || ''
+ spotter: spot.spotter || spot.de || '',
+ source: 'DX Summit'
}));
console.log('[DX Cluster] DX Summit:', spots.length, 'spots');
- return res.json(spots);
+ return spots;
}
- } catch (parseErr) {
- // Parse error, try next source
+ }
+ } catch (error) {
+ clearTimeout(timeout);
+ if (error.name !== 'AbortError') {
+ console.error('[DX Cluster] DX Summit error:', error.message);
}
}
- } catch (error) {
- if (error.name !== 'AbortError') {
- console.error('[DX Cluster] DX Summit error:', error.message);
- }
+ return null;
}
-
- // Source 3: DXHeat (backup)
- try {
- const controller = new AbortController();
- const timeout = setTimeout(() => controller.abort(), 8000);
-
- const response = await fetch('https://dxheat.com/dxc/data.php', {
- headers: {
- 'User-Agent': 'OpenHamClock/3.3',
- 'Accept': 'application/json'
- },
- signal: controller.signal
- });
- clearTimeout(timeout);
+
+ // Helper function for DX Spider (G6NHU-2) via telnet
+ async function fetchDXSpider() {
+ // Check cache first
+ if (Date.now() - dxSpiderCache.timestamp < DXSPIDER_CACHE_TTL && dxSpiderCache.spots.length > 0) {
+ console.log('[DX Cluster] DX Spider: returning', dxSpiderCache.spots.length, 'cached spots');
+ return dxSpiderCache.spots;
+ }
- if (response.ok) {
- const text = await response.text();
+ return new Promise((resolve) => {
+ const spots = [];
+ let buffer = '';
+ let gotSpots = false;
+ let loginSent = false;
+ let commandSent = false;
- try {
- const data = JSON.parse(text);
- const spots = data.spots || data;
+ const client = new net.Socket();
+ client.setTimeout(12000);
+
+ client.connect(7300, 'dxspider.co.uk', () => {
+ console.log('[DX Cluster] DX Spider: connected');
+ });
+
+ client.on('data', (data) => {
+ buffer += data.toString();
- if (Array.isArray(spots) && spots.length > 0) {
- const mapped = spots.slice(0, 20).map(spot => ({
- freq: spot.f || spot.frequency || '0.000',
- call: spot.c || spot.dx || spot.callsign || 'UNKNOWN',
- comment: spot.i || spot.info || '',
- time: spot.t ? String(spot.t).substring(11, 16) + 'z' : '',
- spotter: spot.s || spot.spotter || ''
- }));
- console.log('[DX Cluster] DXHeat:', mapped.length, 'spots');
- return res.json(mapped);
+ // Wait for login prompt
+ if (!loginSent && (buffer.includes('login:') || buffer.includes('Please enter your call'))) {
+ loginSent = true;
+ // Send a guest login (GUEST or anonymous callsign)
+ client.write('GUEST\r\n');
+ return;
}
- } catch (parseErr) {
- // Parse error
- }
- }
- } catch (error) {
- if (error.name !== 'AbortError') {
- console.error('[DX Cluster] DXHeat error:', error.message);
- }
+
+ // Wait for prompt after login, then send command
+ if (loginSent && !commandSent && (buffer.includes('Hello') || buffer.includes('de ') || buffer.includes('>'))) {
+ commandSent = true;
+ // Request last 25 spots
+ setTimeout(() => {
+ client.write('sh/dx 25\r\n');
+ }, 500);
+ return;
+ }
+
+ // Parse DX spots from the output
+ // Format: DX de W3LPL: 14195.0 TI5/AA8HH FT8 -09 dB 1234Z
+ const lines = buffer.split('\n');
+ for (const line of lines) {
+ if (line.includes('DX de ')) {
+ const match = line.match(/DX de ([A-Z0-9\/\-]+):\s+(\d+\.?\d*)\s+([A-Z0-9\/\-]+)\s+(.+?)\s+(\d{4})Z/i);
+ if (match) {
+ const spotter = match[1].replace(':', '');
+ const freqKhz = parseFloat(match[2]);
+ const dxCall = match[3];
+ const comment = match[4].trim();
+ const timeStr = match[5];
+
+ if (!isNaN(freqKhz) && freqKhz > 0 && dxCall) {
+ const freqMhz = (freqKhz / 1000).toFixed(3);
+ const time = timeStr.substring(0, 2) + ':' + timeStr.substring(2, 4) + 'z';
+
+ // Avoid duplicates
+ if (!spots.find(s => s.call === dxCall && s.freq === freqMhz)) {
+ spots.push({
+ freq: freqMhz,
+ call: dxCall,
+ comment: comment,
+ time: time,
+ spotter: spotter,
+ source: 'DX Spider'
+ });
+ gotSpots = true;
+ }
+ }
+ }
+ }
+ }
+
+ // If we have enough spots or see end marker, close connection
+ if (spots.length >= 20 || buffer.includes('G6NHU-2 de GUEST')) {
+ client.write('bye\r\n');
+ setTimeout(() => client.destroy(), 500);
+ }
+ });
+
+ client.on('timeout', () => {
+ console.log('[DX Cluster] DX Spider: timeout');
+ client.destroy();
+ });
+
+ client.on('error', (err) => {
+ console.error('[DX Cluster] DX Spider error:', err.message);
+ client.destroy();
+ });
+
+ client.on('close', () => {
+ if (spots.length > 0) {
+ console.log('[DX Cluster] DX Spider:', spots.length, 'spots');
+ dxSpiderCache = { spots: spots, timestamp: Date.now() };
+ resolve(spots);
+ } else {
+ resolve(null);
+ }
+ });
+
+ // Fallback timeout
+ setTimeout(() => {
+ if (!gotSpots) {
+ client.destroy();
+ }
+ }, 15000);
+ });
}
+
+ // Fetch based on selected source
+ let spots = null;
+
+ if (source === 'hamqth') {
+ spots = await fetchHamQTH();
+ } else if (source === 'dxheat') {
+ spots = await fetchDXHeat();
+ } else if (source === 'dxsummit') {
+ spots = await fetchDXSummit();
+ } else if (source === 'dxspider') {
+ spots = await fetchDXSpider();
+ } else {
+ // Auto mode - try sources in order
+ spots = await fetchHamQTH();
+ if (!spots) spots = await fetchDXHeat();
+ if (!spots) spots = await fetchDXSummit();
+ }
+
+ res.json(spots || []);
+});
- // All sources failed or timed out
- res.json([]);
+// Get available DX cluster sources
+app.get('/api/dxcluster/sources', (req, res) => {
+ res.json([
+ { id: 'auto', name: 'Auto (Best Available)', description: 'Automatically selects the best available source' },
+ { id: 'hamqth', name: 'HamQTH', description: 'HamQTH.com DX Cluster feed' },
+ { id: 'dxheat', name: 'DXHeat', description: 'DXHeat.com real-time cluster' },
+ { id: 'dxsummit', name: 'DX Summit', description: 'DXSummit.fi cluster (may be slow)' },
+ { id: 'dxspider', name: 'DX Spider (G6NHU)', description: 'G6NHU-2 DX Spider node via telnet (dxspider.co.uk)' }
+ ]);
});
// ============================================
diff --git a/setup-pi.sh b/setup-pi.sh
deleted file mode 100644
index dd3c44d..0000000
--- a/setup-pi.sh
+++ /dev/null
@@ -1,194 +0,0 @@
-#!/bin/bash
-#
-# OpenHamClock Raspberry Pi Setup Script
-# Configures Pi for kiosk mode operation
-#
-# Usage: chmod +x setup-pi.sh && ./setup-pi.sh
-#
-
-set -e
-
-echo "========================================"
-echo " OpenHamClock Raspberry Pi Setup"
-echo "========================================"
-echo ""
-
-# Check if running on Raspberry Pi
-if [ ! -f /proc/device-tree/model ]; then
- echo "Warning: This doesn't appear to be a Raspberry Pi."
- echo "Continuing anyway..."
-fi
-
-# Get the current user
-CURRENT_USER=$(whoami)
-HOME_DIR=$(eval echo ~$CURRENT_USER)
-OPENHAMCLOCK_DIR="$HOME_DIR/openhamclock"
-
-echo "Installing for user: $CURRENT_USER"
-echo "Install directory: $OPENHAMCLOCK_DIR"
-echo ""
-
-# Update system
-echo ">>> Updating system packages..."
-sudo apt-get update -qq
-
-# Install required packages
-echo ">>> Installing required packages..."
-sudo apt-get install -y -qq \
- chromium \
- unclutter \
- xdotool \
- x11-xserver-utils
-
-# Create OpenHamClock directory if it doesn't exist
-echo ">>> Setting up OpenHamClock directory..."
-mkdir -p "$OPENHAMCLOCK_DIR"
-
-# Copy index.html if it exists in the current directory
-if [ -f "index.html" ]; then
- cp index.html "$OPENHAMCLOCK_DIR/"
- echo ">>> Copied index.html to $OPENHAMCLOCK_DIR"
-fi
-
-# Create the autostart directory
-echo ">>> Configuring autostart..."
-mkdir -p "$HOME_DIR/.config/autostart"
-
-# Create autostart entry for OpenHamClock
-cat > "$HOME_DIR/.config/autostart/openhamclock.desktop" << EOF
-[Desktop Entry]
-Type=Application
-Name=OpenHamClock
-Comment=Amateur Radio Dashboard
-Exec=/bin/bash $OPENHAMCLOCK_DIR/start-kiosk.sh
-Terminal=false
-Hidden=false
-X-GNOME-Autostart-enabled=true
-EOF
-
-# Create kiosk start script
-echo ">>> Creating kiosk start script..."
-cat > "$OPENHAMCLOCK_DIR/start-kiosk.sh" << 'EOF'
-#!/bin/bash
-#
-# OpenHamClock Kiosk Mode Launcher
-#
-
-# Wait for desktop to be ready
-sleep 5
-
-# Disable screen blanking and power management
-xset s off
-xset -dpms
-xset s noblank
-
-# Hide the mouse cursor after 3 seconds of inactivity
-unclutter -idle 3 -root &
-
-# Kill any existing Chromium processes
-pkill -f chromium || true
-sleep 2
-
-# Start Chromium in kiosk mode
-chromium \
- --kiosk \
- --noerrdialogs \
- --disable-infobars \
- --disable-session-crashed-bubble \
- --disable-restore-session-state \
- --disable-features=TranslateUI \
- --check-for-update-interval=31536000 \
- --disable-component-update \
- --overscroll-history-navigation=0 \
- --incognito \
- "file://$HOME/openhamclock/index.html"
-EOF
-
-chmod +x "$OPENHAMCLOCK_DIR/start-kiosk.sh"
-
-# Create a stop script
-cat > "$OPENHAMCLOCK_DIR/stop-kiosk.sh" << 'EOF'
-#!/bin/bash
-# Stop OpenHamClock kiosk mode
-pkill -f chromium-browser
-pkill -f unclutter
-echo "OpenHamClock stopped."
-EOF
-
-chmod +x "$OPENHAMCLOCK_DIR/stop-kiosk.sh"
-
-# Create a restart script
-cat > "$OPENHAMCLOCK_DIR/restart-kiosk.sh" << 'EOF'
-#!/bin/bash
-# Restart OpenHamClock
-$HOME/openhamclock/stop-kiosk.sh
-sleep 2
-$HOME/openhamclock/start-kiosk.sh &
-EOF
-
-chmod +x "$OPENHAMCLOCK_DIR/restart-kiosk.sh"
-
-# Create systemd service for headless operation (optional)
-echo ">>> Creating systemd service (for headless operation)..."
-sudo tee /etc/systemd/system/openhamclock.service > /dev/null << EOF
-[Unit]
-Description=OpenHamClock Kiosk
-After=graphical-session.target
-
-[Service]
-Type=simple
-User=$CURRENT_USER
-Environment=DISPLAY=:0
-ExecStart=/bin/bash $OPENHAMCLOCK_DIR/start-kiosk.sh
-Restart=on-failure
-RestartSec=5
-
-[Install]
-WantedBy=graphical-session.target
-EOF
-
-# Disable screen blanking in config.txt
-echo ">>> Configuring boot options..."
-if ! grep -q "consoleblank=0" /boot/cmdline.txt 2>/dev/null; then
- sudo sed -i '$ s/$/ consoleblank=0/' /boot/cmdline.txt 2>/dev/null || true
-fi
-
-# Configure GPU memory for better graphics (optional)
-if ! grep -q "gpu_mem=" /boot/config.txt 2>/dev/null; then
- echo "gpu_mem=128" | sudo tee -a /boot/config.txt > /dev/null 2>/dev/null || true
-fi
-
-echo ""
-echo "========================================"
-echo " Setup Complete!"
-echo "========================================"
-echo ""
-echo "OpenHamClock has been installed to: $OPENHAMCLOCK_DIR"
-echo ""
-echo "Files created:"
-echo " - $OPENHAMCLOCK_DIR/index.html (main application)"
-echo " - $OPENHAMCLOCK_DIR/start-kiosk.sh (start in kiosk mode)"
-echo " - $OPENHAMCLOCK_DIR/stop-kiosk.sh (stop kiosk)"
-echo " - $OPENHAMCLOCK_DIR/restart-kiosk.sh (restart kiosk)"
-echo ""
-echo "Auto-start:"
-echo " OpenHamClock will automatically start on next boot."
-echo ""
-echo "Manual commands:"
-echo " Start: ~/openhamclock/start-kiosk.sh"
-echo " Stop: ~/openhamclock/stop-kiosk.sh"
-echo " Restart: ~/openhamclock/restart-kiosk.sh"
-echo ""
-echo "To disable auto-start:"
-echo " rm ~/.config/autostart/openhamclock.desktop"
-echo ""
-echo "Reboot recommended to apply all changes."
-echo ""
-echo "73 de OpenHamClock!"
-echo ""
-
-read -p "Would you like to reboot now? (y/N) " -n 1 -r
-echo
-if [[ $REPLY =~ ^[Yy]$ ]]; then
- sudo reboot
-fi