diff --git a/server/datahandler.js b/server/datahandler.js index 2fac24f..0ffa684 100644 --- a/server/datahandler.js +++ b/server/datahandler.js @@ -421,7 +421,8 @@ function handleData(wss, receivedData, rdsWss) { id: currentTx.id, pi: currentTx.pi, reg: currentTx.reg, - otherMatches: currentTx.others + otherMatches: currentTx.others, + score: currentTx.score, }; } }) diff --git a/server/server_config.js b/server/server_config.js index 5f03398..f5d6a0e 100644 --- a/server/server_config.js +++ b/server/server_config.js @@ -58,6 +58,7 @@ let serverConfig = { lat: "", lon: "", broadcastTuner: false, + gpsMode: false, proxyIp: "", contact: null, }, diff --git a/server/tx_search.js b/server/tx_search.js index ef48664..09dc781 100644 --- a/server/tx_search.js +++ b/server/tx_search.js @@ -4,13 +4,21 @@ const consoleCmd = require('./console'); let localDb = {}; let lastFetchTime = 0; +let piFreqIndex = {}; // Indexing for speedier PI+Freq combinations const fetchInterval = 1000; -const esSwitchCache = {"lastCheck":0, "esSwitch":false}; +const esSwitchCache = {"lastCheck": null, "esSwitch": false}; const esFetchInterval = 300000; var currentPiCode = ''; var currentRdsPs = ''; const usStatesGeoJsonUrl = "https://raw.githubusercontent.com/PublicaMundi/MappingAPI/master/data/geojson/us-states.json"; let usStatesGeoJson = null; // To cache the GeoJSON data for US states +let Latitude = serverConfig.identification.lat; +let Longitude = serverConfig.identification.lon; + +// Create WebSocket URL for GPS lat/lon update. +const webserverPort = serverConfig.webserver.webserverPort || 8080; // Fallback to port 8080 +const externalWsUrl = `ws://127.0.0.1:${webserverPort}/data_plugins`; +const WebSocket = require('ws'); // Get weighting values based on algorithm setting. // Defaults = algorithm 1 @@ -28,18 +36,89 @@ if (typeof algorithms[algoSetting] !== 'undefined') { weightedDist = algorithms[algoSetting][1]; } -// IIFE to build the local TX DB cache from the endpoint. -(async () => { - try { - setTimeout(() => consoleCmd.logInfo('Fetching transmitter database...'), 0); - const response = await fetch(`https://maps.fmdx.org/api?qth=${serverConfig.identification.lat},${serverConfig.identification.lon}`); - if (!response.ok) throw new Error(`HTTP error! Status: ${response.status}`); - localDb = await response.json(); - consoleCmd.logInfo('Transmitter database successfully loaded.'); - } catch (error) { - consoleCmd.logError("Failed to fetch transmitter database:", error); +// Build the TX database. +setTimeout(buildTxDatabase, 3000); + +if (serverConfig.identification.gpsMode) { + // 5-second delay before activation of GPS lat/lon websocket + setTimeout(() => { + const websocket = new WebSocket(externalWsUrl); + consoleCmd.logInfo('Set up GPS websocket for lat/lon'); + // Event listener to receive data + websocket.on('message', (data) => { + try { + // Parse the received data + const parsedData = JSON.parse(data); + + // Check if the dataset is of type GPS + if (parsedData.type === "GPS" && parsedData.value) { + const gpsData = parsedData.value; + const { status, time, lat, lon, alt, mode } = gpsData; + + if (status === "active") { + Latitude = parseFloat(lat); + Longitude = parseFloat(lon); + } + } + } catch (error) { + consoleCmd.logError("Error processing WebSocket data:", error); + } + }); + + }, 5000); +} + +// Function to build local TX database from FMDX Maps endpoint. +async function buildTxDatabase() { + if (Latitude.length > 0 && Longitude.length > 0) { + let awaitingTxInfo = true; + while (awaitingTxInfo) { + try { + consoleCmd.logInfo('Fetching transmitter database...'); + const response = await fetch(`https://maps.fmdx.org/api?qth=${serverConfig.identification.lat},${serverConfig.identification.lon}`, { + method: 'GET', + headers: { + 'Accept': 'application/json' + } + }); + if (!response.ok) throw new Error(`HTTP error! Status: ${response.status}`); + localDb = await response.json(); + buildPiFreqIndex(); + consoleCmd.logInfo('Transmitter database successfully loaded.'); + awaitingTxInfo = false; + } catch (error) { + consoleCmd.logError("Failed to fetch transmitter database:", error); + await new Promise(res => setTimeout(res, 30000)); + consoleCmd.logInfo('Retrying transmitter database download...'); + } + } + } else { + consoleCmd.logInfo('Server latitude and longitude must be set before transmitter database can be built'); } -})(); +} + +// Function to build index map of PI+Freq combinations +function buildPiFreqIndex() { + piFreqIndex = {}; // reset + for (const locData of Object.values(localDb.locations || {})) { + for (const station of locData.stations || []) { + if (!station.freq) continue; + const freq = station.freq; + const pi = station.pi?.toUpperCase(); + const pireg = station.pireg?.toUpperCase(); + if (pi) { + const key = `${freq}|${pi}`; + if (!piFreqIndex[key]) piFreqIndex[key] = []; + piFreqIndex[key].push({ ...locData, station }); + } + if (pireg) { + const regKey = `${freq}|${pireg}`; + if (!piFreqIndex[regKey]) piFreqIndex[regKey] = []; + piFreqIndex[regKey].push({ ...locData, station }); + } + } + } +} // Load the US states GeoJSON data async function loadUsStatesGeoJson() { @@ -105,14 +184,22 @@ function getStateForCoordinates(lat, lon) { * If at least three valid matches are found for any token, the function returns true. */ function validPsCompare(rdsPs, stationPs) { + if (typeof stationPs !== 'string' || typeof rdsPs !== 'string') { + consoleCmd.logError(`Invalid TX values. stationPs: ${stationPs}, rdsPs: ${rdsPs}`); + return false; + } + // Standardize the rdsPs string: replace spaces with underscores and convert to lowercase. const standardizedRdsPs = rdsPs.replace(/ /g, '_').toLowerCase(); // Split stationPs into tokens (e.g., "__mdr___ _kultur_" -> ["__mdr___", "_kultur_"]) - const psTokens = stationPs.split(/\s+/).filter(token => token.length > 0).map(token => token.toLowerCase()); + const psTokens = stationPs.split(/\s+/).filter(token => token.length > 0).map(token => { const lower = token.toLowerCase(); return lower.length < 8 ? lower.padEnd(8, '_') : lower; }); // Iterate through all tokens and check if any token yields at least three valid (non "_" ) matches. for (let token of psTokens) { + // If total non "_" length of token is less than 3, allow match based on that length instead + const tokenLength = token.replace(/_/g, "").length; + const minMatchLen = tokenLength > 2 ? 3 : tokenLength; // If the token's length does not match the standardized rdsPs length, skip this token. if (token.length !== standardizedRdsPs.length) continue; @@ -124,21 +211,20 @@ function validPsCompare(rdsPs, stationPs) { matchCount++; } } - if (matchCount >= 3) { + if (matchCount >= minMatchLen) { return true; } } return false; } -function evaluateStation(station) { - let esMode = checkEs(); +function evaluateStation(station, esMode) { let weightDistance = station.distanceKm; - if (esMode && station.distanceKm > 500) { - weightDistance = Math.abs(station.distanceKm - 1500); + if (esMode && station.distanceKm > 700) { + weightDistance = Math.abs(station.distanceKm - 1500) + 200; } let erp = station.erp && station.erp > 0 ? station.erp : 1; - let extraWeight = erp > 30 && station.distanceKm <= 500 ? 0.3 : 0; + let extraWeight = erp > weightedErp && station.distanceKm <= weightDistance ? 0.3 : 0; let score = 0; // If ERP is 1W, use a simpler formula to avoid zero-scoring. if (erp === 0.001) { @@ -156,45 +242,67 @@ async function fetchTx(freq, piCode, rdsPs) { const now = Date.now(); freq = parseFloat(freq); - if (isNaN(freq)) return; - if (now - lastFetchTime < fetchInterval - || serverConfig.identification.lat.length < 2 - || freq < 87 - || Object.keys(localDb).length === 0 - || (currentPiCode == piCode && currentRdsPs == rdsPs)) { - return Promise.resolve(); - } + if ( + isNaN(freq) || + now - lastFetchTime < fetchInterval || + Latitude.length < 2 || + freq < 87 || + Object.keys(localDb).length === 0 || + (currentPiCode === piCode && currentRdsPs === rdsPs) + ) return Promise.resolve(); lastFetchTime = now; + currentPiCode = piCode; + currentRdsPs = rdsPs; if (serverConfig.webserver.rdsMode === true) await loadUsStatesGeoJson(); - const filteredLocations = Object.values(localDb.locations) - .map(locData => ({ - ...locData, - stations: locData.stations.filter(station => - station.freq === freq && - (station.pi === piCode.toUpperCase() || station.pireg === piCode.toUpperCase() ) && - validPsCompare(rdsPs, station.ps) - ) - })) - .filter(locData => locData.stations.length > 0); // Ensure locations with at least one matching station remain + const key = `${freq}|${piCode.toUpperCase()}`; + let rawMatches = piFreqIndex[key] || []; + + // Format the results into the same structure as before + let filteredLocations = rawMatches.map(({ station, ...locData }) => ({ + ...locData, + stations: [station] + })); + + // Only check PS if we have more than one match. + if (filteredLocations.length > 1) { + filteredLocations = filteredLocations.map(locData => ({ + ...locData, + stations: locData.stations.filter(station => validPsCompare(rdsPs, station.ps)) + })).filter(locData => locData.stations.length > 0); + } - for (loc of filteredLocations) { + for (let loc of filteredLocations) { loc = Object.assign(loc, loc.stations[0]); delete loc.stations; - const dist = haversine(serverConfig.identification.lat, serverConfig.identification.lon, loc.lat, loc.lon); + const dist = haversine(Latitude, Longitude, loc.lat, loc.lon); loc = Object.assign(loc, dist); loc.detectedByPireg = (loc.pireg === piCode.toUpperCase()); } if (filteredLocations.length > 1) { - for (loc of filteredLocations) { - loc.score = evaluateStation(loc); + // Check for any 10kW+ stations within 700km, and don't Es weight if any found. + const tropoPriority = filteredLocations.some( + loc => loc.distanceKm < 700 && loc.erp >= 10 + ); + let esMode = false; + if (!tropoPriority) { + esMode = checkEs(); } - match = filteredLocations.reduce((max, obj) => obj.score > max.score ? obj : max, filteredLocations[0]); - multiMatches = filteredLocations.filter(obj => obj !== match); + for (let loc of filteredLocations) { + loc.score = evaluateStation(loc, esMode); + } + // Sort by score in descending order + filteredLocations.sort((a, b) => b.score - a.score); + match = filteredLocations[0]; + // Have a maximum of 10 extra matches and remove any with less than 1/10 of the winning score + multiMatches = filteredLocations + .slice(1, 11) + .filter(obj => obj.score >= (match.score / 10)); } else if (filteredLocations.length === 1) { match = filteredLocations[0]; + match.score = 1; } if (match) { @@ -204,7 +312,7 @@ async function fetchTx(freq, piCode, rdsPs) { match.state = state; // Add state to matchingCity } } - return { + const result = { station: match.detectedByPireg ? `${match.station.replace("R.", "Radio ")}${match.regname ? ' ' + match.regname : ''}` : match.station.replace("R.", "Radio "), @@ -218,9 +326,15 @@ async function fetchTx(freq, piCode, rdsPs) { pi: match.pi, foundStation: true, reg: match.detectedByPireg, - others: multiMatches, + score: match.score, + others: multiMatches.slice(), }; + filteredLocations.length = 0; + multiMatches.length = 0; + return result; } else { + filteredLocations.length = 0; + multiMatches.length = 0; return Promise.resolve(); } } @@ -229,11 +343,11 @@ function checkEs() { const now = Date.now(); const url = "https://fmdx.org/includes/tools/get_muf.php"; - if (now - esSwitchCache.lastCheck < esFetchInterval) { + if (esSwitchCache.lastCheck && now - esSwitchCache.lastCheck < esFetchInterval) { return esSwitchCache.esSwitch; } - if (serverConfig.identification.lat > 20) { + if (Latitude > 20) { esSwitchCache.lastCheck = now; fetch(url) .then(response => { @@ -241,8 +355,8 @@ function checkEs() { return response.json(); }) .then(data => { - if ((serverConfig.identification.lon < -32 && data.north_america.max_frequency !== "No data") || - (serverConfig.identification.lon >= -32 && data.europe.max_frequency !== "No data")) { + if ((Longitude < -32 && data.north_america.max_frequency !== "No data") || + (Longitude >= -32 && data.europe.max_frequency !== "No data")) { esSwitchCache.esSwitch = true; } }) diff --git a/web/index.ejs b/web/index.ejs index 37a1e01..473f166 100644 --- a/web/index.ejs +++ b/web/index.ejs @@ -290,7 +290,7 @@
-
+

