@@ -19,35 +19,40 @@ app.use(express.json());
1919const CONFIG = {
2020 // DX Spider nodes to try (in order)
2121 nodes : [
22- { host : 'dxspider.co.uk' , port : 7300 , name : 'DX Spider UK (G6NHU)' } ,
2322 { host : 'dxc.nc7j.com' , port : 7373 , name : 'NC7J' } ,
2423 { host : 'dxc.ai9t.com' , port : 7373 , name : 'AI9T' } ,
2524 { host : 'dxc.w6cua.org' , port : 7300 , name : 'W6CUA' } ,
25+ { host : 'dxspider.co.uk' , port : 7300 , name : 'DX Spider UK (G6NHU)' } ,
2626 ] ,
2727 // Callsign with SSID - use env var as-is, or default to OPENHAMCLOCK-56
2828 // Set CALLSIGN=YOURCALL-56 for production, CALLSIGN=YOURCALL-57 for staging
29- callsign : process . env . CALLSIGN || 'OPENHAMCLOCK-56' ,
29+ callsign : process . env . CALLSIGN ?. trim ( ) || 'OPENHAMCLOCK-56' ,
3030 spotRetentionMs : 30 * 60 * 1000 , // 30 minutes
3131 reconnectDelayMs : 10000 , // 10 seconds between reconnect attempts
3232 maxReconnectAttempts : 3 ,
3333 cleanupIntervalMs : 60000 , // 1 minute
3434 keepAliveIntervalMs : 120000 , // 2 minutes - send keepalive
35+ activityTimeoutMs : 180000 , // 3 minutes - if no spots, assume dead and failover
36+ authTimeoutMs : 30000 , // 30 seconds - if no prompt after login, try next node
3537} ;
3638
3739// State
3840let spots = [ ] ;
3941let client = null ;
4042let connected = false ;
41- let connecting = false ; // NEW: Prevent concurrent connection attempts
43+ let connecting = false ; // Prevent concurrent connection attempts
44+ let authenticated = false ; // Track whether login completed
4245let currentNode = null ;
4346let currentNodeIndex = 0 ;
4447let reconnectAttempts = 0 ;
4548let lastSpotTime = null ;
49+ let lastDataTime = null ; // Track ANY data received from node
4650let totalSpotsReceived = 0 ;
4751let connectionStartTime = null ;
4852let buffer = '' ;
4953let reconnectTimer = null ;
5054let keepAliveTimer = null ;
55+ let activityWatchdog = null ; // Fires if no spots arrive within threshold
5156
5257// Logging helper with log levels
5358// LOG_LEVEL: 'debug' = verbose, 'info' = normal, 'warn' = warnings+errors only
@@ -60,12 +65,14 @@ const CATEGORY_LEVELS = {
6065 SPOT : 'debug' , // Per-spot logging is debug-only
6166 CLEANUP : 'debug' , // Periodic cleanup is debug-only
6267 KEEPALIVE : 'debug' , // Keepalive pings are debug-only
68+ DATA : 'debug' , // Non-spot telnet data is debug-only
6369 CMD : 'debug' , // Command logging is debug-only
6470 AUTH : 'info' , // Auth events are informational
6571 CONNECT : 'info' , // Connection events are informational
6672 CLOSE : 'info' ,
6773 RECONNECT : 'info' ,
6874 FAILOVER : 'info' ,
75+ ACTIVITY : 'info' ,
6976 API : 'info' ,
7077 START : 'info' ,
7178 CONFIG : 'info' ,
@@ -224,6 +231,12 @@ const connect = () => {
224231 client = null ;
225232 }
226233
234+ // Clear any stale watchdog from previous connection
235+ if ( activityWatchdog ) {
236+ clearTimeout ( activityWatchdog ) ;
237+ activityWatchdog = null ;
238+ }
239+
227240 const node = CONFIG . nodes [ currentNodeIndex ] ;
228241 currentNode = node ;
229242
@@ -235,9 +248,12 @@ const connect = () => {
235248 client . connect ( node . port , node . host , ( ) => {
236249 connected = true ;
237250 connecting = false ;
238- reconnectAttempts = 0 ;
251+ authenticated = false ;
239252 connectionStartTime = new Date ( ) ;
253+ lastDataTime = Date . now ( ) ;
240254 buffer = '' ;
255+ // NOTE: reconnectAttempts is NOT reset here — only when spots actually arrive.
256+ // This prevents infinite loops on nodes that accept TCP but kick after auth.
241257 log ( 'CONNECT' , `Connected to ${ node . name } ` ) ;
242258
243259 // Send login after short delay
@@ -258,10 +274,21 @@ const connect = () => {
258274 if ( client && connected ) {
259275 client . write ( 'set/dx\r\n' ) ;
260276 log ( 'CMD' , 'Sent: set/dx (enable spot stream)' ) ;
277+
278+ // Start the activity watchdog now that commands are sent
279+ // If no spots arrive within activityTimeoutMs, we'll failover
280+ resetActivityWatchdog ( ) ;
261281 }
262282 } , 2000 ) ;
263283 }
264284 } , 2000 ) ;
285+
286+ // Auth timeout: if node doesn't respond with a prompt, log a warning
287+ setTimeout ( ( ) => {
288+ if ( connected && ! authenticated ) {
289+ log ( 'AUTH' , `No auth confirmation within ${ CONFIG . authTimeoutMs / 1000 } s — node may be unresponsive` ) ;
290+ }
291+ } , CONFIG . authTimeoutMs ) ;
265292 }
266293 } , 1000 ) ;
267294
@@ -271,6 +298,7 @@ const connect = () => {
271298
272299 client . on ( 'data' , ( data ) => {
273300 buffer += data . toString ( ) ;
301+ lastDataTime = Date . now ( ) ;
274302
275303 // Process complete lines
276304 const lines = buffer . split ( '\n' ) ;
@@ -285,8 +313,26 @@ const connect = () => {
285313 const spot = parseSpotLine ( trimmed ) ;
286314 if ( spot ) {
287315 addSpot ( spot ) ;
316+ resetActivityWatchdog ( ) ; // Got a spot, connection is healthy
317+ // Connection proved healthy — reset the failover counter
318+ if ( reconnectAttempts > 0 ) {
319+ log ( 'CONNECT' , `Connection healthy (spots flowing), resetting failover counter` ) ;
320+ reconnectAttempts = 0 ;
321+ }
288322 }
323+ continue ;
324+ }
325+
326+ // Detect auth completion - DX Spider sends "callsign de NODE >" prompt
327+ if ( ! authenticated && / \s d e \s + \S + \s * > / . test ( trimmed ) ) {
328+ authenticated = true ;
329+ log ( 'AUTH' , `Login confirmed: ${ trimmed . substring ( 0 , 80 ) } ` ) ;
330+ resetActivityWatchdog ( ) ; // Auth done, start watching for spots
331+ continue ;
289332 }
333+
334+ // Log non-spot data so we can diagnose issues (debug level)
335+ log ( 'DATA' , trimmed . substring ( 0 , 120 ) ) ;
290336 }
291337 } ) ;
292338
@@ -311,6 +357,48 @@ const connect = () => {
311357 } ) ;
312358} ;
313359
360+ // Reset the activity watchdog - called when spots arrive
361+ const resetActivityWatchdog = ( ) => {
362+ if ( activityWatchdog ) {
363+ clearTimeout ( activityWatchdog ) ;
364+ }
365+
366+ activityWatchdog = setTimeout ( ( ) => {
367+ if ( connected ) {
368+ log ( 'ACTIVITY' , `No spots received in ${ CONFIG . activityTimeoutMs / 1000 } s — forcing failover` ) ;
369+ // Skip straight to next node instead of retrying the same one
370+ currentNodeIndex = ( currentNodeIndex + 1 ) % CONFIG . nodes . length ;
371+ reconnectAttempts = 0 ;
372+ log ( 'FAILOVER' , `Switching to node: ${ CONFIG . nodes [ currentNodeIndex ] . name } ` ) ;
373+
374+ // Force disconnect and reconnect
375+ if ( client ) {
376+ try {
377+ client . removeAllListeners ( ) ;
378+ client . destroy ( ) ;
379+ } catch ( e ) { }
380+ client = null ;
381+ }
382+ connected = false ;
383+ connecting = false ;
384+ authenticated = false ;
385+
386+ if ( keepAliveTimer ) {
387+ clearInterval ( keepAliveTimer ) ;
388+ keepAliveTimer = null ;
389+ }
390+
391+ // Clear any pending reconnect and connect immediately
392+ if ( reconnectTimer ) {
393+ clearTimeout ( reconnectTimer ) ;
394+ reconnectTimer = null ;
395+ }
396+
397+ connect ( ) ;
398+ }
399+ } , CONFIG . activityTimeoutMs ) ;
400+ } ;
401+
314402// Start keepalive timer
315403const startKeepAlive = ( ) => {
316404 if ( keepAliveTimer ) {
@@ -339,24 +427,36 @@ const handleDisconnect = () => {
339427
340428 connected = false ;
341429 connecting = false ;
430+ authenticated = false ;
342431
343432 if ( keepAliveTimer ) {
344433 clearInterval ( keepAliveTimer ) ;
345434 keepAliveTimer = null ;
346435 }
347436
437+ if ( activityWatchdog ) {
438+ clearTimeout ( activityWatchdog ) ;
439+ activityWatchdog = null ;
440+ }
441+
348442 // Don't schedule another reconnect if one is already pending
349443 if ( reconnectTimer ) {
350444 return ;
351445 }
352446
447+ // Detect rapid disconnect (kicked within seconds of connecting)
448+ const connectionDuration = connectionStartTime ? Date . now ( ) - connectionStartTime . getTime ( ) : 0 ;
449+ if ( connectionDuration > 0 && connectionDuration < 15000 ) {
450+ log ( 'RECONNECT' , `Rapid disconnect from ${ currentNode ?. name } after ${ Math . round ( connectionDuration / 1000 ) } s (likely auth rejection or SSID conflict)` ) ;
451+ }
452+
353453 reconnectAttempts ++ ;
354454
355455 if ( reconnectAttempts >= CONFIG . maxReconnectAttempts ) {
356456 // Try next node
357457 currentNodeIndex = ( currentNodeIndex + 1 ) % CONFIG . nodes . length ;
358458 reconnectAttempts = 0 ;
359- log ( 'FAILOVER' , `Switching to node: ${ CONFIG . nodes [ currentNodeIndex ] . name } ` ) ;
459+ log ( 'FAILOVER' , `${ CONFIG . maxReconnectAttempts } consecutive failures — switching to node: ${ CONFIG . nodes [ currentNodeIndex ] . name } ` ) ;
360460 }
361461
362462 log ( 'RECONNECT' , `Attempting reconnect in ${ CONFIG . reconnectDelayMs } ms (attempt ${ reconnectAttempts } )` ) ;
@@ -376,10 +476,12 @@ app.get('/health', (req, res) => {
376476 res . json ( {
377477 status : 'ok' ,
378478 connected,
479+ authenticated,
379480 currentNode : currentNode ?. name || 'none' ,
380481 spotsInMemory : spots . length ,
381482 totalSpotsReceived,
382483 lastSpotTime : lastSpotTime ?. toISOString ( ) || null ,
484+ lastDataTime : lastDataTime ? new Date ( lastDataTime ) . toISOString ( ) : null ,
383485 connectionUptime : connectionStartTime
384486 ? Math . floor ( ( Date . now ( ) - connectionStartTime . getTime ( ) ) / 1000 ) + 's'
385487 : null ,
@@ -544,6 +646,7 @@ setInterval(cleanupSpots, CONFIG.cleanupIntervalMs);
544646app . listen ( PORT , ( ) => {
545647 log ( 'START' , `DX Spider Proxy listening on port ${ PORT } ` ) ;
546648 log ( 'CONFIG' , `Callsign: ${ CONFIG . callsign } ` ) ;
649+ log ( 'CONFIG' , `CALLSIGN env var: ${ process . env . CALLSIGN === undefined ? '(not set)' : `"${ process . env . CALLSIGN } "` } ` ) ;
547650 log ( 'CONFIG' , `Spot retention: ${ CONFIG . spotRetentionMs / 60000 } minutes` ) ;
548651 log ( 'CONFIG' , `Available nodes: ${ CONFIG . nodes . map ( ( n ) => n . name ) . join ( ', ' ) } ` ) ;
549652
0 commit comments