@ -1,9 +1,18 @@
/ * *
/ * *
* usePSKReporter Hook
* usePSKReporter Hook
* Fetches PSKReporter data via MQTT WebSocket connection
* Fetches PSKReporter data via MQTT WebSocket + HTTP fallback
*
*
* Uses real - time MQTT feed from mqtt . pskreporter . info for live spots
* Primary : Real - time MQTT feed from mqtt . pskreporter . info
* No HTTP API calls - direct WebSocket connection from browser
* Fallback : HTTP API via server proxy if MQTT fails to connect
*
* MQTT message format ( from mqtt . pskreporter . info ) :
* sc = sender call , rc = receiver call
* sl = sender locator , rl = receiver locator
* sa = sender ADIF country code , ra = receiver ADIF country code
* f = frequency , md = mode , rp = report ( SNR ) , t = timestamp
* b = band , sq = sequence number
*
* Topic format : pskr / filter / v2 / { band } / { mode } / { sendercall } / { receivercall } / { senderlocator } / { receiverlocator } / { sendercountry } / { receivercountry }
* /
* /
import { useState , useEffect , useCallback , useRef } from 'react' ;
import { useState , useEffect , useCallback , useRef } from 'react' ;
import mqtt from 'mqtt' ;
import mqtt from 'mqtt' ;
@ -50,6 +59,11 @@ function getBandFromHz(freqHz) {
return 'Unknown' ;
return 'Unknown' ;
}
}
// MQTT connection timeout before falling back to HTTP (seconds)
const MQTT _TIMEOUT _MS = 12000 ;
// HTTP poll interval when using fallback (ms)
const HTTP _POLL _INTERVAL = 120000 ; // 2 minutes
export const usePSKReporter = ( callsign , options = { } ) => {
export const usePSKReporter = ( callsign , options = { } ) => {
const {
const {
minutes = 15 , // Time window to keep spots
minutes = 15 , // Time window to keep spots
@ -69,6 +83,8 @@ export const usePSKReporter = (callsign, options = {}) => {
const txReportsRef = useRef ( [ ] ) ;
const txReportsRef = useRef ( [ ] ) ;
const rxReportsRef = useRef ( [ ] ) ;
const rxReportsRef = useRef ( [ ] ) ;
const mountedRef = useRef ( true ) ;
const mountedRef = useRef ( true ) ;
const httpFallbackRef = useRef ( null ) ;
const mqttTimerRef = useRef ( null ) ;
// Clean old spots (older than specified minutes)
// Clean old spots (older than specified minutes)
const cleanOldSpots = useCallback ( ( spots , maxAgeMinutes ) => {
const cleanOldSpots = useCallback ( ( spots , maxAgeMinutes ) => {
@ -76,6 +92,45 @@ export const usePSKReporter = (callsign, options = {}) => {
return spots . filter ( s => s . timestamp > cutoff ) . slice ( 0 , maxSpots ) ;
return spots . filter ( s => s . timestamp > cutoff ) . slice ( 0 , maxSpots ) ;
} , [ maxSpots ] ) ;
} , [ maxSpots ] ) ;
// HTTP fallback fetch
const fetchHTTP = useCallback ( async ( cs ) => {
if ( ! mountedRef . current || ! cs ) return ;
try {
const res = await fetch ( ` /api/pskreporter/ ${ encodeURIComponent ( cs ) } ?minutes= ${ minutes } ` ) ;
if ( ! res . ok ) throw new Error ( ` HTTP ${ res . status } ` ) ;
const data = await res . json ( ) ;
if ( ! mountedRef . current ) return ;
// Merge TX reports
if ( data . tx ? . reports ? . length ) {
txReportsRef . current = data . tx . reports . slice ( 0 , maxSpots ) ;
setTxReports ( [ ... txReportsRef . current ] ) ;
}
// Merge RX reports
if ( data . rx ? . reports ? . length ) {
rxReportsRef . current = data . rx . reports . slice ( 0 , maxSpots ) ;
setRxReports ( [ ... rxReportsRef . current ] ) ;
}
setLastUpdate ( new Date ( ) ) ;
setLoading ( false ) ;
setError ( null ) ;
setSource ( 'http' ) ;
setConnected ( true ) ;
console . log ( ` [PSKReporter HTTP] Loaded ${ data . tx ? . count || 0 } TX, ${ data . rx ? . count || 0 } RX for ${ cs } ` ) ;
} catch ( err ) {
if ( ! mountedRef . current ) return ;
console . error ( '[PSKReporter HTTP] Fallback error:' , err . message ) ;
setError ( 'HTTP fallback failed' ) ;
setLoading ( false ) ;
setSource ( 'error' ) ;
}
} , [ minutes , maxSpots ] ) ;
// Process incoming MQTT message
// Process incoming MQTT message
const processMessage = useCallback ( ( topic , message ) => {
const processMessage = useCallback ( ( topic , message ) => {
if ( ! mountedRef . current ) return ;
if ( ! mountedRef . current ) return ;
@ -83,18 +138,20 @@ export const usePSKReporter = (callsign, options = {}) => {
try {
try {
const data = JSON . parse ( message . toString ( ) ) ;
const data = JSON . parse ( message . toString ( ) ) ;
// PSKReporter MQTT message format
// PSKReporter MQTT message fields:
// sa=sender callsign, sl=sender locator, ra=receiver callsign, rl=receiver locator
// sc = sender call, rc = receiver call (NOT sa/ra which are ADIF country codes)
// f=frequency, md=mode, rp=snr (report), t=timestamp
// sl = sender locator, rl = receiver locator
// f = frequency, md = mode, rp = report (SNR), t = timestamp, b = band
const {
const {
s a : senderCallsign ,
s c : senderCallsign ,
sl : senderLocator ,
sl : senderLocator ,
r a : receiverCallsign ,
r c : receiverCallsign ,
rl : receiverLocator ,
rl : receiverLocator ,
f : frequency ,
f : frequency ,
md : mode ,
md : mode ,
rp : snr ,
rp : snr ,
t : timestamp
t : timestamp ,
b : band
} = data ;
} = data ;
if ( ! senderCallsign || ! receiverCallsign ) return ;
if ( ! senderCallsign || ! receiverCallsign ) return ;
@ -111,7 +168,7 @@ export const usePSKReporter = (callsign, options = {}) => {
receiverGrid : receiverLocator ,
receiverGrid : receiverLocator ,
freq ,
freq ,
freqMHz : freq ? ( freq / 1000000 ) . toFixed ( 3 ) : '?' ,
freqMHz : freq ? ( freq / 1000000 ) . toFixed ( 3 ) : '?' ,
band : getBandFromHz( freq ) ,
band : band || getBandFromHz( freq ) ,
mode : mode || 'Unknown' ,
mode : mode || 'Unknown' ,
snr : snr !== undefined ? parseInt ( snr ) : null ,
snr : snr !== undefined ? parseInt ( snr ) : null ,
timestamp : timestamp ? timestamp * 1000 : now ,
timestamp : timestamp ? timestamp * 1000 : now ,
@ -159,7 +216,7 @@ export const usePSKReporter = (callsign, options = {}) => {
}
}
} , [ callsign , minutes , maxSpots , cleanOldSpots ] ) ;
} , [ callsign , minutes , maxSpots , cleanOldSpots ] ) ;
// Connect to MQTT
// Connect to MQTT with HTTP fallback
useEffect ( ( ) => {
useEffect ( ( ) => {
mountedRef . current = true ;
mountedRef . current = true ;
@ -183,9 +240,11 @@ export const usePSKReporter = (callsign, options = {}) => {
setError ( null ) ;
setError ( null ) ;
setSource ( 'connecting' ) ;
setSource ( 'connecting' ) ;
let mqttFailed = false ;
console . log ( ` [PSKReporter MQTT] Connecting for ${ upperCallsign } ... ` ) ;
console . log ( ` [PSKReporter MQTT] Connecting for ${ upperCallsign } ... ` ) ;
// Connect to PSKReporter MQTT via WebSocket
// Connect to PSKReporter MQTT via WebSocket (TLS on port 1886)
const client = mqtt . connect ( 'wss://mqtt.pskreporter.info:1886/mqtt' , {
const client = mqtt . connect ( 'wss://mqtt.pskreporter.info:1886/mqtt' , {
clientId : ` ohc_ ${ upperCallsign } _ ${ Math . random ( ) . toString ( 16 ) . substr ( 2 , 6 ) } ` ,
clientId : ` ohc_ ${ upperCallsign } _ ${ Math . random ( ) . toString ( 16 ) . substr ( 2 , 6 ) } ` ,
clean : true ,
clean : true ,
@ -196,17 +255,48 @@ export const usePSKReporter = (callsign, options = {}) => {
clientRef . current = client ;
clientRef . current = client ;
// Set timeout — if MQTT doesn't connect within N seconds, fall back to HTTP
mqttTimerRef . current = setTimeout ( ( ) => {
if ( ! mountedRef . current || connected ) return ;
mqttFailed = true ;
console . log ( '[PSKReporter] MQTT timeout, falling back to HTTP...' ) ;
setSource ( 'http-fallback' ) ;
// Initial HTTP fetch
fetchHTTP ( upperCallsign ) ;
// Poll periodically
httpFallbackRef . current = setInterval ( ( ) => {
if ( mountedRef . current ) fetchHTTP ( upperCallsign ) ;
} , HTTP _POLL _INTERVAL ) ;
} , MQTT _TIMEOUT _MS ) ;
client . on ( 'connect' , ( ) => {
client . on ( 'connect' , ( ) => {
if ( ! mountedRef . current ) return ;
if ( ! mountedRef . current ) return ;
// Cancel HTTP fallback timer
if ( mqttTimerRef . current ) {
clearTimeout ( mqttTimerRef . current ) ;
mqttTimerRef . current = null ;
}
// Stop any HTTP polling
if ( httpFallbackRef . current ) {
clearInterval ( httpFallbackRef . current ) ;
httpFallbackRef . current = null ;
}
mqttFailed = false ;
console . log ( '[PSKReporter MQTT] Connected!' ) ;
console . log ( '[PSKReporter MQTT] Connected!' ) ;
setConnected ( true ) ;
setConnected ( true ) ;
setLoading ( false ) ;
setLoading ( false ) ;
setSource ( 'mqtt' ) ;
setSource ( 'mqtt' ) ;
setError ( null ) ;
setError ( null ) ;
// Subscribe to spots where we are the sender (being heard by others)
// Topic format: pskr/filter/v2/{band}/{mode}/{senderCall}/{receiverCall}/...
// Topic format: pskr/filter/v2/{mode}/{band}/{senderCall}/{senderLoc}/{rxCall}/{rxLoc}/{freq}/{snr}
// TX: Subscribe where we are the sender (being heard by others)
// Sender call is at position 3 after v2 (index 5 in full topic)
const txTopic = ` pskr/filter/v2/+/+/ ${ upperCallsign } /# ` ;
const txTopic = ` pskr/filter/v2/+/+/ ${ upperCallsign } /# ` ;
client . subscribe ( txTopic , { qos : 0 } , ( err ) => {
client . subscribe ( txTopic , { qos : 0 } , ( err ) => {
if ( err ) {
if ( err ) {
@ -216,8 +306,9 @@ export const usePSKReporter = (callsign, options = {}) => {
}
}
} ) ;
} ) ;
// Subscribe to spots where we are the receiver (hearing others)
// RX: Subscribe where we are the receiver (hearing others)
const rxTopic = ` pskr/filter/v2/+/+/+/+/ ${ upperCallsign } /# ` ;
// Receiver call is at position 4 after v2 (index 6 in full topic)
const rxTopic = ` pskr/filter/v2/+/+/+/ ${ upperCallsign } /# ` ;
client . subscribe ( rxTopic , { qos : 0 } , ( err ) => {
client . subscribe ( rxTopic , { qos : 0 } , ( err ) => {
if ( err ) {
if ( err ) {
console . error ( '[PSKReporter MQTT] RX subscribe error:' , err ) ;
console . error ( '[PSKReporter MQTT] RX subscribe error:' , err ) ;
@ -232,9 +323,8 @@ export const usePSKReporter = (callsign, options = {}) => {
client . on ( 'error' , ( err ) => {
client . on ( 'error' , ( err ) => {
if ( ! mountedRef . current ) return ;
if ( ! mountedRef . current ) return ;
console . error ( '[PSKReporter MQTT] Error:' , err . message ) ;
console . error ( '[PSKReporter MQTT] Error:' , err . message ) ;
setError ( 'Connection error' ) ;
setError ( 'MQTT connection error' ) ;
setConnected ( false ) ;
// Don't set loading false here - let the timeout trigger HTTP fallback
setLoading ( false ) ;
} ) ;
} ) ;
client . on ( 'close' , ( ) => {
client . on ( 'close' , ( ) => {
@ -247,24 +337,32 @@ export const usePSKReporter = (callsign, options = {}) => {
if ( ! mountedRef . current ) return ;
if ( ! mountedRef . current ) return ;
console . log ( '[PSKReporter MQTT] Offline' ) ;
console . log ( '[PSKReporter MQTT] Offline' ) ;
setConnected ( false ) ;
setConnected ( false ) ;
setSource ( 'offline' ) ;
if ( ! mqttFailed ) setSource ( 'offline' ) ;
} ) ;
} ) ;
client . on ( 'reconnect' , ( ) => {
client . on ( 'reconnect' , ( ) => {
if ( ! mountedRef . current ) return ;
if ( ! mountedRef . current ) return ;
console . log ( '[PSKReporter MQTT] Reconnecting...' ) ;
console . log ( '[PSKReporter MQTT] Reconnecting...' ) ;
setSource ( 'reconnecting' ) ;
if ( ! mqttFailed ) setSource ( 'reconnecting' ) ;
} ) ;
} ) ;
// Cleanup on unmount or callsign change
// Cleanup on unmount or callsign change
return ( ) => {
return ( ) => {
mountedRef . current = false ;
mountedRef . current = false ;
if ( mqttTimerRef . current ) {
clearTimeout ( mqttTimerRef . current ) ;
mqttTimerRef . current = null ;
}
if ( httpFallbackRef . current ) {
clearInterval ( httpFallbackRef . current ) ;
httpFallbackRef . current = null ;
}
if ( client ) {
if ( client ) {
console . log ( '[PSKReporter MQTT] Cleaning up...' ) ;
console . log ( '[PSKReporter MQTT] Cleaning up...' ) ;
client . end ( true ) ;
client . end ( true ) ;
}
}
} ;
} ;
} , [ callsign , enabled , processMessage ] ) ;
} , [ callsign , enabled , processMessage , fetchHTTP ]) ;
// Periodically clean old spots and update ages
// Periodically clean old spots and update ages
useEffect ( ( ) => {
useEffect ( ( ) => {
@ -291,6 +389,15 @@ export const usePSKReporter = (callsign, options = {}) => {
// Manual refresh - force reconnect
// Manual refresh - force reconnect
const refresh = useCallback ( ( ) => {
const refresh = useCallback ( ( ) => {
// Stop HTTP polling
if ( httpFallbackRef . current ) {
clearInterval ( httpFallbackRef . current ) ;
httpFallbackRef . current = null ;
}
if ( mqttTimerRef . current ) {
clearTimeout ( mqttTimerRef . current ) ;
mqttTimerRef . current = null ;
}
if ( clientRef . current ) {
if ( clientRef . current ) {
clientRef . current . end ( true ) ;
clientRef . current . end ( true ) ;
clientRef . current = null ;
clientRef . current = null ;