You've already forked fm-dx-webserver
mirror of
https://github.com/KubaPro010/fm-dx-webserver.git
synced 2026-02-26 22:13:53 +01:00
restructure, bugfixes
This commit is contained in:
74
server/console.js
Normal file
74
server/console.js
Normal file
@@ -0,0 +1,74 @@
|
||||
const verboseMode = process.argv.includes('--debug');
|
||||
const verboseModeFfmpeg = process.argv.includes('--ffmpegdebug');
|
||||
|
||||
const getCurrentTime = () => {
|
||||
const currentTime = new Date();
|
||||
const hours = currentTime.getHours().toString().padStart(2, '0');
|
||||
const minutes = currentTime.getMinutes().toString().padStart(2, '0');
|
||||
return `\x1b[90m[${hours}:${minutes}]\x1b[0m`;
|
||||
};
|
||||
|
||||
const MESSAGE_PREFIX = {
|
||||
DEBUG: "\x1b[36m[DEBUG]\x1b[0m",
|
||||
ERROR: "\x1b[31m[ERROR]\x1b[0m",
|
||||
FFMPEG: "\x1b[36m[FFMPEG]\x1b[0m",
|
||||
INFO: "\x1b[32m[INFO]\x1b[0m",
|
||||
WARN: "\x1b[33m[WARN]\x1b[0m",
|
||||
};
|
||||
|
||||
// Initialize an array to store logs
|
||||
const logs = [];
|
||||
const maxLogLines = 250;
|
||||
|
||||
const logDebug = (...messages) => {
|
||||
if (verboseMode) {
|
||||
const logMessage = `${getCurrentTime()} ${MESSAGE_PREFIX.DEBUG} ${messages.join(' ')}`;
|
||||
logs.push(logMessage);
|
||||
if (logs.length > maxLogLines) {
|
||||
logs.shift();
|
||||
}
|
||||
console.log(logMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const logError = (...messages) => {
|
||||
const logMessage = `${getCurrentTime()} ${MESSAGE_PREFIX.ERROR} ${messages.join(' ')}`;
|
||||
logs.push(logMessage);
|
||||
if (logs.length > maxLogLines) {
|
||||
logs.shift();
|
||||
}
|
||||
console.log(logMessage);
|
||||
};
|
||||
|
||||
const logFfmpeg = (...messages) => {
|
||||
if (verboseModeFfmpeg) {
|
||||
const logMessage = `${getCurrentTime()} ${MESSAGE_PREFIX.FFMPEG} ${messages.join(' ')}`;
|
||||
logs.push(logMessage);
|
||||
if (logs.length > maxLogLines) {
|
||||
logs.shift();
|
||||
}
|
||||
console.log(logMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const logInfo = (...messages) => {
|
||||
const logMessage = `${getCurrentTime()} ${MESSAGE_PREFIX.INFO} ${messages.join(' ')}`;
|
||||
logs.push(logMessage);
|
||||
if (logs.length > maxLogLines) {
|
||||
logs.shift();
|
||||
}
|
||||
console.log(logMessage);
|
||||
};
|
||||
|
||||
const logWarn = (...messages) => {
|
||||
const logMessage = `${getCurrentTime()} ${MESSAGE_PREFIX.WARN} ${messages.join(' ')}`;
|
||||
logs.push(logMessage);
|
||||
if (logs.length > maxLogLines) {
|
||||
logs.shift();
|
||||
}
|
||||
console.log(logMessage);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
logError, logDebug, logFfmpeg, logInfo, logWarn, logs
|
||||
};
|
||||
408
server/datahandler.js
Normal file
408
server/datahandler.js
Normal file
@@ -0,0 +1,408 @@
|
||||
/* Libraries / Imports */
|
||||
const fs = require('fs');
|
||||
const https = require('https');
|
||||
const koffi = require('koffi');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const platform = os.platform();
|
||||
const cpuArchitecture = os.arch();
|
||||
const { configName, serverConfig, configUpdate, configSave } = require('./server_config');
|
||||
let unicode_type;
|
||||
let shared_Library;
|
||||
|
||||
if (platform === 'win32') {
|
||||
unicode_type = 'int16_t';
|
||||
shared_Library=path.join(__dirname, "libraries", "librdsparser.dll");
|
||||
} else if (platform === 'linux') {
|
||||
unicode_type = 'int32_t';
|
||||
shared_Library=path.join(__dirname, "libraries", "librdsparser_" + cpuArchitecture + ".so");
|
||||
}
|
||||
|
||||
const lib = koffi.load(shared_Library);
|
||||
const { fetchTx } = require('./tx_search.js');
|
||||
|
||||
koffi.proto('void callback_pi(void *rds, void *user_data)');
|
||||
koffi.proto('void callback_pty(void *rds, void *user_data)');
|
||||
koffi.proto('void callback_tp(void *rds, void *user_data)');
|
||||
koffi.proto('void callback_ta(void *rds, void *user_data)');
|
||||
koffi.proto('void callback_ms(void *rds, void *user_data)');
|
||||
koffi.proto('void callback_ecc(void *rds, void *user_data)');
|
||||
koffi.proto('void callback_country(void *rds, void *user_data)');
|
||||
koffi.proto('void callback_af(void *rds, uint32_t af, void *user_data)');
|
||||
koffi.proto('void callback_ps(void *rds, void *user_data)');
|
||||
koffi.proto('void callback_rt(void *rds, int flag, void *user_data)');
|
||||
koffi.proto('void callback_ptyn(void *rds, void *user_data)');
|
||||
koffi.proto('void callback_ct(void *rds, void *ct, void *user_data)');
|
||||
|
||||
const rdsparser = {
|
||||
new: lib.func('void* rdsparser_new()'),
|
||||
free: lib.func('void rdsparser_free(void *rds)'),
|
||||
clear: lib.func('void rdsparser_clear(void *rds)'),
|
||||
parse_string: lib.func('bool rdsparser_parse_string(void *rds, const char *input)'),
|
||||
set_text_correction: lib.func('bool rdsparser_set_text_correction(void *rds, uint8_t text, uint8_t type, uint8_t error)'),
|
||||
set_text_progressive: lib.func('bool rdsparser_set_text_progressive(void *rds, uint8_t string, bool state)'),
|
||||
get_pi: lib.func('int32_t rdsparser_get_pi(void *rds)'),
|
||||
get_pty: lib.func('int8_t rdsparser_get_pty(void *rds)'),
|
||||
get_tp: lib.func('int8_t rdsparser_get_tp(void *rds)'),
|
||||
get_ta: lib.func('int8_t rdsparser_get_ta(void *rds)'),
|
||||
get_ms: lib.func('int8_t rdsparser_get_ms(void *rds)'),
|
||||
get_ecc: lib.func('int16_t rdsparser_get_ecc(void *rds)'),
|
||||
get_country: lib.func('int rdsparser_get_country(void *rds)'),
|
||||
get_ps: lib.func('void* rdsparser_get_ps(void *rds)'),
|
||||
get_rt: lib.func('void* rdsparser_get_rt(void *rds, int flag)'),
|
||||
get_ptyn: lib.func('void* rdsparser_get_ptyn(void *rds)'),
|
||||
register_pi: lib.func('void rdsparser_register_pi(void *rds, void *cb)'),
|
||||
register_pty: lib.func('void rdsparser_register_pty(void *rds, void *cb)'),
|
||||
register_tp: lib.func('void rdsparser_register_tp(void *rds, void *cb)'),
|
||||
register_ta: lib.func('void rdsparser_register_ta(void *rds, void *cb)'),
|
||||
register_ms: lib.func('void rdsparser_register_ms(void *rds, void *cb)'),
|
||||
register_ecc: lib.func('void rdsparser_register_ecc(void *rds, void *cb)'),
|
||||
register_country: lib.func('void rdsparser_register_country(void *rds, void *cb)'),
|
||||
register_af: lib.func('void rdsparser_register_af(void *rds, void *cb)'),
|
||||
register_ps: lib.func('void rdsparser_register_ps(void *rds, void *cb)'),
|
||||
register_rt: lib.func('void rdsparser_register_rt(void *rds, void *cb)'),
|
||||
register_ptyn: lib.func('void rdsparser_register_ptyn(void *rds, void *cb)'),
|
||||
register_ct: lib.func('void rdsparser_register_ct(void *rds, void *cb)'),
|
||||
string_get_content: lib.func(unicode_type + '* rdsparser_string_get_content(void *string)'),
|
||||
string_get_errors: lib.func('uint8_t* rdsparser_string_get_errors(void *string)'),
|
||||
string_get_length: lib.func('uint8_t rdsparser_string_get_length(void *string)'),
|
||||
ct_get_year: lib.func('uint16_t rdsparser_ct_get_year(void *ct)'),
|
||||
ct_get_month: lib.func('uint8_t rdsparser_ct_get_month(void *ct)'),
|
||||
ct_get_day: lib.func('uint8_t rdsparser_ct_get_day(void *ct)'),
|
||||
ct_get_hour: lib.func('uint8_t rdsparser_ct_get_hour(void *ct)'),
|
||||
ct_get_minute: lib.func('uint8_t rdsparser_ct_get_minute(void *ct)'),
|
||||
ct_get_offset: lib.func('int8_t rdsparser_ct_get_offset(void *ct)'),
|
||||
pty_lookup_short: lib.func('const char* rdsparser_pty_lookup_short(int8_t pty, bool rbds)'),
|
||||
pty_lookup_long: lib.func('const char* rdsparser_pty_lookup_long(int8_t pty, bool rbds)'),
|
||||
country_lookup_name: lib.func('const char* rdsparser_country_lookup_name(int country)'),
|
||||
country_lookup_iso: lib.func('const char* rdsparser_country_lookup_iso(int country)')
|
||||
}
|
||||
|
||||
const callbacks = {
|
||||
pi: koffi.register(rds => (
|
||||
value = rdsparser.get_pi(rds)
|
||||
//console.log('PI: ' + value.toString(16).toUpperCase())
|
||||
), 'callback_pi*'),
|
||||
|
||||
pty: koffi.register(rds => (
|
||||
value = rdsparser.get_pty(rds),
|
||||
dataToSend.pty = value
|
||||
), 'callback_pty*'),
|
||||
|
||||
tp: koffi.register(rds => (
|
||||
value = rdsparser.get_tp(rds),
|
||||
dataToSend.tp = value
|
||||
), 'callback_tp*'),
|
||||
|
||||
ta: koffi.register(rds => (
|
||||
value = rdsparser.get_ta(rds),
|
||||
dataToSend.ta = value
|
||||
), 'callback_ta*'),
|
||||
|
||||
ms: koffi.register(rds => (
|
||||
value = rdsparser.get_ms(rds),
|
||||
dataToSend.ms = value
|
||||
), 'callback_ms*'),
|
||||
|
||||
af: koffi.register((rds, value) => (
|
||||
dataToSend.af.push(value)
|
||||
), 'callback_af*'),
|
||||
|
||||
ecc: koffi.register(rds => (
|
||||
value = rdsparser.get_ecc(rds)
|
||||
), 'callback_ecc*'),
|
||||
|
||||
country: koffi.register(rds => (
|
||||
value = rdsparser.get_country(rds),
|
||||
display = rdsparser.country_lookup_name(value),
|
||||
iso = rdsparser.country_lookup_iso(value),
|
||||
dataToSend.country_name = display,
|
||||
dataToSend.country_iso = iso
|
||||
), 'callback_country*'),
|
||||
|
||||
ps: koffi.register(rds => (
|
||||
ps = rdsparser.get_ps(rds),
|
||||
dataToSend.ps = decode_unicode(ps),
|
||||
dataToSend.ps_errors = decode_errors(ps)
|
||||
), 'callback_ps*'),
|
||||
|
||||
rt: koffi.register((rds, flag) => {
|
||||
const rt = rdsparser.get_rt(rds, flag);
|
||||
|
||||
if (flag === 0) {
|
||||
dataToSend.rt0 = decode_unicode(rt);
|
||||
dataToSend.rt0_errors = decode_errors(rt);
|
||||
}
|
||||
|
||||
if (flag === 1) {
|
||||
dataToSend.rt1 = decode_unicode(rt);
|
||||
dataToSend.rt1_errors = decode_errors(rt);
|
||||
}
|
||||
}, 'callback_rt*'),
|
||||
|
||||
ptyn: koffi.register((rds, flag) => (
|
||||
value = decode_unicode(rdsparser.get_ptyn(rds))
|
||||
/*console.log('PTYN: ' + value)*/
|
||||
), 'callback_ptyn*'),
|
||||
|
||||
ct: koffi.register((rds, ct) => (
|
||||
year = rdsparser.ct_get_year(ct),
|
||||
month = String(rdsparser.ct_get_month(ct)).padStart(2, '0'),
|
||||
day = String(rdsparser.ct_get_day(ct)).padStart(2, '0'),
|
||||
hour = String(rdsparser.ct_get_hour(ct)).padStart(2, '0'),
|
||||
minute = String(rdsparser.ct_get_minute(ct)).padStart(2, '0'),
|
||||
offset = rdsparser.ct_get_offset(ct),
|
||||
tz_sign = (offset >= 0 ? '+' : '-'),
|
||||
tz_hour = String(Math.abs(Math.floor(offset / 60))).padStart(2, '0'),
|
||||
tz_minute = String(Math.abs(offset % 60)).padStart(2, '0')
|
||||
//console.log('CT: ' + year + '-' + month + '-' + day + ' ' + hour + ':' + minute + ' (' + tz_sign + tz_hour + ':' + tz_minute + ')')
|
||||
), 'callback_ct*')
|
||||
};
|
||||
|
||||
let rds = rdsparser.new()
|
||||
rdsparser.set_text_correction(rds, 0, 0, 2);
|
||||
rdsparser.set_text_correction(rds, 0, 1, 2);
|
||||
rdsparser.set_text_correction(rds, 1, 0, 2);
|
||||
rdsparser.set_text_correction(rds, 1, 1, 2);
|
||||
rdsparser.set_text_progressive(rds, 0, true)
|
||||
rdsparser.set_text_progressive(rds, 1, true)
|
||||
rdsparser.register_pi(rds, callbacks.pi);
|
||||
rdsparser.register_pty(rds, callbacks.pty);
|
||||
rdsparser.register_tp(rds, callbacks.tp);
|
||||
rdsparser.register_ta(rds, callbacks.ta);
|
||||
rdsparser.register_ms(rds, callbacks.ms);
|
||||
rdsparser.register_ecc(rds, callbacks.ecc);
|
||||
rdsparser.register_country(rds, callbacks.country);
|
||||
rdsparser.register_af(rds, callbacks.af);
|
||||
rdsparser.register_ps(rds, callbacks.ps);
|
||||
rdsparser.register_rt(rds, callbacks.rt);
|
||||
rdsparser.register_ptyn(rds, callbacks.ptyn);
|
||||
rdsparser.register_ct(rds, callbacks.ct);
|
||||
|
||||
const decode_unicode = function(string) {
|
||||
let length = rdsparser.string_get_length(string);
|
||||
if (length) {
|
||||
let content = rdsparser.string_get_content(string);
|
||||
let array = koffi.decode(content, unicode_type + ' [' + length + ']');
|
||||
return String.fromCodePoint.apply(String, array);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const decode_errors = function(string) {
|
||||
let length = rdsparser.string_get_length(string);
|
||||
if (length) {
|
||||
let errors = rdsparser.string_get_errors(string);
|
||||
let array = koffi.decode(errors, 'uint8_t [' + length + ']');
|
||||
return Uint8Array.from(array).toString();
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const updateInterval = 75;
|
||||
const clientUpdateIntervals = new Map(); // Store update intervals for each client
|
||||
|
||||
// Initialize the data object
|
||||
var dataToSend = {
|
||||
pi: '?',
|
||||
freq: 87.500.toFixed(3),
|
||||
previousFreq: 87.500.toFixed(3),
|
||||
signal: 0,
|
||||
highestSignal: -Infinity,
|
||||
st: false,
|
||||
st_forced: false,
|
||||
rds: false,
|
||||
ps: '',
|
||||
tp: 0,
|
||||
ta: 0,
|
||||
ms: -1,
|
||||
pty: 0,
|
||||
af: [],
|
||||
rt0: '',
|
||||
rt1: '',
|
||||
ims: 0,
|
||||
eq: 0,
|
||||
ant: 0,
|
||||
txInfo: {
|
||||
station: '',
|
||||
pol: '',
|
||||
erp: '',
|
||||
city: '',
|
||||
itu: '',
|
||||
distance: '',
|
||||
azimuth: ''
|
||||
},
|
||||
country_name: '',
|
||||
country_iso: 'UN',
|
||||
users: 0,
|
||||
};
|
||||
|
||||
const filterMappings = {
|
||||
'G11': { eq: 1, ims: 1 },
|
||||
'G01': { eq: 0, ims: 1 },
|
||||
'G10': { eq: 1, ims: 0 },
|
||||
'G00': { eq: 0, ims: 0 }
|
||||
};
|
||||
|
||||
|
||||
var legacyRdsPiBuffer = null;
|
||||
const initialData = { ...dataToSend };
|
||||
const resetToDefault = dataToSend => Object.assign(dataToSend, initialData);
|
||||
|
||||
|
||||
function handleData(ws, receivedData) {
|
||||
// Retrieve the last update time for this client
|
||||
let lastUpdateTime = clientUpdateIntervals.get(ws) || 0;
|
||||
const currentTime = Date.now();
|
||||
|
||||
let modifiedData, parsedValue;
|
||||
const receivedLines = receivedData.split('\n');
|
||||
|
||||
for (const receivedLine of receivedLines) {
|
||||
switch (true) {
|
||||
case receivedLine.startsWith('P'):
|
||||
modifiedData = receivedLine.slice(1);
|
||||
legacyRdsPiBuffer = modifiedData;
|
||||
if (dataToSend.pi.length >= modifiedData.length || dataToSend.pi == '?') {
|
||||
dataToSend.pi = modifiedData;
|
||||
}
|
||||
break;
|
||||
case receivedLine.startsWith('T'):
|
||||
resetToDefault(dataToSend);
|
||||
dataToSend.af.length = 0;
|
||||
rdsparser.clear(rds);
|
||||
modifiedData = receivedLine.substring(1);
|
||||
parsedValue = parseFloat(modifiedData);
|
||||
|
||||
if (!isNaN(parsedValue)) {
|
||||
initialData.freq = (parsedValue / 1000).toFixed(3);
|
||||
dataToSend.freq = (parsedValue / 1000).toFixed(3);
|
||||
dataToSend.pi = '?';
|
||||
}
|
||||
break;
|
||||
case receivedLine.startsWith('Z'):
|
||||
dataToSend.ant = receivedLine.substring(1);
|
||||
initialData.ant = receivedLine.substring(1);
|
||||
break;
|
||||
case receivedLine.startsWith('G'):
|
||||
const mapping = filterMappings[receivedLine];
|
||||
if (mapping) {
|
||||
initialData.eq = mapping.eq;
|
||||
initialData.ims = mapping.ims;
|
||||
dataToSend.eq = mapping.eq;
|
||||
dataToSend.ims = mapping.ims;
|
||||
}
|
||||
break;
|
||||
case receivedData.startsWith('Sm'):
|
||||
processSignal(receivedData, false, false);
|
||||
break;
|
||||
case receivedData.startsWith('Ss'):
|
||||
processSignal(receivedData, true, false);
|
||||
break;
|
||||
case receivedData.startsWith('SS'):
|
||||
processSignal(receivedData, true, true);
|
||||
break;
|
||||
case receivedData.startsWith('SM'):
|
||||
processSignal(receivedData, false, true);
|
||||
break;
|
||||
case receivedLine.startsWith('R'):
|
||||
modifiedData = receivedLine.slice(1);
|
||||
dataToSend.rds = true;
|
||||
|
||||
if (modifiedData.length == 14) {
|
||||
// Handle legacy RDS message
|
||||
var errorsNew = 0;
|
||||
var pi;
|
||||
|
||||
if (legacyRdsPiBuffer !== null &&
|
||||
legacyRdsPiBuffer.length >= 4) {
|
||||
pi = legacyRdsPiBuffer.slice(0, 4);
|
||||
// PI message does not carry explicit information about
|
||||
// error correction, but this is a good substitute.
|
||||
errorsNew = (legacyRdsPiBuffer.length - 4) << 6;
|
||||
} else {
|
||||
pi = '0000';
|
||||
errorsNew = (0x03 << 6);
|
||||
}
|
||||
|
||||
let errorsOld = parseInt(modifiedData.slice(12), 16);
|
||||
errorsNew |= (errorsOld & 0x03) << 4;
|
||||
errorsNew |= (errorsOld & 0x0C);
|
||||
errorsNew |= (errorsOld & 0x30) >> 4;
|
||||
|
||||
modifiedData = pi + modifiedData.slice(0, 12);
|
||||
modifiedData += errorsNew.toString(16).padStart(2, '0');
|
||||
}
|
||||
|
||||
rdsparser.parse_string(rds, modifiedData);
|
||||
legacyRdsPiBuffer = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the received TX info
|
||||
const currentTx = fetchTx(parseFloat(dataToSend.freq).toFixed(1), dataToSend.pi, dataToSend.ps);
|
||||
if(currentTx && currentTx.station !== undefined) {
|
||||
dataToSend.txInfo = {
|
||||
station: currentTx.station,
|
||||
pol: currentTx.pol,
|
||||
erp: currentTx.erp,
|
||||
city: currentTx.city,
|
||||
itu: currentTx.itu,
|
||||
distance: currentTx.distance,
|
||||
azimuth: currentTx.azimuth
|
||||
}
|
||||
}
|
||||
|
||||
// Send the updated data to the client
|
||||
const dataToSendJSON = JSON.stringify(dataToSend);
|
||||
if (currentTime - lastUpdateTime >= updateInterval) {
|
||||
clientUpdateIntervals.set(ws, currentTime); // Update the last update time for this client
|
||||
ws.send(dataToSendJSON);
|
||||
}
|
||||
}
|
||||
|
||||
function showOnlineUsers(currentUsers) {
|
||||
dataToSend.users = currentUsers;
|
||||
initialData.users = currentUsers;
|
||||
}
|
||||
|
||||
function convertSignal(dBFS, fullScaleVoltage = 1, inputImpedance = 300) {
|
||||
// Convert dBFS to voltage
|
||||
let voltage = Math.pow(10, dBFS / 20) * fullScaleVoltage;
|
||||
|
||||
// Convert voltage to microvolts
|
||||
let uV = voltage * 1e6;
|
||||
|
||||
// Convert microvolts to dBuV
|
||||
let dBf = 20 * Math.log10(uV / Math.sqrt(2) / Math.sqrt(inputImpedance));
|
||||
|
||||
return dBf.toFixed(2);
|
||||
}
|
||||
|
||||
function processSignal(receivedData, st, stForced) {
|
||||
const modifiedData = receivedData.substring(2);
|
||||
const parsedValue = parseFloat(modifiedData);
|
||||
dataToSend.st = st;
|
||||
dataToSend.st_forced = stForced;
|
||||
initialData.st = st;
|
||||
initialData.st_forced = stForced;
|
||||
|
||||
if (!isNaN(parsedValue)) {
|
||||
/*if (serverConfig.device && serverConfig.device === 'sdr') {
|
||||
dataToSend.signal = convertSignal(parsedValue);
|
||||
initialData.signal = convertSignal(parsedValue);
|
||||
} else {*/
|
||||
dataToSend.signal = parsedValue.toFixed(2);
|
||||
initialData.signal = parsedValue.toFixed(2);
|
||||
//}
|
||||
|
||||
if(dataToSend.signal > dataToSend.highestSignal) {
|
||||
dataToSend.highestSignal = dataToSend.signal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
handleData, showOnlineUsers, dataToSend, initialData, resetToDefault
|
||||
};
|
||||
224
server/endpoints.js
Normal file
224
server/endpoints.js
Normal file
@@ -0,0 +1,224 @@
|
||||
// Library imports
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const fs = require('fs');
|
||||
const { SerialPort } = require('serialport')
|
||||
const path = require('path');
|
||||
|
||||
// File Imports
|
||||
const { parseAudioDevice } = require('./stream/parser');
|
||||
const { configName, serverConfig, configUpdate, configSave, configExists, configPath } = require('./server_config');
|
||||
const helpers = require('./helpers');
|
||||
const storage = require('./storage');
|
||||
const { logInfo, logDebug, logWarn, logError, logFfmpeg, logs } = require('./console');
|
||||
const dataHandler = require('./datahandler');
|
||||
const fmdxList = require('./fmdx_list');
|
||||
|
||||
// Endpoints
|
||||
router.get('/', (req, res) => {
|
||||
if (configExists() === false) {
|
||||
let serialPorts;
|
||||
|
||||
SerialPort.list()
|
||||
.then((deviceList) => {
|
||||
serialPorts = deviceList.map(port => ({
|
||||
path: port.path,
|
||||
friendlyName: port.friendlyName,
|
||||
}));
|
||||
|
||||
parseAudioDevice((result) => {
|
||||
res.render('wizard', {
|
||||
isAdminAuthenticated: true,
|
||||
videoDevices: result.audioDevices,
|
||||
audioDevices: result.videoDevices,
|
||||
serialPorts: serialPorts
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
res.render('index', {
|
||||
isAdminAuthenticated: req.session.isAdminAuthenticated,
|
||||
isTuneAuthenticated: req.session.isTuneAuthenticated,
|
||||
tunerName: serverConfig.identification.tunerName,
|
||||
tunerDesc: helpers.parseMarkdown(serverConfig.identification.tunerDesc),
|
||||
tunerDescMeta: helpers.removeMarkdown(serverConfig.identification.tunerDesc),
|
||||
tunerLock: serverConfig.lockToAdmin,
|
||||
publicTuner: serverConfig.publicTuner,
|
||||
ownerContact: serverConfig.identification.contact,
|
||||
antennaSwitch: serverConfig.antennaSwitch,
|
||||
tuningLimit: serverConfig.webserver.tuningLimit,
|
||||
tuningLowerLimit: serverConfig.webserver.tuningLowerLimit,
|
||||
tuningUpperLimit: serverConfig.webserver.tuningUpperLimit,
|
||||
chatEnabled: serverConfig.webserver.chatEnabled,
|
||||
device: serverConfig.device
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/wizard', (req, res) => {
|
||||
let serialPorts;
|
||||
|
||||
SerialPort.list()
|
||||
.then((deviceList) => {
|
||||
serialPorts = deviceList.map(port => ({
|
||||
path: port.path,
|
||||
friendlyName: port.friendlyName,
|
||||
}));
|
||||
|
||||
parseAudioDevice((result) => {
|
||||
res.render('wizard', {
|
||||
isAdminAuthenticated: req.session.isAdminAuthenticated,
|
||||
videoDevices: result.audioDevices,
|
||||
audioDevices: result.videoDevices,
|
||||
serialPorts: serialPorts
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
router.get('/setup', (req, res) => {
|
||||
let serialPorts;
|
||||
|
||||
SerialPort.list()
|
||||
.then((deviceList) => {
|
||||
serialPorts = deviceList.map(port => ({
|
||||
path: port.path,
|
||||
friendlyName: port.friendlyName,
|
||||
}));
|
||||
|
||||
parseAudioDevice((result) => {
|
||||
const processUptimeInSeconds = Math.floor(process.uptime());
|
||||
const formattedProcessUptime = helpers.formatUptime(processUptimeInSeconds);
|
||||
|
||||
res.render('setup', {
|
||||
isAdminAuthenticated: req.session.isAdminAuthenticated,
|
||||
videoDevices: result.audioDevices,
|
||||
audioDevices: result.videoDevices,
|
||||
serialPorts: serialPorts,
|
||||
memoryUsage: (process.memoryUsage.rss() / 1024 / 1024).toFixed(1) + ' MB',
|
||||
processUptime: formattedProcessUptime,
|
||||
consoleOutput: logs,
|
||||
onlineUsers: dataHandler.dataToSend.users,
|
||||
connectedUsers: storage.connectedUsers
|
||||
});
|
||||
});
|
||||
})
|
||||
|
||||
});
|
||||
|
||||
router.get('/api', (req, res) => {
|
||||
const { ps_errors, rt0_errors, rt1_errors, ims, eq, ant, st_forced, previousFreq, txInfo, ...dataToSend } = dataHandler.dataToSend;
|
||||
res.json(dataToSend);
|
||||
});
|
||||
|
||||
|
||||
const authenticate = (req, res, next) => {
|
||||
const { password } = req.body;
|
||||
|
||||
// Check if the entered password matches the admin password
|
||||
if (password === serverConfig.password.adminPass) {
|
||||
req.session.isAdminAuthenticated = true;
|
||||
req.session.isTuneAuthenticated = true;
|
||||
logInfo('User from ' + req.connection.remoteAddress + ' logged in as an administrator.');
|
||||
next();
|
||||
} else if (password === serverConfig.password.tunePass) {
|
||||
req.session.isAdminAuthenticated = false;
|
||||
req.session.isTuneAuthenticated = true;
|
||||
logInfo('User from ' + req.connection.remoteAddress + ' logged in with tune permissions.');
|
||||
next();
|
||||
} else {
|
||||
res.status(403).json({ message: 'Login failed. Wrong password?' });
|
||||
}
|
||||
};
|
||||
|
||||
// Route for login
|
||||
router.post('/login', authenticate, (req, res) => {
|
||||
// Redirect to the main page after successful login
|
||||
res.status(200).json({ message: 'Logged in successfully, refreshing the page...' });
|
||||
});
|
||||
|
||||
router.get('/logout', (req, res) => {
|
||||
// Clear the session and redirect to the main page
|
||||
req.session.destroy(() => {
|
||||
res.status(200).json({ message: 'Logged out successfully, refreshing the page...' });
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/saveData', (req, res) => {
|
||||
const data = req.body;
|
||||
let firstSetup;
|
||||
if(req.session.isAdminAuthenticated || configExists() === false) {
|
||||
configUpdate(data);
|
||||
fmdxList.update();
|
||||
|
||||
if(configExists() === false) {
|
||||
firstSetup = true;
|
||||
}
|
||||
|
||||
/* TODO: Refactor to server_config.js */
|
||||
// Save data to a JSON file
|
||||
fs.writeFile(configPath, JSON.stringify(serverConfig, null, 2), (err) => {
|
||||
if (err) {
|
||||
logError(err);
|
||||
res.status(500).send('Internal Server Error');
|
||||
} else {
|
||||
logInfo('Server config changed successfully.');
|
||||
if(firstSetup === true) {
|
||||
res.status(200).send('Data saved successfully!\nPlease, restart the server to load your configuration.');
|
||||
} else {
|
||||
res.status(200).send('Data saved successfully!\nSome settings may need a server restart to apply.');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/getData', (req, res) => {
|
||||
if(req.session.isAdminAuthenticated) {
|
||||
// Check if the file exists
|
||||
fs.access(configPath, fs.constants.F_OK, (err) => {
|
||||
if (err) {
|
||||
// File does not exist
|
||||
res.status(404).send('Data not found');
|
||||
} else {
|
||||
// File exists, send it as the response
|
||||
res.sendFile(path.join(__dirname, '../' + configName + '.json'));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/getDevices', (req, res) => {
|
||||
if (req.session.isAdminAuthenticated || !fs.existsSync(configName + '.json')) {
|
||||
parseAudioDevice((result) => {
|
||||
res.json(result);
|
||||
});
|
||||
} else {
|
||||
res.status(403).json({ error: 'Unauthorized' });
|
||||
}
|
||||
});
|
||||
|
||||
/* Static data are being sent through here on connection - these don't change when the server is running */
|
||||
router.get('/static_data', (req, res) => {
|
||||
res.json({
|
||||
qthLatitude: serverConfig.identification.lat,
|
||||
qthLongitude: serverConfig.identification.lon,
|
||||
presets: serverConfig.webserver.presets || [],
|
||||
defaultTheme: serverConfig.webserver.defaultTheme || 'theme1'
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/server_time', (req, res) => {
|
||||
const serverTime = new Date(); // Get current server time
|
||||
const serverTimeUTC = new Date(serverTime.getTime() - (serverTime.getTimezoneOffset() * 60000)); // Adjust server time to UTC
|
||||
res.json({
|
||||
serverTime: serverTimeUTC,
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/ping', (req, res) => {
|
||||
res.send('pong');
|
||||
});
|
||||
|
||||
|
||||
module.exports = router;
|
||||
115
server/fmdx_list.js
Normal file
115
server/fmdx_list.js
Normal file
@@ -0,0 +1,115 @@
|
||||
/* Libraries / Imports */
|
||||
const fs = require('fs');
|
||||
const fetch = require('node-fetch');
|
||||
const { logDebug, logError, logInfo, logWarn } = require('./console');
|
||||
const { serverConfig, configUpdate, configSave } = require('./server_config');
|
||||
var pjson = require('../package.json');
|
||||
|
||||
let timeoutID = null;
|
||||
|
||||
function send(request) {
|
||||
const url = "https://list.fmdx.pl/api/";
|
||||
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(request)
|
||||
};
|
||||
|
||||
fetch(url, options)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success && data.token)
|
||||
{
|
||||
if (!serverConfig.identification.token)
|
||||
{
|
||||
logInfo("Registered to FM-DX Server Map successfully.");
|
||||
serverConfig.identification.token = data.token;
|
||||
configSave();
|
||||
}
|
||||
else
|
||||
{
|
||||
logDebug("FM-DX Server Map update successful.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
logWarn("Failed to update FM-DX Server Map: " + (data.error ? data.error : 'unknown error'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
logWarn("Failed to update FM-DX Server Map: " + error);
|
||||
});
|
||||
}
|
||||
|
||||
function sendKeepalive() {
|
||||
if (!serverConfig.identification.token)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const request = {
|
||||
token: serverConfig.identification.token,
|
||||
status: (serverConfig.lockToAdmin ? 2 : 1)
|
||||
};
|
||||
|
||||
send(request);
|
||||
}
|
||||
|
||||
function sendUpdate() {
|
||||
|
||||
let bwLimit = '';
|
||||
if (serverConfig.webserver.tuningLimit === true) {
|
||||
bwLimit = serverConfig.webserver.tuningLowerLimit + ' - ' + serverConfig.webserver.tuningUpperLimit + ' Mhz';
|
||||
}
|
||||
|
||||
const request = {
|
||||
status: (serverConfig.lockToAdmin ? 2 : 1),
|
||||
coords: [serverConfig.identification.lat, serverConfig.identification.lon],
|
||||
name: serverConfig.identification.tunerName,
|
||||
desc: serverConfig.identification.tunerDesc,
|
||||
audioChannels: serverConfig.audio.audioChannels,
|
||||
audioQuality: serverConfig.audio.audioBitrate,
|
||||
contact: serverConfig.identification.contact || '',
|
||||
tuner: serverConfig.device || '',
|
||||
bwLimit: bwLimit,
|
||||
version: pjson.version
|
||||
};
|
||||
|
||||
if (serverConfig.identification.token)
|
||||
{
|
||||
request.token = serverConfig.identification.token;
|
||||
}
|
||||
|
||||
if (serverConfig.identification.proxyIp.length)
|
||||
{
|
||||
request.url = serverConfig.identification.proxyIp;
|
||||
}
|
||||
else
|
||||
{
|
||||
request.port = serverConfig.webserver.webserverPort;
|
||||
}
|
||||
|
||||
send(request);
|
||||
}
|
||||
|
||||
function update() {
|
||||
if (timeoutID !== null) {
|
||||
clearTimeout(timeoutID);
|
||||
}
|
||||
|
||||
if (!serverConfig.identification.broadcastTuner)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
sendUpdate();
|
||||
timeoutID = setInterval(sendKeepalive, 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
update
|
||||
};
|
||||
84
server/helpers.js
Normal file
84
server/helpers.js
Normal file
@@ -0,0 +1,84 @@
|
||||
const WebSocket = require('ws');
|
||||
const dataHandler = require('./datahandler');
|
||||
|
||||
function parseMarkdown(parsed) {
|
||||
parsed = parsed.replace(/<\/?[^>]+(>|$)/g, '');
|
||||
|
||||
var grayTextRegex = /--(.*?)--/g;
|
||||
parsed = parsed.replace(grayTextRegex, '<span class="text-gray">$1</span>');
|
||||
|
||||
var boldRegex = /\*\*(.*?)\*\*/g;
|
||||
parsed = parsed.replace(boldRegex, '<strong>$1</strong>');
|
||||
|
||||
var italicRegex = /\*(.*?)\*/g;
|
||||
parsed = parsed.replace(italicRegex, '<em>$1</em>');
|
||||
|
||||
var linkRegex = /\[([^\]]+)]\(([^)]+)\)/g;
|
||||
parsed = parsed.replace(linkRegex, '<a href="$2">$1</a>');
|
||||
|
||||
parsed = parsed.replace(/\n/g, '<br>');
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function removeMarkdown(parsed) {
|
||||
parsed = parsed.replace(/<\/?[^>]+(>|$)/g, '');
|
||||
|
||||
var grayTextRegex = /--(.*?)--/g;
|
||||
parsed = parsed.replace(grayTextRegex, '$1');
|
||||
|
||||
var boldRegex = /\*\*(.*?)\*\*/g;
|
||||
parsed = parsed.replace(boldRegex, '$1');
|
||||
|
||||
var italicRegex = /\*(.*?)\*/g;
|
||||
parsed = parsed.replace(italicRegex, '$1');
|
||||
|
||||
var linkRegex = /\[([^\]]+)]\(([^)]+)\)/g;
|
||||
parsed = parsed.replace(linkRegex, '$1');
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function formatUptime(uptimeInSeconds) {
|
||||
const secondsInMinute = 60;
|
||||
const secondsInHour = secondsInMinute * 60;
|
||||
const secondsInDay = secondsInHour * 24;
|
||||
|
||||
const days = Math.floor(uptimeInSeconds / secondsInDay);
|
||||
const hours = Math.floor((uptimeInSeconds % secondsInDay) / secondsInHour);
|
||||
const minutes = Math.floor((uptimeInSeconds % secondsInHour) / secondsInMinute);
|
||||
|
||||
return `${days}d ${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
let incompleteDataBuffer = '';
|
||||
|
||||
function resolveDataBuffer(data, wss) {
|
||||
var receivedData = incompleteDataBuffer + data.toString();
|
||||
const isIncomplete = (receivedData.slice(-1) != '\n');
|
||||
|
||||
if (isIncomplete) {
|
||||
const position = receivedData.lastIndexOf('\n');
|
||||
if (position < 0) {
|
||||
incompleteDataBuffer = receivedData;
|
||||
receivedData = '';
|
||||
} else {
|
||||
incompleteDataBuffer = receivedData.slice(position + 1);
|
||||
receivedData = receivedData.slice(0, position + 1);
|
||||
}
|
||||
} else {
|
||||
incompleteDataBuffer = '';
|
||||
}
|
||||
|
||||
if (receivedData.length) {
|
||||
wss.clients.forEach((client) => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
dataHandler.handleData(client, receivedData);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseMarkdown, removeMarkdown, formatUptime, resolveDataBuffer
|
||||
}
|
||||
430
server/index.js
Normal file
430
server/index.js
Normal file
@@ -0,0 +1,430 @@
|
||||
// Library imports
|
||||
const express = require('express');
|
||||
const endpoints = require('./endpoints');
|
||||
const session = require('express-session');
|
||||
const bodyParser = require('body-parser');
|
||||
const http = require('http');
|
||||
const httpProxy = require('http-proxy');
|
||||
const https = require('https');
|
||||
const app = express();
|
||||
const httpServer = http.createServer(app);
|
||||
const WebSocket = require('ws');
|
||||
const wss = new WebSocket.Server({ noServer: true });
|
||||
const chatWss = new WebSocket.Server({ noServer: true });
|
||||
const path = require('path');
|
||||
const net = require('net');
|
||||
const client = new net.Socket();
|
||||
const crypto = require('crypto');
|
||||
const { SerialPort } = require('serialport')
|
||||
|
||||
// File imports
|
||||
const helpers = require('./helpers');
|
||||
const dataHandler = require('./datahandler');
|
||||
const fmdxList = require('./fmdx_list');
|
||||
const { logDebug, logError, logInfo, logWarn } = require('./console');
|
||||
const storage = require('./storage');
|
||||
const audioStream = require('./stream/index.js');
|
||||
const { configName, serverConfig, configUpdate, configSave } = require('./server_config');
|
||||
const pjson = require('../package.json');
|
||||
|
||||
console.log(`\x1b[32m
|
||||
_____ __ __ ______ __ __ __ _
|
||||
| ___| \\/ | | _ \\ \\/ / \\ \\ / /__| |__ ___ ___ _ ____ _____ _ __
|
||||
| |_ | |\\/| |_____| | | \\ / \\ \\ /\\ / / _ \\ '_ \\/ __|/ _ \\ '__\\ \\ / / _ \\ '__|
|
||||
| _| | | | |_____| |_| / \\ \\ V V / __/ |_) \\__ \\ __/ | \\ V / __/ |
|
||||
|_| |_| |_| |____/_/\\_\\ \\_/\\_/ \\___|_.__/|___/\\___|_| \\_/ \\___|_|
|
||||
`);
|
||||
console.log('\x1b[0mFM-DX-Webserver', pjson.version);
|
||||
console.log('\x1b[90m======================================================');
|
||||
|
||||
// Create a WebSocket proxy instance
|
||||
const proxy = httpProxy.createProxyServer({
|
||||
target: 'ws://localhost:' + (Number(serverConfig.webserver.webserverPort) + 10), // WebSocket httpServer's address
|
||||
ws: true, // Enable WebSocket proxying
|
||||
changeOrigin: true // Change the origin of the host header to the target URL
|
||||
});
|
||||
|
||||
let currentUsers = 0;
|
||||
let serialport;
|
||||
|
||||
app.use(bodyParser.urlencoded({ extended: true }));
|
||||
const sessionMiddleware = session({
|
||||
secret: 'GTce3tN6U8odMwoI',
|
||||
resave: false,
|
||||
saveUninitialized: true,
|
||||
});
|
||||
app.use(sessionMiddleware);
|
||||
app.use(bodyParser.json());
|
||||
|
||||
connectToXdrd();
|
||||
connectToSerial();
|
||||
|
||||
// Serial Connection
|
||||
function connectToSerial() {
|
||||
if (serverConfig.xdrd.wirelessConnection === false) {
|
||||
|
||||
serialport = new SerialPort({path: serverConfig.xdrd.comPort, baudRate: 115200 });
|
||||
|
||||
serialport.on('open', () => {
|
||||
logInfo('Using COM device: ' + serverConfig.xdrd.comPort);
|
||||
|
||||
serialport.write('x\n');
|
||||
serialport.write('M0\n');
|
||||
serialport.write('Y100\n');
|
||||
serialport.write('D0\n');
|
||||
serialport.write('A0\n');
|
||||
serialport.write('F-1\n');
|
||||
serialport.write('Z0\n');
|
||||
serialport.write('G11\n');
|
||||
serialport.write('V0\n');
|
||||
serialport.write('Q0\n');
|
||||
serialport.write('C0\n');
|
||||
serialport.write('I0,0\n');
|
||||
|
||||
if(serverConfig.defaultFreq) {
|
||||
serialport.write('T' + Math.round(serverConfig.defaultFreq * 1000) +'\n');
|
||||
dataHandler.initialData.freq = Number(serverConfig.defaultFreq).toFixed(3);
|
||||
dataHandler.dataToSend.freq = Number(serverConfig.defaultFreq).toFixed(3);
|
||||
} else {
|
||||
serialport.write('T87500\n');
|
||||
}
|
||||
|
||||
serialport.on('data', (data) => {
|
||||
helpers.resolveDataBuffer(data, wss);
|
||||
});
|
||||
});
|
||||
|
||||
serialport.on('error', (error) => {
|
||||
logError(error.message);
|
||||
});
|
||||
|
||||
return serialport;
|
||||
}
|
||||
}
|
||||
|
||||
// xdrd connection
|
||||
function connectToXdrd() {
|
||||
const { xdrd } = serverConfig;
|
||||
|
||||
if (xdrd.wirelessConnection) {
|
||||
client.connect(xdrd.xdrdPort, xdrd.xdrdIp, () => {
|
||||
logInfo('Connection to xdrd established successfully.');
|
||||
|
||||
const authFlags = {
|
||||
authMsg: false,
|
||||
firstClient: false,
|
||||
receivedPassword: false
|
||||
};
|
||||
|
||||
const authDataHandler = (data) => {
|
||||
const receivedData = data.toString();
|
||||
const lines = receivedData.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (!authFlags.receivedPassword) {
|
||||
authFlags.receivedSalt = line.trim();
|
||||
authenticateWithXdrd(client, authFlags.receivedSalt, xdrd.xdrdPassword);
|
||||
authFlags.receivedPassword = true;
|
||||
} else {
|
||||
if (line.startsWith('a')) {
|
||||
authFlags.authMsg = true;
|
||||
logWarn('Authentication with xdrd failed. Is your password set correctly?');
|
||||
} else if (line.startsWith('o1,')) {
|
||||
authFlags.firstClient = true;
|
||||
} else if (line.startsWith('T') && line.length <= 7) {
|
||||
const freq = line.slice(1) / 1000;
|
||||
dataHandler.dataToSend.freq = freq.toFixed(3);
|
||||
} else if (line.startsWith('OK')) {
|
||||
authFlags.authMsg = true;
|
||||
logInfo('Authentication with xdrd successful.');
|
||||
} else if (line.startsWith('G')) {
|
||||
const [command, value] = line.split('');
|
||||
switch (command) {
|
||||
case 'G':
|
||||
dataHandler.initialData.eq = value[1];
|
||||
dataHandler.dataToSend.eq = value[1];
|
||||
dataHandler.initialData.ims = value[0];
|
||||
dataHandler.dataToSend.ims = value[0];
|
||||
break;
|
||||
}
|
||||
} else if (line.startsWith('Z')) {
|
||||
let modifiedLine = line.slice(1);
|
||||
dataHandler.initialData.ant = modifiedLine;
|
||||
dataHandler.dataToSend.ant = modifiedLine;
|
||||
}
|
||||
|
||||
if (authFlags.authMsg && authFlags.firstClient) {
|
||||
client.write('x\n');
|
||||
client.write(serverConfig.defaultFreq ? 'T' + Math.round(serverConfig.defaultFreq * 1000) + '\n' : 'T87500\n');
|
||||
client.write('A0\n');
|
||||
client.write('G00\n');
|
||||
client.off('data', authDataHandler);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
client.on('data', (data) => {
|
||||
helpers.resolveDataBuffer(data, wss);
|
||||
authDataHandler(data);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
client.on('close', () => {
|
||||
if(serverConfig.autoShutdown === false) {
|
||||
logWarn('Disconnected from xdrd. Attempting to reconnect.');
|
||||
setTimeout(function () {
|
||||
connectToXdrd();
|
||||
}, 2000)
|
||||
} else {
|
||||
logWarn('Disconnected from xdrd.');
|
||||
}
|
||||
});
|
||||
|
||||
client.on('error', (err) => {
|
||||
switch (true) {
|
||||
case err.message.includes("ECONNRESET"):
|
||||
logError("Connection to xdrd lost. Reconnecting...");
|
||||
break;
|
||||
case err.message.includes("ETIMEDOUT"):
|
||||
logError("Connection to xdrd @ " + serverConfig.xdrd.xdrdIp + ":" + serverConfig.xdrd.xdrdPort + " timed out.");
|
||||
break;
|
||||
case err.message.includes("ECONNREFUSED"):
|
||||
logError("Connection to xdrd @ " + serverConfig.xdrd.xdrdIp + ":" + serverConfig.xdrd.xdrdPort + " failed. Is xdrd running?");
|
||||
break;
|
||||
case err.message.includes("EINVAL"):
|
||||
logError("Attempts to reconnect are failing repeatedly. Consider checking your settings or restarting xdrd.");
|
||||
break;
|
||||
default:
|
||||
logError("Unhandled error: ", err.message);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
function authenticateWithXdrd(client, salt, password) {
|
||||
const sha1 = crypto.createHash('sha1');
|
||||
const saltBuffer = Buffer.from(salt, 'utf-8');
|
||||
const passwordBuffer = Buffer.from(password, 'utf-8');
|
||||
sha1.update(saltBuffer);
|
||||
sha1.update(passwordBuffer);
|
||||
|
||||
const hashedPassword = sha1.digest('hex');
|
||||
client.write(hashedPassword + '\n');
|
||||
client.write('x\n');
|
||||
}
|
||||
|
||||
app.set('view engine', 'ejs');
|
||||
app.set('views', path.join(__dirname, '../web'));
|
||||
app.use('/', endpoints);
|
||||
|
||||
/**
|
||||
* WEBSOCKET BLOCK
|
||||
*/
|
||||
wss.on('connection', (ws, request) => {
|
||||
const clientIp = request.headers['x-forwarded-for'] || request.connection.remoteAddress;
|
||||
currentUsers++;
|
||||
dataHandler.showOnlineUsers(currentUsers);
|
||||
if(currentUsers === 1 && serverConfig.autoShutdown === true && serverConfig.xdrd.wirelessConnection) {
|
||||
serverConfig.xdrd.wirelessConnection === true ? connectToXdrd() : serialport.write('x\n');
|
||||
}
|
||||
|
||||
// Use ipinfo.io API to get geolocation information
|
||||
https.get(`https://ipinfo.io/${clientIp}/json`, (response) => {
|
||||
let data = '';
|
||||
|
||||
response.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
response.on('end', () => {
|
||||
try {
|
||||
const locationInfo = JSON.parse(data);
|
||||
const options = { year: 'numeric', month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' };
|
||||
const connectionTime = new Date().toLocaleString([], options);
|
||||
|
||||
if(locationInfo.country === undefined) {
|
||||
const userData = { ip: clientIp, location: 'Unknown', time: connectionTime };
|
||||
storage.connectedUsers.push(userData);
|
||||
logInfo(`Web client \x1b[32mconnected\x1b[0m (${clientIp}) \x1b[90m[${currentUsers}]\x1b[0m`);
|
||||
} else {
|
||||
const userLocation = `${locationInfo.city}, ${locationInfo.region}, ${locationInfo.country}`;
|
||||
const userData = { ip: clientIp, location: userLocation, time: connectionTime };
|
||||
storage.connectedUsers.push(userData);
|
||||
logInfo(`Web client \x1b[32mconnected\x1b[0m (${clientIp}) \x1b[90m[${currentUsers}]\x1b[0m Location: ${locationInfo.city}, ${locationInfo.region}, ${locationInfo.country}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logInfo(`Web client \x1b[32mconnected\x1b[0m (${clientIp}) \x1b[90m[${currentUsers}]\x1b[0m`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ws.on('message', (message) => {
|
||||
const command = message.toString();
|
||||
logDebug(`Command received from \x1b[90m${clientIp}\x1b[0m: ${command}`);
|
||||
|
||||
if (command.startsWith('X')) {
|
||||
logWarn(`Remote tuner shutdown attempted by \x1b[90m${clientIp}\x1b[0m. You may consider blocking this user.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.includes("'")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.startsWith('w') && request.session.isAdminAuthenticated) {
|
||||
switch (command) {
|
||||
case 'wL1':
|
||||
serverConfig.lockToAdmin = true;
|
||||
break;
|
||||
case 'wL0':
|
||||
serverConfig.lockToAdmin = false;
|
||||
break;
|
||||
case 'wT0':
|
||||
serverConfig.publicTuner = true;
|
||||
break;
|
||||
case 'wT1':
|
||||
serverConfig.publicTuner = false;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (command.startsWith('T')) {
|
||||
const tuneFreq = Number(command.slice(1)) / 1000;
|
||||
const { tuningLimit, tuningLowerLimit, tuningUpperLimit } = serverConfig.webserver;
|
||||
|
||||
if (tuningLimit && (tuneFreq < tuningLowerLimit || tuneFreq > tuningUpperLimit) || isNaN(tuneFreq)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { isAdminAuthenticated, isTuneAuthenticated } = request.session || {};
|
||||
const { wirelessConnection } = serverConfig.xdrd;
|
||||
|
||||
if ((serverConfig.publicTuner || (isTuneAuthenticated && wirelessConnection)) &&
|
||||
(!serverConfig.lockToAdmin || isAdminAuthenticated)) {
|
||||
const output = serverConfig.xdrd.wirelessConnection ? client : serialport;
|
||||
output.write(`${command}\n`);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', (code, reason) => {
|
||||
currentUsers--;
|
||||
dataHandler.showOnlineUsers(currentUsers);
|
||||
|
||||
// Find the index of the user's data in storage.connectedUsers array
|
||||
const index = storage.connectedUsers.findIndex(user => user.ip === clientIp);
|
||||
if (index !== -1) {
|
||||
storage.connectedUsers.splice(index, 1); // Remove the user's data from storage.connectedUsers array
|
||||
}
|
||||
|
||||
if (currentUsers === 0 && serverConfig.enableDefaultFreq === true && serverConfig.autoShutdown !== true && serverConfig.xdrd.wirelessConnection === true) {
|
||||
setTimeout(function() {
|
||||
if(currentUsers === 0) {
|
||||
client.write('T' + Math.round(serverConfig.defaultFreq * 1000) +'\n');
|
||||
dataHandler.resetToDefault(dataHandler.dataToSend);
|
||||
dataHandler.dataToSend.freq = Number(serverConfig.defaultFreq).toFixed(3);
|
||||
}
|
||||
}, 10000)
|
||||
}
|
||||
|
||||
if (currentUsers === 0 && serverConfig.autoShutdown === true && serverConfig.xdrd.wirelessConnection === true) {
|
||||
client.write('X\n');
|
||||
}
|
||||
|
||||
logInfo(`Web client \x1b[31mdisconnected\x1b[0m (${clientIp}) \x1b[90m[${currentUsers}]`);
|
||||
});
|
||||
|
||||
ws.on('error', console.error);
|
||||
});
|
||||
|
||||
// CHAT WEBSOCKET BLOCK
|
||||
chatWss.on('connection', (ws, request) => {
|
||||
const clientIp = request.headers['x-forwarded-for'] || request.connection.remoteAddress;
|
||||
|
||||
// Send chat history to the newly connected client
|
||||
storage.chatHistory.forEach(function(message) {
|
||||
message.history = true; // Adding the history parameter
|
||||
ws.send(JSON.stringify(message));
|
||||
});
|
||||
|
||||
const ipMessage = {
|
||||
type: 'clientIp',
|
||||
ip: clientIp,
|
||||
admin: request.session.isAdminAuthenticated
|
||||
};
|
||||
ws.send(JSON.stringify(ipMessage));
|
||||
|
||||
ws.on('message', function incoming(message) {
|
||||
const messageData = JSON.parse(message);
|
||||
messageData.ip = clientIp; // Adding IP address to the message object
|
||||
const currentTime = new Date();
|
||||
|
||||
const hours = String(currentTime.getHours()).padStart(2, '0');
|
||||
const minutes = String(currentTime.getMinutes()).padStart(2, '0');
|
||||
messageData.time = `${hours}:${minutes}`; // Adding current time to the message object in hours:minutes format
|
||||
|
||||
if (serverConfig.webserver.banlist?.includes(clientIp)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if(request.session.isAdminAuthenticated === true) {
|
||||
messageData.admin = true;
|
||||
}
|
||||
|
||||
if (messageData.message.length > 255) {
|
||||
messageData.message = messageData.message.substring(0, 255);
|
||||
}
|
||||
|
||||
storage.chatHistory.push(messageData);
|
||||
if (storage.chatHistory.length > 50) {
|
||||
storage.chatHistory.shift();
|
||||
}
|
||||
|
||||
const modifiedMessage = JSON.stringify(messageData);
|
||||
|
||||
chatWss.clients.forEach(function each(client) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(modifiedMessage);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ws.on('close', function close() {
|
||||
});
|
||||
});
|
||||
|
||||
// Websocket register for /text, /audio and /chat paths
|
||||
httpServer.on('upgrade', (request, socket, head) => {
|
||||
if (request.url === '/text') {
|
||||
sessionMiddleware(request, {}, () => {
|
||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
wss.emit('connection', ws, request);
|
||||
});
|
||||
});
|
||||
} else if (request.url === '/audio') {
|
||||
proxy.ws(request, socket, head);
|
||||
} else if (request.url === '/chat') {
|
||||
sessionMiddleware(request, {}, () => {
|
||||
chatWss.handleUpgrade(request, socket, head, (ws) => {
|
||||
chatWss.emit('connection', ws, request);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
socket.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
app.use(express.static(path.join(__dirname, '../web'))); // Serve the entire web folder to the user
|
||||
|
||||
httpServer.listen(serverConfig.webserver.webserverPort, serverConfig.webserver.webserverIp, () => {
|
||||
let currentAddress = serverConfig.webserver.webserverIp;
|
||||
currentAddress == '0.0.0.0' ? currentAddress = 'localhost' : currentAddress = serverConfig.webserver.webserverIp;
|
||||
logInfo(`Web server is running at \x1b[34mhttp://${currentAddress}:${serverConfig.webserver.webserverPort}\x1b[0m.`);
|
||||
});
|
||||
|
||||
fmdxList.update();
|
||||
BIN
server/libraries/librdsparser.dll
Normal file
BIN
server/libraries/librdsparser.dll
Normal file
Binary file not shown.
BIN
server/libraries/librdsparser_arm.so
Normal file
BIN
server/libraries/librdsparser_arm.so
Normal file
Binary file not shown.
BIN
server/libraries/librdsparser_arm64.so
Normal file
BIN
server/libraries/librdsparser_arm64.so
Normal file
Binary file not shown.
BIN
server/libraries/librdsparser_x64.so
Normal file
BIN
server/libraries/librdsparser_x64.so
Normal file
Binary file not shown.
99
server/server_config.js
Normal file
99
server/server_config.js
Normal file
@@ -0,0 +1,99 @@
|
||||
/* Libraries / Imports */
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { logDebug, logError, logInfo, logWarn } = require('./console');
|
||||
|
||||
let configName = 'config';
|
||||
|
||||
const index = process.argv.indexOf('--config');
|
||||
if (index !== -1 && index + 1 < process.argv.length) {
|
||||
configName = process.argv[index + 1];
|
||||
logInfo('Loading with a custom config file:', configName + '.json')
|
||||
}
|
||||
|
||||
const configPath = path.join(__dirname, '../' + configName + '.json');
|
||||
|
||||
let serverConfig = {
|
||||
webserver: {
|
||||
webserverIp: "0.0.0.0",
|
||||
webserverPort: 8080,
|
||||
banlist: [],
|
||||
chatEnabled: true
|
||||
},
|
||||
xdrd: {
|
||||
wirelessConnection: "",
|
||||
comPort: "",
|
||||
xdrdIp: "127.0.0.1",
|
||||
xdrdPort: 7373,
|
||||
xdrdPassword: ""
|
||||
},
|
||||
audio: {
|
||||
audioDevice: "Microphone (High Definition Audio Device)",
|
||||
audioChannels: 2,
|
||||
audioBitrate: "128k"
|
||||
},
|
||||
identification: {
|
||||
token: null,
|
||||
tunerName: "",
|
||||
tunerDesc: "",
|
||||
lat: "0",
|
||||
lon: "0",
|
||||
broadcastTuner: false,
|
||||
proxyIp: "",
|
||||
},
|
||||
password: {
|
||||
tunePass: "",
|
||||
adminPass: ""
|
||||
},
|
||||
device: 'tef',
|
||||
defaultFreq: 87.5,
|
||||
publicTuner: true,
|
||||
lockToAdmin: false,
|
||||
autoShutdown: false,
|
||||
enableDefaultFreq: false
|
||||
};
|
||||
|
||||
function deepMerge(target, source)
|
||||
{
|
||||
Object.keys(source).forEach(function(key) {
|
||||
if (typeof target[key] === 'object' && target[key] !== null) {
|
||||
deepMerge(target[key], source[key]);
|
||||
} else {
|
||||
target[key] = source[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function configUpdate(newConfig) {
|
||||
if (newConfig.webserver && newConfig.webserver.banlist !== undefined) {
|
||||
// If new banlist is provided, replace the existing one
|
||||
serverConfig.webserver.banlist = newConfig.webserver.banlist;
|
||||
delete newConfig.webserver.banlist; // Remove banlist from newConfig to avoid merging
|
||||
}
|
||||
|
||||
deepMerge(serverConfig, newConfig);
|
||||
}
|
||||
|
||||
|
||||
function configSave() {
|
||||
fs.writeFile(configPath, JSON.stringify(serverConfig, null, 2), (err) => {
|
||||
if (err) {
|
||||
logError(err);
|
||||
} else {
|
||||
logInfo('Server config saved successfully.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function configExists() {
|
||||
return fs.existsSync(configPath);
|
||||
}
|
||||
|
||||
if (fs.existsSync(configPath)) {
|
||||
const configFileContents = fs.readFileSync(configPath, 'utf8');
|
||||
serverConfig = JSON.parse(configFileContents);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
configName, serverConfig, configUpdate, configSave, configExists, configPath
|
||||
};
|
||||
4
server/storage.js
Normal file
4
server/storage.js
Normal file
@@ -0,0 +1,4 @@
|
||||
let connectedUsers = [];
|
||||
let chatHistory = [];
|
||||
|
||||
module.exports = { connectedUsers, chatHistory };
|
||||
437
server/stream/3las.server.js
Normal file
437
server/stream/3las.server.js
Normal file
@@ -0,0 +1,437 @@
|
||||
"use strict";
|
||||
var fs = require('fs');
|
||||
|
||||
let serverConfig = {
|
||||
audio: {
|
||||
audioBitrate: "128k"
|
||||
},
|
||||
};
|
||||
|
||||
if(fs.existsSync('./config.json')) {
|
||||
const configFileContents = fs.readFileSync('./config.json', 'utf8');
|
||||
serverConfig = JSON.parse(configFileContents);
|
||||
}
|
||||
|
||||
/*
|
||||
Stdin streamer is part of 3LAS (Low Latency Live Audio Streaming)
|
||||
https://github.com/JoJoBond/3LAS
|
||||
*/
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
||||
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
||||
return new (P || (P = Promise))(function (resolve, reject) {
|
||||
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
||||
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||
});
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const fs_1 = require("fs");
|
||||
const child_process_1 = require("child_process");
|
||||
const ws = __importStar(require("ws"));
|
||||
const wrtc = require('wrtc');
|
||||
const Settings = JSON.parse((0, fs_1.readFileSync)('stream/settings.json', 'utf-8'));
|
||||
const FFmpeg_command = (() => {
|
||||
if (process.platform === 'win32')
|
||||
return Settings.FallbackFFmpegPath;
|
||||
else if (process.platform === 'linux')
|
||||
return "ffmpeg";
|
||||
})();
|
||||
class RtcProvider {
|
||||
constructor() {
|
||||
this.RtcDistributePeer = new wrtc.RTCPeerConnection(Settings.RtcConfig);
|
||||
this.RtcDistributePeer.addTransceiver('audio');
|
||||
this.RtcDistributePeer.ontrack = this.OnTrack.bind(this);
|
||||
this.RtcDistributePeer.onicecandidate = this.OnIceCandidate_Distribute.bind(this);
|
||||
this.RtcSourcePeer = new wrtc.RTCPeerConnection(Settings.RtcConfig);
|
||||
this.RtcSourceMediaSource = new wrtc.nonstandard.RTCAudioSource();
|
||||
this.RtcSourceTrack = this.RtcSourceMediaSource.createTrack();
|
||||
this.RtcSourcePeer.addTrack(this.RtcSourceTrack);
|
||||
this.RtcSourcePeer.onicecandidate = this.OnIceCandidate_Source.bind(this);
|
||||
this.Init();
|
||||
}
|
||||
Init() {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
let offer = yield this.RtcSourcePeer.createOffer();
|
||||
yield this.RtcSourcePeer.setLocalDescription(new wrtc.RTCSessionDescription(offer));
|
||||
yield this.RtcDistributePeer.setRemoteDescription(offer);
|
||||
let answer = yield this.RtcDistributePeer.createAnswer();
|
||||
yield this.RtcDistributePeer.setLocalDescription(new wrtc.RTCSessionDescription(answer));
|
||||
yield this.RtcSourcePeer.setRemoteDescription(new wrtc.RTCSessionDescription(answer));
|
||||
});
|
||||
}
|
||||
OnTrack(event) {
|
||||
this.RtcDistributeTrack = event.track;
|
||||
}
|
||||
OnIceCandidate_Distribute(e) {
|
||||
if (!e.candidate)
|
||||
return;
|
||||
(() => __awaiter(this, void 0, void 0, function* () { return yield this.RtcSourcePeer.addIceCandidate(e.candidate); }))();
|
||||
}
|
||||
OnIceCandidate_Source(e) {
|
||||
if (!e.candidate)
|
||||
return;
|
||||
(() => __awaiter(this, void 0, void 0, function* () { return yield this.RtcDistributePeer.addIceCandidate(e.candidate); }))();
|
||||
}
|
||||
InsertMediaData(data) {
|
||||
if (!this.RtcSourceMediaSource)
|
||||
return;
|
||||
this.RtcSourceMediaSource.onData(data);
|
||||
}
|
||||
GetTrack() {
|
||||
return this.RtcDistributeTrack;
|
||||
}
|
||||
}
|
||||
class StreamClient {
|
||||
constructor(server, socket) {
|
||||
this.Server = server;
|
||||
this.Socket = socket;
|
||||
this.BinaryOptions = {
|
||||
compress: false,
|
||||
binary: true
|
||||
};
|
||||
this.Socket.on('error', this.OnError.bind(this));
|
||||
this.Socket.on('message', this.OnMessage.bind(this));
|
||||
}
|
||||
OnMessage(message, isBinary) {
|
||||
try {
|
||||
let request = JSON.parse(message.toString());
|
||||
if (request.type == "answer") {
|
||||
(() => __awaiter(this, void 0, void 0, function* () { return yield this.RtcPeer.setRemoteDescription(new wrtc.RTCSessionDescription(request.data)); }))();
|
||||
}
|
||||
else if (request.type == "webrtc") {
|
||||
this.Server.SetWebRtc(this);
|
||||
}
|
||||
else if (request.type == "fallback") {
|
||||
this.Server.SetFallback(this, request.data);
|
||||
}
|
||||
else if (request.type == "stats") {
|
||||
if (Settings.AdminKey && request.data == Settings.AdminKey) {
|
||||
this.SendText(JSON.stringify({
|
||||
"type": "stats",
|
||||
"data": this.Server.GetStats(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.OnError(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (_a) {
|
||||
this.OnError(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
OnError(_err) {
|
||||
this.Server.DestroyClient(this);
|
||||
}
|
||||
Destroy() {
|
||||
try {
|
||||
this.Socket.close();
|
||||
}
|
||||
catch (ex) {
|
||||
}
|
||||
if (this.RtcSender && this.RtcPeer)
|
||||
this.RtcPeer.removeTrack(this.RtcSender);
|
||||
if (this.RtcSender)
|
||||
this.RtcSender = null;
|
||||
if (this.RtcTrack)
|
||||
this.RtcTrack = null;
|
||||
if (this.RtcPeer) {
|
||||
this.RtcPeer.close();
|
||||
delete this.RtcPeer;
|
||||
this.RtcPeer = null;
|
||||
}
|
||||
}
|
||||
SendBinary(buffer) {
|
||||
if (this.Socket.readyState != ws.OPEN) {
|
||||
this.OnError(null);
|
||||
return;
|
||||
}
|
||||
this.Socket.send(buffer, this.BinaryOptions);
|
||||
}
|
||||
SendText(text) {
|
||||
if (this.Socket.readyState != ws.OPEN) {
|
||||
this.OnError(null);
|
||||
return;
|
||||
}
|
||||
this.Socket.send(text);
|
||||
}
|
||||
StartRtc(track) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
this.RtcPeer = new wrtc.RTCPeerConnection(Settings.RtcConfig);
|
||||
this.RtcTrack = track;
|
||||
this.RtcSender = this.RtcPeer.addTrack(this.RtcTrack);
|
||||
this.RtcPeer.onconnectionstatechange = this.OnConnectionStateChange.bind(this);
|
||||
this.RtcPeer.onicecandidate = this.OnIceCandidate.bind(this);
|
||||
let offer = yield this.RtcPeer.createOffer();
|
||||
yield this.RtcPeer.setLocalDescription(new wrtc.RTCSessionDescription(offer));
|
||||
this.SendText(JSON.stringify({
|
||||
"type": "offer",
|
||||
"data": offer
|
||||
}));
|
||||
});
|
||||
}
|
||||
OnConnectionStateChange(e) {
|
||||
if (!this.RtcPeer)
|
||||
return;
|
||||
let state = this.RtcPeer.connectionState;
|
||||
if (state != "new" && state != "connecting" && state != "connected")
|
||||
this.OnError(null);
|
||||
}
|
||||
OnIceCandidate(e) {
|
||||
if (e.candidate) {
|
||||
this.SendText(JSON.stringify({
|
||||
"type": "candidate",
|
||||
"data": e.candidate
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
class StreamServer {
|
||||
constructor(port, channels, sampleRate) {
|
||||
this.Port = port;
|
||||
this.Channels = channels;
|
||||
this.SampleRate = sampleRate;
|
||||
this.RtcProvider = new RtcProvider();
|
||||
this.Clients = new Set();
|
||||
this.RtcClients = new Set();
|
||||
this.FallbackClients = {
|
||||
"wav": new Set(),
|
||||
"mp3": new Set()
|
||||
};
|
||||
this.FallbackProvider = {};
|
||||
if (Settings.FallbackUseMp3) {
|
||||
this.FallbackProvider["mp3"] = AFallbackProvider.Create(this, "mp3");
|
||||
}
|
||||
if (Settings.FallbackUseWav) {
|
||||
this.FallbackProvider["wav"] = AFallbackProvider.Create(this, "wav");
|
||||
}
|
||||
this.StdIn = process.stdin;
|
||||
this.SamplesCount = this.SampleRate / 100;
|
||||
this.Samples = new Int16Array(this.Channels * this.SamplesCount);
|
||||
this.SamplesPosition = 0;
|
||||
}
|
||||
Run() {
|
||||
this.Server = new ws.Server({
|
||||
"host": ["127.0.0.1", "::1"],
|
||||
"port": this.Port,
|
||||
"clientTracking": true,
|
||||
"perMessageDeflate": false
|
||||
});
|
||||
this.Server.on('connection', this.OnServerConnection.bind(this));
|
||||
this.StdIn.on('data', this.OnStdInData.bind(this));
|
||||
this.StdIn.resume();
|
||||
}
|
||||
BroadcastBinary(format, buffer) {
|
||||
this.FallbackClients[format].forEach((function each(client) {
|
||||
client.SendBinary(buffer);
|
||||
}).bind(this));
|
||||
}
|
||||
OnStdInData(buffer) {
|
||||
for (let i = 0; i < buffer.length; i += 2) {
|
||||
this.Samples[this.SamplesPosition] = buffer.readInt16LE(i);
|
||||
this.SamplesPosition++;
|
||||
if (this.SamplesPosition >= this.Samples.length) {
|
||||
let data = {
|
||||
"samples": this.Samples,
|
||||
"sampleRate": this.SampleRate,
|
||||
"bitsPerSample": 16,
|
||||
"channelCount": this.Channels,
|
||||
"numberOfFrames": this.SamplesCount,
|
||||
};
|
||||
this.RtcProvider.InsertMediaData(data);
|
||||
this.Samples = new Int16Array(this.Channels * this.SamplesCount);
|
||||
this.SamplesPosition = 0;
|
||||
}
|
||||
}
|
||||
for (let format in this.FallbackProvider) {
|
||||
this.FallbackProvider[format].InsertData(buffer);
|
||||
}
|
||||
}
|
||||
OnServerConnection(socket, _request) {
|
||||
this.Clients.add(new StreamClient(this, socket));
|
||||
}
|
||||
SetFallback(client, format) {
|
||||
if (format != "mp3" && format != "wav") {
|
||||
this.DestroyClient(client);
|
||||
return;
|
||||
}
|
||||
this.FallbackClients[format].add(client);
|
||||
this.FallbackProvider[format].PrimeClient(client);
|
||||
}
|
||||
SetWebRtc(client) {
|
||||
this.RtcClients.add(client);
|
||||
client.StartRtc(this.RtcProvider.GetTrack());
|
||||
}
|
||||
DestroyClient(client) {
|
||||
this.FallbackClients["mp3"].delete(client);
|
||||
this.FallbackClients["wav"].delete(client);
|
||||
this.RtcClients.delete(client);
|
||||
this.Clients.delete(client);
|
||||
client.Destroy();
|
||||
}
|
||||
GetStats() {
|
||||
let rtc = this.RtcClients.size;
|
||||
let fallback = {
|
||||
"wav": (this.FallbackClients["wav"] ? this.FallbackClients["wav"].size : 0),
|
||||
"mp3": (this.FallbackClients["mp3"] ? this.FallbackClients["mp3"].size : 0),
|
||||
};
|
||||
let total = rtc;
|
||||
for (let format in fallback) {
|
||||
total += fallback[format];
|
||||
}
|
||||
return {
|
||||
"Total": total,
|
||||
"Rtc": rtc,
|
||||
"Fallback": fallback,
|
||||
};
|
||||
}
|
||||
static Create(options) {
|
||||
if (!options["-port"])
|
||||
throw new Error("Port undefined. Please use -port to define the port.");
|
||||
if (typeof options["-port"] !== "number" || options["-port"] !== Math.floor(options["-port"]) || options["-port"] < 1 || options["-port"] > 65535)
|
||||
throw new Error("Invalid port. Must be natural number between 1 and 65535.");
|
||||
if (!options["-channels"])
|
||||
throw new Error("Channels undefined. Please use -channels to define the number of channels.");
|
||||
if (typeof options["-channels"] !== "number" || options["-channels"] !== Math.floor(options["-channels"]) ||
|
||||
!(options["-channels"] == 1 || options["-channels"] == 2))
|
||||
throw new Error("Invalid channels. Must be either 1 or 2.");
|
||||
if (!options["-samplerate"])
|
||||
throw new Error("Sample rate undefined. Please use -samplerate to define the sample rate.");
|
||||
if (typeof options["-samplerate"] !== "number" || options["-samplerate"] !== Math.floor(options["-samplerate"]) || options["-samplerate"] < 1)
|
||||
throw new Error("Invalid sample rate. Must be natural number greater than 0.");
|
||||
return new StreamServer(options["-port"], options["-channels"], options["-samplerate"]);
|
||||
}
|
||||
}
|
||||
class AFallbackProvider {
|
||||
constructor(server) {
|
||||
this.Server = server;
|
||||
this.Process = (0, child_process_1.spawn)(FFmpeg_command, this.GetFFmpegArguments(), { shell: false, detached: false, stdio: ['pipe', 'pipe', 'ignore'] });
|
||||
this.Process.stdout.addListener('data', this.OnData.bind(this));
|
||||
}
|
||||
InsertData(buffer) {
|
||||
this.Process.stdin.write(buffer);
|
||||
}
|
||||
static Create(server, format) {
|
||||
if (format == "mp3") {
|
||||
return new FallbackProviderMp3(server);
|
||||
}
|
||||
else if (format == "wav") {
|
||||
return new FallbackProviderWav(server, 384);
|
||||
}
|
||||
}
|
||||
}
|
||||
class FallbackProviderMp3 extends AFallbackProvider {
|
||||
constructor(server) {
|
||||
super(server);
|
||||
}
|
||||
GetFFmpegArguments() {
|
||||
return [
|
||||
"-fflags", "+nobuffer+flush_packets", "-flags", "low_delay", "-rtbufsize", "32", "-probesize", "32",
|
||||
"-f", "s16le",
|
||||
"-ar", this.Server.SampleRate.toString(),
|
||||
"-ac", this.Server.Channels.toString(),
|
||||
"-i", "pipe:0",
|
||||
"-c:a", "libmp3lame",
|
||||
"-b:a", serverConfig.audio.audioBitrate,
|
||||
"-ac", this.Server.Channels.toString(),
|
||||
"-reservoir", "0",
|
||||
"-f", "mp3", "-write_xing", "0", "-id3v2_version", "0",
|
||||
"-fflags", "+nobuffer", "-flush_packets", "1",
|
||||
"pipe:1"
|
||||
];
|
||||
}
|
||||
OnData(chunk) {
|
||||
this.Server.BroadcastBinary("mp3", chunk);
|
||||
}
|
||||
PrimeClient(_) {
|
||||
}
|
||||
}
|
||||
class FallbackProviderWav extends AFallbackProvider {
|
||||
constructor(server, chunkSize) {
|
||||
super(server);
|
||||
if (typeof chunkSize !== "number" || chunkSize !== Math.floor(chunkSize) || chunkSize < 1)
|
||||
throw new Error("Invalid ChunkSize. Must be natural number greater than or equal to 1.");
|
||||
this.ChunkSize = chunkSize;
|
||||
this.ChunkBuffer = Buffer.alloc(0);
|
||||
this.HeaderBuffer = new Array();
|
||||
}
|
||||
GetFFmpegArguments() {
|
||||
return [
|
||||
"-fflags", "+nobuffer+flush_packets", "-flags", "low_delay", "-rtbufsize", "32", "-probesize", "32",
|
||||
"-f", "s16le",
|
||||
"-ar", this.Server.SampleRate.toString(),
|
||||
"-ac", this.Server.Channels.toString(),
|
||||
"-i", "pipe:0",
|
||||
"-c:a", "pcm_s16le",
|
||||
"-ar", Settings.FallbackWavSampleRate.toString(),
|
||||
"-ac", "1",
|
||||
"-f", "wav",
|
||||
"-flush_packets", "1", "-fflags", "+nobuffer", "-chunk_size", "384", "-packetsize", "384",
|
||||
"pipe:1"
|
||||
];
|
||||
}
|
||||
OnData(chunk) {
|
||||
// Check if riff for wav
|
||||
if (this.HeaderBuffer.length == 0) {
|
||||
// Check if chunk is a header page
|
||||
let isHeader = (chunk[0] == 0x52 && chunk[1] == 0x49 && chunk[2] == 0x46 && chunk[3] == 0x46);
|
||||
if (isHeader) {
|
||||
this.HeaderBuffer.push(chunk);
|
||||
this.Server.BroadcastBinary("wav", chunk);
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.ChunkBuffer = Buffer.concat(new Array(this.ChunkBuffer, chunk), this.ChunkBuffer.length + chunk.length);
|
||||
if (this.ChunkBuffer.length >= this.ChunkSize) {
|
||||
let chunkBuffer = this.ChunkBuffer;
|
||||
this.ChunkBuffer = Buffer.alloc(0);
|
||||
this.Server.BroadcastBinary("wav", chunkBuffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
PrimeClient(client) {
|
||||
let headerBuffer = this.HeaderBuffer;
|
||||
for (let i = 0; i < headerBuffer.length; i++) {
|
||||
client.SendBinary(headerBuffer[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
const OptionParser = {
|
||||
"-port": function (txt) { return parseInt(txt, 10); },
|
||||
"-channels": function (txt) { return parseInt(txt, 10); },
|
||||
"-samplerate": function (txt) { return parseInt(txt, 10); }
|
||||
};
|
||||
const Options = {};
|
||||
// Parse parameters
|
||||
for (let i = 2; i < (process.argv.length - 1); i += 2) {
|
||||
if (!OptionParser[process.argv[i]])
|
||||
throw new Error("Invalid argument: '" + process.argv[i] + "'.");
|
||||
if (Options[process.argv[i]])
|
||||
throw new Error("Redefined argument: '" + process.argv[i] + "'. Please use '" + process.argv[i] + "' only ONCE");
|
||||
Options[process.argv[i]] = OptionParser[process.argv[i]](process.argv[i + 1]);
|
||||
}
|
||||
const Server = StreamServer.Create(Options);
|
||||
Server.Run();
|
||||
//# sourceMappingURL=3las.server.js.map
|
||||
61
server/stream/index.js
Normal file
61
server/stream/index.js
Normal file
@@ -0,0 +1,61 @@
|
||||
const { spawn } = require('child_process');
|
||||
const consoleCmd = require('../console.js');
|
||||
const { configName, serverConfig, configUpdate, configSave } = require('../server_config');
|
||||
const { logDebug, logError, logInfo, logWarn } = require('../console');
|
||||
const commandExists = require('command-exists-promise');
|
||||
|
||||
// Check if FFmpeg is installed
|
||||
commandExists('ffmpeg')
|
||||
.then(exists => {
|
||||
if (exists) {
|
||||
logInfo("An existing installation of ffmpeg found, enabling audio stream.");
|
||||
enableAudioStream();
|
||||
} else {
|
||||
logError("No ffmpeg installation found. Audio stream won't be available.");
|
||||
}
|
||||
})
|
||||
|
||||
function enableAudioStream() {
|
||||
var ffmpegCommand;
|
||||
serverConfig.webserver.webserverPort = Number(serverConfig.webserver.webserverPort);
|
||||
// Specify the command and its arguments
|
||||
const command = 'ffmpeg';
|
||||
const flags = `-fflags +nobuffer+flush_packets -flags low_delay -rtbufsize 6192 -probesize 32`;
|
||||
const codec = `-acodec pcm_s16le -ar 48000 -ac ${serverConfig.audio.audioChannels}`;
|
||||
const output = `-f s16le -fflags +nobuffer+flush_packets -packetsize 384 -flush_packets 1 -bufsize 960`;
|
||||
// Combine all the settings for the ffmpeg command
|
||||
if (process.platform === 'win32') {
|
||||
// Windows
|
||||
ffmpegCommand = `${flags} -f dshow -audio_buffer_size 50 -i audio="${serverConfig.audio.audioDevice}" ${codec} ${output} pipe:1 | node stream/3las.server.js -port ${serverConfig.webserver.webserverPort + 10} -samplerate 48000 -channels ${serverConfig.audio.audioChannels}`;
|
||||
} else {
|
||||
// Linux
|
||||
ffmpegCommand = `${flags} -f alsa -i "${serverConfig.audio.softwareMode && serverConfig.audio.softwareMode == true ? 'plug' : ''}${serverConfig.audio.audioDevice}" ${codec} ${output} pipe:1 | node stream/3las.server.js -port ${serverConfig.webserver.webserverPort + 10} -samplerate 48000 -channels ${serverConfig.audio.audioChannels}`;
|
||||
}
|
||||
|
||||
consoleCmd.logInfo("Using audio device: " + serverConfig.audio.audioDevice);
|
||||
consoleCmd.logInfo(`Launching audio stream on internal port ${serverConfig.webserver.webserverPort + 10}.`);
|
||||
// Spawn the child process
|
||||
|
||||
if(serverConfig.audio.audioDevice.length > 2) {
|
||||
const childProcess = spawn(command, [ffmpegCommand], { shell: true });
|
||||
|
||||
// Handle the output of the child process (optional)
|
||||
childProcess.stdout.on('data', (data) => {
|
||||
consoleCmd.logFfmpeg(`stdout: ${data}`);
|
||||
});
|
||||
|
||||
childProcess.stderr.on('data', (data) => {
|
||||
consoleCmd.logFfmpeg(`stderr: ${data}`);
|
||||
});
|
||||
|
||||
// Handle the child process exit event
|
||||
childProcess.on('close', (code) => {
|
||||
consoleCmd.logFfmpeg(`Child process exited with code ${code}`);
|
||||
});
|
||||
|
||||
// You can also listen for the 'error' event in case the process fails to start
|
||||
childProcess.on('error', (err) => {
|
||||
consoleCmd.logFfmpeg(`Error starting child process: ${err}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
125
server/stream/parser.js
Normal file
125
server/stream/parser.js
Normal file
@@ -0,0 +1,125 @@
|
||||
'use strict';
|
||||
|
||||
const exec = require('child_process').exec;
|
||||
const fs = require('fs');
|
||||
const filePath = '/proc/asound/cards';
|
||||
const platform = process.platform;
|
||||
|
||||
function parseAudioDevice(options, callback) {
|
||||
let videoDevices = [];
|
||||
let audioDevices = [];
|
||||
let isVideo = true;
|
||||
|
||||
if (typeof options === 'function') {
|
||||
callback = options;
|
||||
options = null;
|
||||
}
|
||||
options = options || {};
|
||||
const ffmpegPath = options.ffmpegPath || 'ffmpeg';
|
||||
const callbackExists = typeof callback === 'function';
|
||||
|
||||
let inputDevice, prefix, audioSeparator, alternativeName, deviceParams;
|
||||
switch (platform) {
|
||||
case 'win32':
|
||||
inputDevice = 'dshow';
|
||||
prefix = /\[dshow/;
|
||||
audioSeparator = /DirectShow\saudio\sdevices/;
|
||||
alternativeName = /Alternative\sname\s*?\"(.*?)\"/;
|
||||
deviceParams = /\"(.*?)\"/;
|
||||
break;
|
||||
case 'darwin':
|
||||
inputDevice = 'avfoundation';
|
||||
prefix = /^\[AVFoundation/;
|
||||
audioSeparator = /AVFoundation\saudio\sdevices/;
|
||||
deviceParams = /^\[AVFoundation.*?\]\s\[(\d*?)\]\s(.*)$/;
|
||||
break;
|
||||
case 'linux':
|
||||
fs.readFile(filePath, 'utf8', (err, data) => {
|
||||
if (err) {
|
||||
console.error(`Error reading file: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract values between square brackets, trim whitespace, and prefix with 'hw:'
|
||||
const regex = /\[([^\]]+)\]/g;
|
||||
const matches = (data.match(regex) || []).map(match => 'hw:' + match.replace(/\s+/g, '').slice(1, -1));
|
||||
|
||||
if (matches.length > 0) {
|
||||
// Process the extracted values
|
||||
matches.forEach(function(match) {
|
||||
if (typeof match === 'string') {
|
||||
audioDevices.push({ name: match });
|
||||
} else if (typeof match === 'object' && match.name) {
|
||||
audioDevices.push(match);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
logWarn('No audio devices have been found.');
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
const searchPrefix = (line) => (line.search(prefix) > -1);
|
||||
const searchAudioSeparator = (line) => isVideo && (line.search(audioSeparator) > -1);
|
||||
const searchAlternativeName = (line) => (platform === 'win32') && (line.search(/Alternative\sname/) > -1);
|
||||
|
||||
|
||||
const execute = (fulfill, reject) => {
|
||||
exec(`${ffmpegPath} -f ${inputDevice} -list_devices true -i ""`, (err, stdout, stderr) => {
|
||||
stderr.split("\n")
|
||||
.filter(searchPrefix)
|
||||
.forEach((line) => {
|
||||
const deviceList = isVideo ? videoDevices : audioDevices;
|
||||
if (searchAudioSeparator(line)) {
|
||||
isVideo = false;
|
||||
return;
|
||||
}
|
||||
if (searchAlternativeName(line)) {
|
||||
const lastDevice = deviceList[deviceList.length - 1];
|
||||
lastDevice.alternativeName = line.match(alternativeName)[1];
|
||||
return;
|
||||
}
|
||||
const params = line.match(deviceParams);
|
||||
if (params) {
|
||||
let device;
|
||||
switch (platform) {
|
||||
case 'win32':
|
||||
device = {
|
||||
name: params[1]
|
||||
};
|
||||
break;
|
||||
case 'darwin':
|
||||
device = {
|
||||
id: parseInt(params[1]),
|
||||
name: params[2]
|
||||
};
|
||||
break;
|
||||
case 'linux':
|
||||
device = {
|
||||
name: params[1]
|
||||
};
|
||||
break;
|
||||
}
|
||||
deviceList.push(device);
|
||||
}
|
||||
});
|
||||
audioDevices = audioDevices.filter(device => device.name !== undefined);
|
||||
const result = { videoDevices, audioDevices };
|
||||
if (callbackExists) {
|
||||
callback(result);
|
||||
} else {
|
||||
fulfill(result);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (callbackExists) {
|
||||
execute();
|
||||
} else {
|
||||
return new Promise(execute);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { parseAudioDevice };
|
||||
9
server/stream/settings.json
Normal file
9
server/stream/settings.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"RtcConfig" : null,
|
||||
"FallbackFFmpegPath": "ffmpeg.exe",
|
||||
"FallbackUseMp3": true,
|
||||
"FallbackUseWav": false,
|
||||
"FallbackMp3Bitrate": 192,
|
||||
"FallbackWavSampleRate": 16000,
|
||||
"AdminKey": ""
|
||||
}
|
||||
121
server/tx_search.js
Normal file
121
server/tx_search.js
Normal file
@@ -0,0 +1,121 @@
|
||||
const fetch = require('node-fetch');
|
||||
const { serverConfig } = require('./server_config')
|
||||
|
||||
let cachedData = {};
|
||||
|
||||
let lastFetchTime = 0;
|
||||
const fetchInterval = 3000;
|
||||
|
||||
// Fetch data from maps
|
||||
function fetchTx(freq, piCode, rdsPs) {
|
||||
const now = Date.now();
|
||||
freq = parseFloat(freq);
|
||||
|
||||
if(isNaN(freq)) {
|
||||
return;
|
||||
}
|
||||
// Check if it's been at least 3 seconds since the last fetch and if the QTH is correct
|
||||
if (now - lastFetchTime < fetchInterval || serverConfig.identification.lat.length < 2 || freq < 87) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
lastFetchTime = now;
|
||||
|
||||
// Check if data for the given frequency is already cached
|
||||
if (cachedData[freq]) {
|
||||
return processData(cachedData[freq], piCode, rdsPs);
|
||||
}
|
||||
|
||||
const url = "https://maps.fmdx.pl/api?freq=" + freq;
|
||||
|
||||
return fetch(url)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Cache the fetched data for the specific frequency
|
||||
cachedData[freq] = data;
|
||||
return processData(data, piCode, rdsPs);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error fetching data:", error);
|
||||
});
|
||||
}
|
||||
|
||||
function processData(data, piCode, rdsPs) {
|
||||
let matchingStation = null;
|
||||
let matchingCity = null;
|
||||
let maxScore = -Infinity; // Initialize maxScore with a very low value
|
||||
let txAzimuth;
|
||||
let maxDistance;
|
||||
|
||||
for (const cityId in data.locations) {
|
||||
const city = data.locations[cityId];
|
||||
if (city.stations) {
|
||||
for (const station of city.stations) {
|
||||
if (station.pi === piCode.toUpperCase() && !station.extra && station.ps && station.ps.toLowerCase().includes(rdsPs.replace(/ /g, '_').replace(/^_*(.*?)_*$/, '$1').toLowerCase())) {
|
||||
const distance = haversine(serverConfig.identification.lat, serverConfig.identification.lon, city.lat, city.lon);
|
||||
const score = (10*Math.log10(station.erp*1000)) / distance.distanceKm; // Calculate score
|
||||
if (score > maxScore) {
|
||||
maxScore = score;
|
||||
txAzimuth = distance.azimuth;
|
||||
matchingStation = station;
|
||||
matchingCity = city;
|
||||
maxDistance = distance.distanceKm;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matchingStation) {
|
||||
return {
|
||||
station: matchingStation.station.replace("R.", "Radio "),
|
||||
pol: matchingStation.pol.toUpperCase(),
|
||||
erp: matchingStation.erp,
|
||||
city: matchingCity.name,
|
||||
itu: matchingCity.itu,
|
||||
distance: maxDistance.toFixed(0),
|
||||
azimuth: txAzimuth.toFixed(0),
|
||||
foundStation: true
|
||||
};
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function haversine(lat1, lon1, lat2, lon2) {
|
||||
const R = 6371; // Earth radius in kilometers
|
||||
const dLat = deg2rad(lat2 - lat1);
|
||||
const dLon = deg2rad(lon2 - lon1);
|
||||
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
|
||||
// Distance in kilometers
|
||||
const distance = R * c;
|
||||
|
||||
// Azimuth calculation
|
||||
const y = Math.sin(dLon) * Math.cos(deg2rad(lat2));
|
||||
const x = Math.cos(deg2rad(lat1)) * Math.sin(deg2rad(lat2)) -
|
||||
Math.sin(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) * Math.cos(dLon);
|
||||
const azimuth = Math.atan2(y, x);
|
||||
|
||||
// Convert azimuth from radians to degrees
|
||||
const azimuthDegrees = (azimuth * 180 / Math.PI + 360) % 360;
|
||||
|
||||
return {
|
||||
distanceKm: distance,
|
||||
azimuth: azimuthDegrees
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function deg2rad(deg) {
|
||||
return deg * (Math.PI / 180);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchTx
|
||||
};
|
||||
Reference in New Issue
Block a user