diff --git a/client/discordAudioBot/dabWrappers.mjs b/client/discordAudioBot/dabWrappers.mjs new file mode 100644 index 0000000..f5fdf94 --- /dev/null +++ b/client/discordAudioBot/dabWrappers.mjs @@ -0,0 +1,114 @@ +import { connectToChannel, checkIfConnectedToVC, initDiscordBotClient, getVoiceChannelFromID, getVoiceConnectionFromGuild } from './dab.mjs'; +import { openOP25, closeOP25 } from '../op25Handler/op25Handler.mjs'; + +let activeDiscordClient = undefined; +const activeDiscordVoiceConnections = {}; + +/** + * Join the requested server VC and listen to the requested system + * @param {object} joinData The object containing all the information to join the server + */ +export const joinDiscordVC = async (joinData) => { + console.log("Join requested: ", joinData) + const connection = await new Promise((res) => { + // Check if a client already exists + if (!activeDiscordClient) { + // Open a new client and join the requested channel with the requested ID + initDiscordBotClient(joinData.clientID, joinData.system, client => { + // Open an instance of OP25 + openOP25(joinData.system); + + getVoiceChannelFromID(client, joinData.channelID).then(vc => { + // Add the client object to the IO instance + activeDiscordClient = client; + const connection = connectToChannel(vc); + activeDiscordVoiceConnections[vc.guild.id] = connection; + console.log("Bot Connected to VC"); + res(connection); + }); + }); + } else { + // Join the requested channel with the requested ID + getVoiceChannelFromID(activeDiscordClient, joinData.channelID).then(vc => { + // Add the client object to the IO instance + const connection = connectToChannel(vc); + activeDiscordVoiceConnections[vc.guild.id] = connection; + console.log("Bot Connected to VC"); + res(connection); + }); + } + }); + + return connection; +} + +/** + * Leave VC on the requested server + * @param {string} guildId The guild ID to disconnect from VC + */ +export const leaveDiscordVC = async (guildId) => { + console.log("Leave requested"); + if (await checkIfConnectedToVC(guildId)) { + const connection = await getVoiceConnectionFromGuild(guildId); + if (connection) { + console.log("There is an open VC connection, closing it now"); + // Destroy the open VC connection + connection.destroy(); + + // Remove the connection from the object + delete activeDiscordVoiceConnections[guildId]; + + // Check if this was the last open VC connection + if (Object.keys(activeDiscordVoiceConnections).length == 0) { + console.log("No more open VC connections, closing the client-side discord client and OP25") + // Close the active client if there are no open VCs after this one + activeDiscordClient.destroy(); + activeDiscordClient = undefined; + + // Close OP25 + await closeOP25(); + } + } + } +} + + +/** + * Check if the bot is connected to a discord VC in the given server + * @param {string} guildId The guild id to check the connection status in + * @returns {boolean} If the node is connected to VC in the given guild + */ +export const checkIfDiscordVCConnected = async (guildId) => { + console.log("Requested status check"); + if (await checkIfConnectedToVC(guildId)) { + console.log("There is an open VC connection"); + return (true); + } else { + return (false); + } +} + + +/** + * Get the username of the bot in a given guild + * (there may be a server nickname given to the bot in a certain guild) + * @param {string} guildId The guild id to check the connection status in + * @returns {string} The username of the bot in the given guild's CV + */ +export const getDiscordUsername = async (guildId) => { + console.log("Requested username"); + if (activeDiscordClient) return (activeDiscordClient.user.username); + else return (undefined); +} + + +/** + * Check if there is an open discord client + * @returns {boolean} If the client is open or not + */ +export const checkIfClientIsOpen = async () => { + if (activeDiscordClient) { + return (true); + } + return (false); +} \ No newline at end of file diff --git a/client/modules/socketClient.mjs b/client/modules/socketClient.mjs index a91e955..84e3466 100644 --- a/client/modules/socketClient.mjs +++ b/client/modules/socketClient.mjs @@ -1,6 +1,5 @@ import { io } from "socket.io-client"; -import { connectToChannel, initDiscordBotClient, getVoiceChannelFromID, checkIfConnectedToVC, getVoiceConnectionFromGuild } from '../discordAudioBot/dab.mjs'; -import { logIntoServerWrapper, sendNodeUpdateWrapper, nodeCheckStatus } from "./socketClientWrappers.mjs"; +import { logIntoServerWrapper, nodeCheckStatus, nodeJoinServer, nodeLeaveServer, nodeGetUsername, nodeCheckDiscordClientStatus, nodeCheckCurrentSystem } from "./socketClientWrappers.mjs"; /** * Initialize the socket connection with the server, this will handle disconnects within itself @@ -12,53 +11,36 @@ export const initSocketConnection = async (localNodeConfig) => { const socket = io.connect(serverEndpoint); - const discordClients = {}; - + // Socket Events ('system' events persay) + // When the socket connects to the node server socket.on('connect', async () => { console.log('Connected to the server'); await logIntoServerWrapper(socket, localNodeConfig); }); - socket.on('node-join', async (joinData) => { - console.log("Join requested: ", joinData) - // TODO - Implement logic to control OP25 for the requested channel/system - - // Join the requested channel with the requested ID - initDiscordBotClient(joinData.clientID, joinData.system, client => { - getVoiceChannelFromID(client, joinData.channelID).then(vc => { - // Add the client object to the IO instance - discordClients[vc.guild.id] = client; - const connection = connectToChannel(vc); - console.log("Bot Connected to VC"); - }) - }); - }); - - socket.on('node-leave', async (guildId) => { - console.log("Leave requested"); - if (await checkIfConnectedToVC(guildId)) { - const connection = await getVoiceConnectionFromGuild(guildId); - if (connection) { - console.log("There is an open VC connection, closing it now"); - // Destroy the open VC connection - connection.destroy(); - - // Remove the client from the socket connection - delete discordClients[guildId]; - } - } - }); - - socket.on('node-get-discord-username', async (guildId, socketCallback) => { - console.log("Requested username"); - socketCallback(discordClients[guildId].user.username); - }); - - socket.on('node-check-connected-status', nodeCheckStatus); - + // When the socket disconnects from the node server socket.on('disconnect', () => { console.log('Disconnected from the server'); }); + // Node events/commands + // Requested to join a discord guild and listen to a system + socket.on('node-join', nodeJoinServer); + + // Requested to leave a discord guild + socket.on('node-leave', nodeLeaveServer); + + // Requested to get the discord username in a given guild + socket.on('node-get-discord-username', nodeGetUsername); + + // Requested to check if the node is connected to VC in a given guild + socket.on('node-check-connected-status', nodeCheckStatus); + + // Requested to check if the node has an open discord client + socket.on('node-check-discord-open-client', nodeCheckDiscordClientStatus); + + // Requested to get the current listening system + socket.on('node-check-current-system', nodeCheckCurrentSystem); + return socket; } \ No newline at end of file diff --git a/client/modules/socketClientWrappers.mjs b/client/modules/socketClientWrappers.mjs index 62dbd5d..c8a2d83 100644 --- a/client/modules/socketClientWrappers.mjs +++ b/client/modules/socketClientWrappers.mjs @@ -1,4 +1,5 @@ -import { checkIfConnectedToVC } from '../discordAudioBot/dab.mjs'; +import { checkIfDiscordVCConnected, joinDiscordVC, leaveDiscordVC, getDiscordUsername, checkIfClientIsOpen } from '../discordAudioBot/dabWrappers.mjs'; +import { getCurrentSystem } from '../op25Handler/op25Handler.mjs'; /** * Wrapper to log into the server @@ -27,19 +28,20 @@ export const sendNodeUpdateWrapper = async (socket, localNodeConfig) => { } -export const nodeJoinServer = async (joinData) => { - console.log("Join requested: ", joinData) - // TODO - Implement logic to control OP25 for the requested channel/system +/** + * Join the requested server VC and listen to the requested system + * @param {object} joinData The object containing all the information to join the server + */ +export const nodeJoinServer = async (joinData) => { + await joinDiscordVC(joinData); +} - // Join the requested channel with the requested ID - initDiscordBotClient(joinData.clientID, joinData.system, client => { - getVoiceChannelFromID(client, joinData.channelID).then(vc => { - // Add the client object to the IO instance - discordClients[vc.guild.id] = client; - const connection = connectToChannel(vc); - console.log("Bot Connected to VC"); - }) - }); +/** + * Leave VC on the requested server + * @param {string} guildId The guild ID to disconnect from VC + */ +export const nodeLeaveServer = async (guildId) => { + await leaveDiscordVC(guildId); } @@ -47,14 +49,37 @@ export const nodeJoinServer = async (joinData) => { * Check if the bot is connected to a discord VC in the given server * @param {string} guildId The guild id to check the connection status in * @param {any} socketCallback The callback function to return the result to - * @returns {any} + * @callback {boolean} If the node is connected to VC in the given guild */ export const nodeCheckStatus = async (guildId, socketCallback) => { - console.log("Requested status check"); - if (await checkIfConnectedToVC(guildId)) { - console.log("There is an open VC connection"); - socketCallback(true); - } else { - socketCallback(false); - } + socketCallback(await checkIfDiscordVCConnected(guildId)); +} + + +/** + * Get the username of the bot in a given guild + * (there may be a server nickname given to the bot in a certain guild) + * @param {string} guildId The guild id to check the connection status in + * @param {any} socketCallback The callback function to return the result to + * @callback {any} + */ +export const nodeGetUsername = async (guildId, socketCallback) => { + socketCallback(await getDiscordUsername(guildId)); +} + + +/** + * Check if the local node has an open discord client in any server + * @callback {boolean} If the node has an open discord client or not + */ +export const nodeCheckDiscordClientStatus = async (socketCallback) => { + socketCallback(await checkIfClientIsOpen()); +} + +/** + * Check what system the local node is currently listening to + * @callback {boolean} If the node has an open discord client or not + */ +export const nodeCheckCurrentSystem = async (socketCallback) => { + socketCallback(await getCurrentSystem()); } \ No newline at end of file diff --git a/client/modules/subprocessHandler.mjs b/client/modules/subprocessHandler.mjs new file mode 100644 index 0000000..43e4c70 --- /dev/null +++ b/client/modules/subprocessHandler.mjs @@ -0,0 +1,66 @@ +import { spawn } from "child_process"; + +/** + * Object to store references to spawned processes. + * @type {Object.} + */ +const runningProcesses = {}; + +/** + * Launches a new process if it's not already running. + * @param {string} processName - The name of the process to launch. + * @param {string[]} args - The arguments to pass to the process. + */ +export const launchProcess = (processName, args) => { + if (!runningProcesses[processName]) { + const childProcess = spawn(processName, args); + + // Store reference to the spawned process + runningProcesses[processName] = childProcess; + + childProcess.on('exit', (code, signal) => { + // Remove reference to the process when it exits + delete runningProcesses[processName]; + console.log(`${processName} process exited with code ${code} and signal ${signal}`); + }); + + console.log(`${processName} process started.`); + } else { + console.log(`${processName} process is already running.`); + } +} + +/** + * Checks the status of a process. + * @param {string} processName - The name of the process to check. + * @returns {string} A message indicating whether the process is running or not. + */ +export const checkProcessStatus = (processName) => { + const childProcess = runningProcesses[processName]; + if (childProcess) { + // Check if the process is running + if (!childProcess.killed) { + return `${processName} process is running.`; + } else { + return `${processName} process is not running.`; + } + } else { + return `${processName} process is not running.`; + } +} + +/** + * Kills a running process. + * @param {string} processName - The name of the process to kill. + */ +export const killProcess = (processName) => { + const childProcess = runningProcesses[processName]; + if (childProcess) { + childProcess.kill(); + console.log(`${processName} process killed.`); + } else { + console.log(`${processName} process is not running.`); + } +} + +export const getRunningProcesses = () => runningProcesses; \ No newline at end of file diff --git a/client/op25Handler/modules/op25ConfigGenerators.mjs b/client/op25Handler/modules/op25ConfigGenerators.mjs new file mode 100644 index 0000000..8ae0740 --- /dev/null +++ b/client/op25Handler/modules/op25ConfigGenerators.mjs @@ -0,0 +1,182 @@ +import { promises as fs } from 'fs'; + +class OP25ConfigObject { + constructor() { } + + async exportToFile(filename) { + try { + const jsonConfig = JSON.stringify(this, null, 2); + await fs.writeFile(filename, jsonConfig); + console.log(`Config exported to ${filename}`); + } catch (error) { + console.error(`Error exporting config to ${filename}: ${error}`); + } + } +} + +export class P25ConfigGenerator extends OP25ConfigObject { + constructor({ systemName, controlChannels, tagsFile, whitelistFile = undefined }) { + super(); + const controlChannelsString = controlChannels.join(','); + this.channels = new channelConfig({ + "channelName": systemName, + "systemName": systemName, + "enableAnalog": "off", + "demodType": "cqpsk", + "cqpskTracking": true, + "filterType": "rc" + }); + this.devices = new deviceConfig({ + "gain": "LNA:36" + }); + this.trunking = new trunkingConfig({ + "module": "tk_p25.py", + "systemName": systemName, + "controlChannelsString": controlChannelsString, + "tagsFile": tagsFile, + "whitelist": whitelistFile + }); + this.audio = new audioConfig({}); + this.terminal = new terminalConfig({}); + } +} + +export class NBFMConfigGenerator extends OP25ConfigObject { + constructor({ systemName, frequency, nbfmSquelch = -70 }) { + super(); + this.channels = new channelConfig({ + "channelName": systemName, + "enableAnalog": "on", + "nbfmSquelch": nbfmSquelch, + "frequency": frequency, + "demodType": "fsk4", + "filterType": "widepulse" + }); + this.devices = new deviceConfig({ + "gain": "LNA:32" + }); + this.audio = new audioConfig({}); + this.terminal = new terminalConfig({}); + } +} + +class channelConfig { + constructor({ + channelName = "Voice_ch1", + device = "sdr0", + systemName, + metaStreamName, + demodType, // cqpsk: P25; fsk4: everything else + cqpskTracking, + trackingThreshold = 120, + trackingFeedback = 0.75, + destination = "udp://127.0.0.1:23456", + excess_bw = 0.2, + filterType = "rc", // rc: P25; widepulse: analog + ifRate = 24000, + plot = "", + symbolRate = 4800, + enableAnalog, //[on, off, auto] + nbfmDeviation = 4000, // only needed if analog is enabled + nbfmSquelch = -50, // only needed if analog is enabled + frequency, // only needed if analog is enabled + blacklist, + whitelist, + cryptKeys + }) { + // Core Configs + this.name = channelName; + this.device = device; + this.demod_type = demodType; + this.destination = destination; + this.excess_bw = excess_bw; + this.filter_type = filterType; + this.if_rate = ifRate; + this.plot = plot; + this.symbol_rate = symbolRate; + this.enable_analog = enableAnalog; + + // P25 config + if (!enableAnalog || enableAnalog === "off" || systemName) this.trunking_sysname = systemName; + if (!enableAnalog || enableAnalog === "off" || systemName && metaStreamName) this.meta_stream_name = metaStreamName ?? ""; + if (!enableAnalog || enableAnalog === "off" || systemName) this.cqpsk_tracking = cqpskTracking; + if (!enableAnalog || enableAnalog === "off" || systemName) this.tracking_threshold = trackingThreshold; + if (!enableAnalog || enableAnalog === "off" || systemName) this.tracking_feedback = trackingFeedback; + if (!enableAnalog || enableAnalog === "off" || systemName && blacklist) this.blacklist = blacklist ?? ""; + if (!enableAnalog || enableAnalog === "off" || systemName && whitelist) this.whitelist = whitelist ?? ""; + if (!enableAnalog || enableAnalog === "off" || systemName && cryptKeys) this.crypt_keys = cryptKeys ?? ""; + + // Analog config + if (enableAnalog === "on" || enableAnalog === "auto") this.nbfm_deviation = nbfmDeviation; + if (enableAnalog === "on" || enableAnalog === "auto") this.nbfm_squelch = nbfmSquelch; + if (enableAnalog === "on" || enableAnalog === "auto") this.frequency = frequency; + } +} + +class deviceConfig { + constructor({ args = "rtl", gain = "LNA:32", gainMode = false, name = "sdr0", offset = 0, ppm = 0.0, sampleRate = 1920000, tunable = true }) { + this.args = args + this.gains = gain + this.gain_mode = gainMode + this.name = name + this.offset = offset + this.ppm = ppm + this.rate = sampleRate + this.usable_bw_pct = 0.85 + this.tunable = tunable + } +} + +class trunkingConfig { + /** + * + * @param {object} * + */ + constructor({ module, systemName, controlChannelsString, tagsFile = "", nac = "0x0", wacn = "0x0", cryptBehavior = 2, whitelist = "", blacklist = "" }) { + this.module = module; + this.chans = [{ + "nac": nac, + "wacn": wacn, + "sysname": systemName, + "control_channel_list": controlChannelsString, + "whitelist": whitelist, + "blacklist": blacklist, + "tgid_tags_file": tagsFile, + "tdma_cc": false, + "crypt_behavior": cryptBehavior + }]; + } +} + +class audioConfig { + constructor({ module = "sockaudio.py", port = 23456, deviceName = "default" }) { + this.module = module; + this.instances = [{ + "instance_name": "audio0", + "device_name": deviceName, + "udp_port": port, + "audio_gain": 2.0, + "number_channels": 1 + }]; + } +} + +class metadataStreamConfig { + constructor({ }) { + this.module = ""; + this.streams = []; + } +} + +class terminalConfig { + constructor({ module = "terminal.py", terminalType = "http:0.0.0.0:8080" }) { + this.module = module; + this.terminal_type = terminalType; + this.curses_plot_interval = 0.1; + this.http_plot_interval = 1.0; + this.http_plot_directory = "../www/images"; + this.tuning_step_large = 1200; + this.tuning_step_small = 100; + } +} + diff --git a/client/op25Handler/op25Handler.mjs b/client/op25Handler/op25Handler.mjs new file mode 100644 index 0000000..5064552 --- /dev/null +++ b/client/op25Handler/op25Handler.mjs @@ -0,0 +1,14 @@ +let currentSystem = undefined; + + +export const openOP25 = async (systemName) => { + currentSystem = systemName; +} + +export const closeOP25 = async () => { + currentSystem = undefined; +} + +export const getCurrentSystem = async () => { + return currentSystem; +} \ No newline at end of file diff --git a/server/discordBot/commands/join.mjs b/server/discordBot/commands/join.mjs index 2ad24e3..4ba34a5 100644 --- a/server/discordBot/commands/join.mjs +++ b/server/discordBot/commands/join.mjs @@ -1,5 +1,5 @@ import { SlashCommandBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; -import { requestNodeJoinSystem, checkIfNodeIsConnectedToVC } from '../../modules/socketServerWrappers.mjs'; +import { requestNodeJoinSystem, checkIfNodeIsConnectedToVC, checkIfNodeHasOpenDiscordClient, getNodeCurrentListeningSystem } from '../../modules/socketServerWrappers.mjs'; import { getSystemsByNuid, getAllSystems, getSystemByName } from '../../modules/mongoSystemsWrappers.mjs'; // Exporting data property @@ -63,11 +63,22 @@ export async function execute(nodeIo, interaction) { // Get all open socket nodes const openSockets = [...await nodeIo.allSockets()]; // TODO - Filter the returned nodes to only nodes that have the radio capability console.log("All open sockets: ", openSockets); - + var availableNodes = []; // Check each open socket to see if the node has the requested system await Promise.all(openSockets.map(async openSocket => { openSocket = await nodeIo.sockets.sockets.get(openSocket); + // Check if the node has an existing open client (meaning the radio is already being listened to) + const hasOpenClient = await checkIfNodeHasOpenDiscordClient(openSocket); + if (hasOpenClient) { + let currentSystem = await getNodeCurrentListeningSystem(openSocket); + if (currentSystem != system.name) { + console.log("Node is listening to a different system than requested", openSocket.node.name); + return; + } + } + + // Check if the bot has an open voice connection in the requested server already const connected = await checkIfNodeIsConnectedToVC(nodeIo, interaction.guild.id, openSocket.node.nuid); console.log("Connected:", connected); if (!connected) { @@ -76,6 +87,7 @@ export async function execute(nodeIo, interaction) { availableNodes.push(openSocket); } } + })); console.log("Availble nodes:", availableNodes.map(socket => socket.node.name)); diff --git a/server/modules/socketServerWrappers.mjs b/server/modules/socketServerWrappers.mjs index 37a5ded..41e0df2 100644 --- a/server/modules/socketServerWrappers.mjs +++ b/server/modules/socketServerWrappers.mjs @@ -182,6 +182,51 @@ export const getAllSocketsConnectedToVC = async (nodeIo, guildId) => { return socketsConnectedToVC; } + +/** + * Check if the given node has an open discord client + * @param {any} openSocket The open socket connection with the node to check + * @returns {boolean} If the given node has an open discord client or not + */ +export const checkIfNodeHasOpenDiscordClient = async (openSocket) => { + // Check the open socket to see if the node has an open discord client + let hasOpenDiscordClient = false; + await new Promise((res) => { + openSocket.emit('node-check-discord-open-client', (status) => { + if (status) { + console.log("Socket has an open discord client:", openSocket.node.name, status); + hasOpenDiscordClient = true; + } else { + console.log("Socket does NOT have an open discord client:", openSocket.node.name); + } + res(); + }) + }); + + return hasOpenDiscordClient; +} + +export const getNodeCurrentListeningSystem = async (openSocket) => { + const hasOpenClient = checkIfNodeHasOpenDiscordClient(openSocket); + if (!hasOpenClient) return undefined; + + // check what system the socket is listening to + let currentSystem = undefined; + await new Promise((res) => { + openSocket.emit('node-check-current-system', (system) => { + if (system) { + console.log("Socket is listening to system:", openSocket.node.name, system); + currentSystem = system; + } else { + console.log("Socket is not currently listening to a system:", openSocket.node.name); + } + res(); + }) + }); + + return currentSystem; +} + /** * Wrapper to check if the given NUID is connected to a VC * @param {any} nodeIo The nodeIo object that contains the IO server