diff --git a/web/js/main.js b/web/js/main.js index ffde72d..a11f0da 100644 --- a/web/js/main.js +++ b/web/js/main.js @@ -925,13 +925,11 @@ function throttle(fn, wait) { } function buildAltTxList(txList) { - const wrapper = '
'; let outString = ''; - outString += wrapper; for (let i = 0; i < txList.length; i++) { const tx = txList[i]; outString += `
-
+

${tx.station.replace("R.", "Radio ").replace(/%/g, '%25')}

@@ -942,11 +940,7 @@ function buildAltTxList(txList) {
`; - if (i % 2 !== 0) { - outString += `
${wrapper}`; - } } - outString += '
'; return outString; } @@ -962,6 +956,12 @@ function updateHtmlIfChanged($element, newHtml) { } } +function updateDatasetValIfChanged($element, dataLabel, newVal) { + if ($element.attr(dataLabel) !== newVal) { + $element.attr(dataLabel, newVal); + } +} + // Main function to update data elements, optimized const updateDataElements = throttle(function(parsedData) { updateTextIfChanged($dataFrequency, parsedData.freq); @@ -1022,6 +1022,7 @@ const updateDataElements = throttle(function(parsedData) { updateTextIfChanged($('#data-station-pol'), parsedData.txInfo.pol); updateHtmlIfChanged($('#data-station-azimuth'), parsedData.txInfo.azi + '°'); updateHtmlIfChanged($('#data-station-others'), parsedData.txInfo.otherMatches.length > 0 ? ('+' + parsedData.txInfo.otherMatches.length +'') : ''); + updateDatasetValIfChanged($('#data-station-container'), "data-score", parsedData.txInfo.score); const txDistance = localStorage.getItem('imperialUnits') == "true" ? (Number(parsedData.txInfo.dist) * 0.621371192).toFixed(0) + " mi" : parsedData.txInfo.dist + " km"; const altTxInfo = buildAltTxList(parsedData.txInfo.otherMatches); updateHtmlIfChanged($('#alternative-txes'), altTxInfo);