diff --git a/server.js b/server.js index d268fa5..16e4e33 100644 --- a/server.js +++ b/server.js @@ -9545,6 +9545,23 @@ function normalizeCallsign(value) { return (value || '').trim().toUpperCase(); } +function parseXmlBoolean(value) { + if (value == null) return null; + const normalized = String(value).trim().toLowerCase(); + if (!normalized) return null; + if (normalized === 'true' || normalized === '1' || normalized === 'yes') return true; + if (normalized === 'false' || normalized === '0' || normalized === 'no') return false; + return null; +} + +function detectContestSource(xml) { + const app = getXmlTag(xml, 'app').toLowerCase(); + const logger = getXmlTag(xml, 'logger').toLowerCase(); + if (app.includes('dxlog') || logger.includes('dxlog')) return 'dxlog'; + if (app.includes('n1mm') || logger.includes('n1mm')) return 'n1mm'; + return 'contest'; +} + function n1mmFreqToMHz(value, bandMHz) { const v = parseFloat(value); if (!v || Number.isNaN(v)) return bandMHz || null; @@ -9637,10 +9654,11 @@ function addContestQso(qso) { return true; } -function parseN1MMContactInfo(xml) { +function parseContestContactInfo(xml) { const dxCall = normalizeCallsign(getXmlTag(xml, 'call')); if (!dxCall) return null; + const source = detectContestSource(xml); const myCall = normalizeCallsign(getXmlTag(xml, 'mycall')) || normalizeCallsign(getXmlTag(xml, 'stationprefix')) || CONFIG.callsign; @@ -9657,13 +9675,22 @@ function parseN1MMContactInfo(xml) { const contestName = getXmlTag(xml, 'contestname') || ''; const timestampStr = getXmlTag(xml, 'timestamp') || ''; const timestamp = parseN1MMTimestamp(timestampStr) || Date.now(); - const id = getXmlTag(xml, 'ID') || ''; + const id = getXmlTag(xml, 'ID') || getXmlTag(xml, 'guid') || getXmlTag(xml, 'qsoid') || ''; + const isNewQso = parseXmlBoolean(getXmlTag(xml, 'newqso')); + const isDuplicate = parseXmlBoolean(getXmlTag(xml, 'duplicate')); + const isInvalid = parseXmlBoolean(getXmlTag(xml, 'invalid')); + const isDeleted = parseXmlBoolean(getXmlTag(xml, 'xqso')); + + // DXLog may send non-new / duplicate / invalid/deleted updates on the same stream. + // Only ingest genuine new, valid QSOs so the map and auto-DX-follow stay clean. + if (isNewQso === false) return null; + if (isDuplicate === true || isInvalid === true || isDeleted === true) return null; const loc = resolveQsoLocation(dxCall, grid, comment); const qso = { id, - source: 'n1mm', + source, timestamp, time: timestampStr, myCall, @@ -9748,17 +9775,17 @@ if (N1MM_ENABLED) { const text = buf.toString('utf8'); const xml = extractContactInfoXml(text); if (!xml) return; - const qso = parseN1MMContactInfo(xml); + const qso = parseContestContactInfo(xml); if (qso) addContestQso(qso); }); n1mmSocket.on('error', (err) => { - logErrorOnce('N1MM UDP', err.message); + logErrorOnce('Contest UDP', err.message); }); n1mmSocket.on('listening', () => { const addr = n1mmSocket.address(); - console.log(`[N1MM] UDP listener on ${addr.address}:${addr.port}`); + console.log(`[Contest UDP] listener on ${addr.address}:${addr.port}`); }); n1mmSocket.bind(N1MM_UDP_PORT, '0.0.0.0'); @@ -9857,8 +9884,8 @@ app.listen(PORT, '0.0.0.0', () => { if (WSJTX_RELAY_KEY) { console.log(` 🔁 WSJT-X relay endpoint enabled (POST /api/wsjtx/relay)`); } -if (N1MM_ENABLED) { - console.log(` 📥 N1MM UDP listener on port ${N1MM_UDP_PORT}`); + if (N1MM_ENABLED) { + console.log(` 📥 Contest logger UDP listener (N1MM/DXLog) on port ${N1MM_UDP_PORT}`); } if (AUTO_UPDATE_ENABLED) { console.log(` 🔄 Auto-update enabled every ${AUTO_UPDATE_INTERVAL_MINUTES || 60} minutes`); diff --git a/src/components/PluginLayer.jsx b/src/components/PluginLayer.jsx index f60ef82..a6a719c 100644 --- a/src/components/PluginLayer.jsx +++ b/src/components/PluginLayer.jsx @@ -4,36 +4,42 @@ */ import React from 'react'; -export const PluginLayer = ({ - plugin, - enabled, - opacity, - map, - callsign, - locator, +export const PluginLayer = ({ + plugin, + enabled, + opacity, + map, + callsign, + locator, lowMemoryMode, satellites, units, - config + config, + onDXChange, + dxLocked, + dxLocation }) => { const layerFunc = plugin.useLayer || plugin.hook; if (typeof layerFunc === 'function') { - layerFunc({ - map, - enabled, - opacity, - callsign, - locator, - lowMemoryMode, - satellites, + layerFunc({ + map, + enabled, + opacity, + callsign, + locator, + lowMemoryMode, + satellites, units, - config + config, + onDXChange, + dxLocked, + dxLocation }); } return null; }; -export default PluginLayer; \ No newline at end of file +export default PluginLayer; diff --git a/src/components/WorldMap.jsx b/src/components/WorldMap.jsx index 3036258..178b071 100644 --- a/src/components/WorldMap.jsx +++ b/src/components/WorldMap.jsx @@ -1302,6 +1302,26 @@ export const WorldMap = ({ return (
+
+ + {/* Render all plugin layers */} + {mapInstanceRef.current && getAvailableLayers().map(layerDef => ( + + ))} + // MODIS SLIDER CODE HERE {/* Azimuthal equidistant projection (canvas-based) */} {mapStyle === 'azimuthal' && ( ))} @@ -1592,4 +1615,4 @@ export const WorldMap = ({ ); }; -export default WorldMap; \ No newline at end of file +export default WorldMap; diff --git a/src/plugins/layers/useContestQsos.js b/src/plugins/layers/useContestQsos.js index 01c8ba7..c69e0e3 100644 --- a/src/plugins/layers/useContestQsos.js +++ b/src/plugins/layers/useContestQsos.js @@ -13,13 +13,14 @@ export const metadata = { version: '1.0.0' }; -export function useLayer({ enabled = false, opacity = 0.7, map = null }) { +export function useLayer({ enabled = false, opacity = 0.7, map = null, onDXChange = null }) { const [qsos, setQsos] = useState([]); const [deLocation, setDeLocation] = useState(null); const markersRef = useRef([]); const linesRef = useRef([]); const pollRef = useRef(null); const configLoadedRef = useRef(false); + const lastAutoDxQsoIdRef = useRef(null); useEffect(() => { if (!enabled || configLoadedRef.current) return; @@ -129,5 +130,31 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null }) { }); }, [qsos, enabled, opacity, map, deLocation]); + useEffect(() => { + if (!enabled || typeof onDXChange !== 'function' || !Array.isArray(qsos) || qsos.length === 0) return; + + const latestWithCoords = [...qsos] + .reverse() + .find((qso) => Number.isFinite(parseFloat(qso?.lat)) && Number.isFinite(parseFloat(qso?.lon))); + + if (!latestWithCoords) return; + + const qsoId = latestWithCoords.id || + `${latestWithCoords.dxCall || ''}-${latestWithCoords.timestamp || ''}-${latestWithCoords.lat}-${latestWithCoords.lon}`; + + if (!qsoId || qsoId === lastAutoDxQsoIdRef.current) return; + + const lat = parseFloat(latestWithCoords.lat); + let lon = parseFloat(latestWithCoords.lon); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return; + + while (lon > 180) lon -= 360; + while (lon < -180) lon += 360; + + // Auto-follow newest contest QSO so propagation/DX panel refresh from logger activity. + onDXChange({ lat, lon }); + lastAutoDxQsoIdRef.current = qsoId; + }, [enabled, qsos, onDXChange]); + return { layer: markersRef.current }; }