Source: extensions/sysinfo/sysinfo.js

/**
 *      StreamRoller Copyright 2023 "SilenusTA https://www.twitch.tv/olddepressedgamer"
 * 
 *      StreamRoller is an all in one streaming solution designed to give a single
 *      'second monitor' control page and allow easy integration for configuring
 *      content (ie. tweets linked to chat, overlays triggered by messages, hue lights
 *      controlled by donations etc)
 * 
 *      This program is free software: you can redistribute it and/or modify
 *      it under the terms of the GNU Affero General Public License as published
 *      by the Free Software Foundation, either version 3 of the License, or
 *      (at your option) any later version.
 * 
 *      This program is distributed in the hope that it will be useful,
 *      but WITHOUT ANY WARRANTY; without even the implied warranty of
 *      MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *      GNU Affero General Public License for more details.
 * 
 *      You should have received a copy of the GNU Affero General Public License
 *      along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */
/**
 * @extension Sysinfo
 * Provides information about the system(computer) that the extension is running on
 */
// ============================================================================
//                           IMPORTS/VARIABLES
// ============================================================================
import * as logger from "../../backend/data_center/modules/logger.js";
import sr_api from "../../backend/data_center/public/streamroller-message-api.cjs";
import si from 'systeminformation';
import * as fs from "fs";
import { dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));

const localConfig = {
    OUR_CHANNEL: "SYSINFO_CHANNEL",
    EXTENSION_NAME: "sysinfo",
    SYSTEM_LOGGING_TAG: "[EXTENSION]",
    DataCenterSocket: null,
    MAX_CONNECTION_ATTEMPTS: 5,
    poll_handle_cpu_data: null,
    poll_handle_cpu_temperature: null,
    poll_handle_gpu_data: null,
    CPUDataTriggerUpdated: false,
    GPUDataTriggerUpdated: false,
    CPUTemperaturesTriggerUpdated: false
};

const default_serverConfig = {
    __version__: "0.1",
    // data used in the settings modal
    extensionname: localConfig.EXTENSION_NAME,
    channel: localConfig.OUR_CHANNEL,
    sysinfo_enabled: "off",
    sysinfo_console_log_enabled: "off",
    sysinfo_cpu_data_enabled: "off",
    sysinfo_cpu_data_poll_interval: "60",//default poll time if enabled
    sysinfo_cpu_temperature_enabled: "off",
    sysinfo_cpu_temperature_poll_interval: "10",
    sysinfo_gpu_data_enabled: "off",
    sysinfo_gpu_data_poll_interval: "60",//default poll time if enabled


    sysinfo_restore_defaults: "off",
    // data for the app
    temps_enabled: "off"

};
let serverConfig = structuredClone(default_serverConfig)
const triggersandactions =
{
    extensionname: serverConfig.extensionname,
    description: "SysInfo Extension can provide information about the system that StreamRoller is running on",
    version: "0.2",
    channel: serverConfig.channel,
    triggers:
        [
            {
                name: "sysInfoCPUData",
                displaytitle: "CPU Data",
                description: "Data about the CPU",
                messagetype: "trigger_sysinfoCPUData",
                parameters: {
                    reference: "",// this will be auto or a value passed in by an action
                    //fields will be added on the first call (values change for specific hardware)
                }
            },
            {
                name: "sysInfoCPUTemperatures",
                displaytitle: "CPU Temperatures",
                description: "CPU Temperatures if available (you may need to run StreamRoller as admin to allow windows to read this)",
                messagetype: "trigger_sysinfoCPUTemperatures",
                parameters: {
                    reference: "",// this will be auto or a value passed in by an action
                    //fields will be added on the first call (values change for specific hardware)
                }
            },
            {
                name: "sysInfoGPUData",
                displaytitle: "GPU Data",
                description: "GPU Data",
                messagetype: "trigger_sysInfoGPUData",
                parameters: {
                    reference: "",// this will be auto or a value passed in by an action
                    //fields will be added on the first call (values change for specific hardware)/
                }
            }
        ],
    actions:
        [
            {
                name: "sysInfoGetCPUData",
                displaytitle: "Get CPU Data",
                description: "Sends out a trigger with the CPU Data",
                messagetype: "action_sysInfoGetCPUData",
                parameters: { reference: "" }
            },
            {
                name: "sysInfoGetCPUTemperatures",
                displaytitle: "Get CPU Temperatures",
                description: "Sends out a trigger with the CPU Temperatures",
                messagetype: "action_sysInfoGetCPUTemperatures",
                parameters: { reference: "" }
            },
            {
                name: "sysInfoGetGPUData",
                displaytitle: "Get GPU Data",
                description: "Sends out a trigger with the GPU Data",
                messagetype: "action_sysInfoGetGPUData",
                parameters: { reference: "" }
            }

        ],
}
// ============================================================================
//                           FUNCTION: initialise
// ============================================================================
/**
 * Starts the extension using the given data.
 * @param {object:Express} app 
 * @param {string} host 
 * @param {number} port 
 * @param {number} heartbeat 
 */
function initialise (app, host, port, heartbeat)
{
    // update our triggers with the systems fields
    getCPUData("initialise");
    getGPUData("initialise");
    getCPUTemperature("initialise");

    try
    {
        localConfig.DataCenterSocket = sr_api.setupConnection(onDataCenterMessage, onDataCenterConnect,
            onDataCenterDisconnect, host, port);
    } catch (err)
    {
        logger.err(localConfig.SYSTEM_LOGGING_TAG + localConfig.EXTENSION_NAME + ".initialise", "localConfig.DataCenterSocket connection failed:", err);
    }
}

// ============================================================================
//                           FUNCTION: onDataCenterDisconnect
// ============================================================================
/**
 * Disconnection message sent from the server
 * @param {String} reason 
 */
function onDataCenterDisconnect (reason)
{
    // do something here when disconnects happens if you want to handle them
    logger.log(localConfig.SYSTEM_LOGGING_TAG + localConfig.EXTENSION_NAME + ".onDataCenterDisconnect", reason);
}
// ============================================================================
//                           FUNCTION: onDataCenterConnect
// ============================================================================
/**
 * Connection message handler
 * @param {*} socket 
 */
function onDataCenterConnect (socket)
{
    logger.log(localConfig.SYSTEM_LOGGING_TAG + localConfig.EXTENSION_NAME + ".onDataCenterConnect", "Creating our channel");

    sr_api.sendMessage(localConfig.DataCenterSocket,
        sr_api.ServerPacket("RequestConfig", serverConfig.extensionname));

    sr_api.sendMessage(localConfig.DataCenterSocket,
        sr_api.ServerPacket("CreateChannel", localConfig.EXTENSION_NAME, localConfig.OUR_CHANNEL)
    );

}
// ============================================================================
//                           FUNCTION: onDataCenterMessage
// ============================================================================
/**
 * receives message from the socket
 * @param {data} server_packet 
 */
function onDataCenterMessage (server_packet)
{
    //logger.log(localConfig.SYSTEM_LOGGING_TAG + localConfig.EXTENSION_NAME + ".onDataCenterMessage", "message received ", server_packet);
    if (server_packet.type === "ConfigFile")
    {
        if (server_packet.data != "" && server_packet.to === serverConfig.extensionname)
        {
            if (server_packet.data.__version__ != default_serverConfig.__version__)
            {
                serverConfig = structuredClone(default_serverConfig);
                console.log("\x1b[31m" + serverConfig.extensionname + " ConfigFile Updated", "The config file has been Restored. Your settings may have changed" + "\x1b[0m");
            }
            else
                serverConfig = structuredClone(server_packet.data);

            SaveConfigToServer();
            UpdateMonitoring();
        }
    }
    else if (server_packet.type === "ExtensionMessage")
    {
        let extension_packet = server_packet.data;
        if (extension_packet.type === "RequestSettingsWidgetSmallCode")
            SendSettingsWidgetSmall(extension_packet.from);
        else if (extension_packet.type === "SettingsWidgetSmallData")
        {
            if (extension_packet.data.extensionname === serverConfig.extensionname)
            {
                if (extension_packet.data.sysinfo_restore_defaults == "on")
                {
                    serverConfig = structuredClone(default_serverConfig);
                    console.log("\x1b[31m" + serverConfig.extensionname + " ConfigFile Updated.", "The config file has been Restored. Your settings may have changed" + "\x1b[0m");
                }
                else
                {
                    serverConfig.sysinfo_enabled = "off";
                    serverConfig.sysinfo_console_log_enabled = "off";
                    serverConfig.sysinfo_cpu_data_enabled = "off";
                    serverConfig.sysinfo_cpu_temperature_enabled = "off";
                    serverConfig.sysinfo_gpu_data_enabled = "off";
                    for (const [key, value] of Object.entries(extension_packet.data))
                        serverConfig[key] = value;
                }
                SaveConfigToServer();
                SendSettingsWidgetSmall("");
                UpdateMonitoring();
            }
        }
        else if (extension_packet.type === "SendTriggerAndActions")
        {
            sendTriggersAndActions(server_packet.from)
        }
        else if (extension_packet.type === "action_sysInfoGetCPUTemperatures")
        {
            console.log("action_sysInfoGetCPUTemperatures called with", extension_packet.data)
            getCPUTemperature(extension_packet.data.reference)
        }
        else if (extension_packet.type === "action_sysInfoGetCPUData")
        {
            console.log("action_sysInfoGetCPUData called with", extension_packet.data)
            getCPUData(extension_packet.data.reference)
        }
        else if (extension_packet.type === "action_sysInfoGetGPUData")
        {
            console.log("action_sysInfoGetGPUData called with", extension_packet.data)
            getGPUData(extension_packet.data.reference)
        }
        else
            logger.log(localConfig.SYSTEM_LOGGING_TAG + localConfig.EXTENSION_NAME + ".onDataCenterMessage", "received unhandled ExtensionMessage ", server_packet);

    }
    else if (server_packet.type === "ChannelData")
    {
        let extension_packet = server_packet.data;
        if (extension_packet.type === "HeartBeat")
        {
            //Just ignore messages we know we don't want to handle
        }
        else
        {
            logger.log(localConfig.SYSTEM_LOGGING_TAG + localConfig.EXTENSION_NAME + ".onDataCenterMessage", "received message from unhandled channel ", server_packet.dest_channel);
        }
    }
    else if (server_packet.type === "InvalidMessage")
    {
        logger.err(localConfig.SYSTEM_LOGGING_TAG + localConfig.EXTENSION_NAME + ".onDataCenterMessage",
            "InvalidMessage ", server_packet.data.error, server_packet);
    }
    else if (server_packet.type === "LoggingLevel")
    {
        logger.setLoggingLevel(server_packet.data)
    }
    else if (server_packet.type === "ChannelJoined"
        || server_packet.type === "ChannelCreated"
        || server_packet.type === "ChannelLeft"
    )
    {
        // just a blank handler for items we are not using to avoid message from the catchall
    }
    // ------------------------------------------------ unknown message type received -----------------------------------------------
    else
        logger.warn(localConfig.SYSTEM_LOGGING_TAG + localConfig.EXTENSION_NAME +
            ".onDataCenterMessage", "Unhandled message type", server_packet.type);
}
// ===========================================================================
//                           FUNCTION: SendSettingsWidgetSmall
// ===========================================================================
/**
 * sends some modal code to be displayed on the admin page or somewhere else
 * this is done as part of the webpage request for modal message we get from 
 * extension. It is a way of getting some user feedback via submitted forms
 * from a page that supports the modal system
 * @param {String} toextension 
 */
function SendSettingsWidgetSmall (toextension)
{
    fs.readFile(__dirname + '/sysinfosettingswidgetsmall.html', function (err, filedata)
    {
        if (err)
            logger.err(localConfig.SYSTEM_LOGGING_TAG + localConfig.EXTENSION_NAME +
                ".SendSettingsWidgetSmall", "failed to load modal", err);
        else
        {
            let modalstring = filedata.toString();
            for (const [key, value] of Object.entries(serverConfig))
            {
                // checkboxes
                if (value === "on")
                    modalstring = modalstring.replace(key + "checked", "checked");
                // replace text strings
                else if (typeof (value) == "string")
                    modalstring = modalstring.replace(key + "text", value);
            }
            sr_api.sendMessage(localConfig.DataCenterSocket,
                sr_api.ServerPacket(
                    "ExtensionMessage",
                    localConfig.EXTENSION_NAME,
                    sr_api.ExtensionPacket(
                        "SettingsWidgetSmallCode",
                        localConfig.EXTENSION_NAME,
                        modalstring,
                        "",
                        toextension,
                        localConfig.OUR_CHANNEL
                    ),
                    "",
                    toextension
                ))
        }
    });
}
// ============================================================================
//                           FUNCTION: SaveConfigToServer
// ============================================================================
/**
 * Sends our config to the server to be saved for next time we run
 */
function SaveConfigToServer ()
{
    sr_api.sendMessage(localConfig.DataCenterSocket, sr_api.ServerPacket
        ("SaveConfig",
            localConfig.EXTENSION_NAME,
            serverConfig))
}
// ============================================================================
//                           FUNCTION: sendTriggersAndActions
// ============================================================================
/**
 * Sends the triggers and actions to the extension or broadcast if extension name is ""
 * @param {string} extension 
 */
function sendTriggersAndActions (extension)
{
    if (extension != "")
    {
        sr_api.sendMessage(localConfig.DataCenterSocket,
            sr_api.ServerPacket("ExtensionMessage",
                serverConfig.extensionname,
                sr_api.ExtensionPacket(
                    "TriggerAndActions",
                    serverConfig.extensionname,
                    triggersandactions,
                    "",
                    extension
                ),
                "",
                extension
            )
        )
    }
    else
    {
        sr_api.sendMessage(localConfig.DataCenterSocket,
            sr_api.ServerPacket("ExtensionMessage",
                serverConfig.extensionname,
                sr_api.ExtensionPacket(
                    "TriggerAndActions",
                    serverConfig.extensionname,
                    triggersandactions,
                    serverConfig.channel,
                    ""
                ),
                serverConfig.channel,
                ""
            )
        )
    }
}
// ============================================================================
//                           FUNCTION: SaveConfigToServer
// ============================================================================
/**
 * Sends our config to the server to be saved for next time we run
 */
function UpdateMonitoring ()
{
    if (serverConfig.sysinfo_enabled == "on")
    {
        if (serverConfig.sysinfo_cpu_data_enabled == "on")
        {
            // is there any reason to have a poll for this data?
            console.log("CPUData Polling TBD");
            //getCPUData()
        }
        else
            clearTimeout(localConfig.poll_handle_cpu_data);

        if (serverConfig.sysinfo_cpu_temperature_enabled == "on")
            getCPUTemperatureScheduler();
        else
            clearTimeout(localConfig.poll_handle_cpu_temperature);

        if (serverConfig.sysinfo_gpu_data_enabled == "on")
            getGPUDataScheduler();
        else
            clearTimeout(localConfig.poll_handle_gpu_data);
    }
}
// ============================================================================
//                           FUNCTION: getCPUData
// ============================================================================
/**
 * Requests CPU data and sends out a trigger message to the system
  * @param {string} [reference="poll"]
 */
function getCPUData (reference = "poll")
{
    si.cpu()
        .then(data => 
        {
            // get the data in a format we can use in the triggers (can't access multidimensional arrays)
            let new_data = flattenObject(data);
            // update our triggers if we haven't done so already
            if (!localConfig.CPUDataTriggerUpdated)
            {
                updateTrigger("trigger_sysinfoCPUData", new_data);
                localConfig.CPUDataTriggerUpdated = true;
            }

            // add the users reference or the default if not provided
            new_data.reference = reference;
            if (serverConfig.sysinfo_console_log_enabled == "on")
            {
                console.log("CPU Data:", new_data)
            }
            // send the trigger with the new data
            sendTrigger("trigger_sysinfoCPUData", new_data)
        })
        .catch(error => 
        {
            console.log("##### SysInfo getCPUData Error ######");
            console.error(error)
        });
}

// ============================================================================
//                           FUNCTION: getCPUTemperature
// ============================================================================
/**
 * Requests CPU Temperature data and sends out a trigger message to the system
 * @param {string} [reference="poll"]
 */
function getCPUTemperature (reference = "poll")
{
    // for an action use getCPUTemperature(reference); where reference is from the users action
    try
    {
        si.cpuTemperature()
            .then(data => 
            {
                // get the data in a format we can use in the triggers (can't access multidimensional arrays)
                let new_data = flattenObject(data);
                if (!localConfig.CPUTemperaturesTriggerUpdated)
                {
                    updateTrigger("trigger_sysinfoCPUTemperatures", new_data)
                    localConfig.CPUTemperaturesTriggerUpdated = true;
                }

                new_data.reference = reference;
                if (serverConfig.sysinfo_console_log_enabled == "on")
                {
                    console.log("CPU Temperature:", new_data)
                }
                // send the trigger with the new data
                sendTrigger("trigger_sysinfoCPUTemperatures", new_data)
            })
            .catch(error => 
            {
                logger.err(localConfig.SYSTEM_LOGGING_TAG + serverConfig.extensionname + ".getCPUTemperature", "failed:", error);
            });
    }
    catch (err)
    {
        logger.err(localConfig.SYSTEM_LOGGING_TAG + serverConfig.extensionname + ".getCPUTemperature", "callback failed:", err.message);
    }
    // restart timer if we are poll enabled
    if (serverConfig.sysinfo_cpu_temperature_enabled == "on")
        getCPUTemperatureScheduler();
}
// ============================================================================
//                           FUNCTION: getCPUTemperatureScheduler
// ============================================================================
/**
 * start the poll timer for the CPU temperature
 */
function getCPUTemperatureScheduler ()
{
    console.log("getCPUTemperatureScheduler")
    // if we have a poll timer value, setup the poll timer
    if (localConfig.poll_handle_cpu_temperature > 0)
        clearTimeout(localConfig.poll_handle_cpu_temperature);
    localConfig.poll_handle_cpu_temperature = setTimeout(getCPUTemperature, serverConfig.sysinfo_cpu_temperature_poll_interval * 1000)
}
// ============================================================================
//                           FUNCTION: getGPUData
// ============================================================================
/**
 * Requests GPU data and sends out a trigger message to the system
 * @param {string} [reference="poll"]
 */
function getGPUData (reference = "poll")
{
    si.graphics()
        .then(data => 
        {
            let new_data = flattenObject(data);
            if (!localConfig.GPUDataTriggerUpdated)
            {
                updateTrigger("trigger_sysInfoGPUData", new_data);
                localConfig.GPUDataTriggerUpdated = true;
            }
            new_data.reference = reference;
            if (serverConfig.sysinfo_console_log_enabled == "on")
            {
                console.log("GPU Data:", new_data)
            }
            sendTrigger("trigger_sysInfoGPUData", new_data)
        })
        .catch(error => 
        {
            console.log("##### SysInfo getGPUData Error ######");
            console.error(error)
        });
    // restart timer if we are poll enabled
    if (serverConfig.sysinfo_gpu_data_enabled == "on")
        getGPUDataScheduler();
}
// ============================================================================
//                           FUNCTION: getGPUDataScheduler
// ============================================================================
/**
 * start the poll timer for the GPU Data
 */
function getGPUDataScheduler ()
{
    console.log("getGPUDataScheduler")
    // if we have a poll timer value, setup the poll timer
    if (localConfig.poll_handle_gpu_data > 0)
        clearTimeout(localConfig.poll_handle_gpu_data);
    localConfig.poll_handle_gpu_data = setTimeout(getGPUData, serverConfig.sysinfo_gpu_data_poll_interval * 1000)
}
// ============================================================================
//                           FUNCTION: sendTrigger
// ============================================================================
/**
 * Requests CPU Temperature data and sends out a trigger message to the system
 * @param {string} trigger_name 
 * @param {object} trigger_data 
 */
function sendTrigger (trigger_name, trigger_data)
{
    let trigger = findTriggerByMessageType(trigger_name);
    if (trigger)
    {
        trigger.parameters = trigger_data;
        //console.log("#### sendTrigger sending ####")
        //console.log(JSON.stringify(trigger, null, 2))
        sr_api.sendMessage(localConfig.DataCenterSocket,
            sr_api.ServerPacket("ChannelData",
                serverConfig.extensionname,
                sr_api.ExtensionPacket(
                    trigger_name,
                    serverConfig.extensionname,
                    trigger,
                    serverConfig.channel,
                ),
                serverConfig.channel)
        )
    }
    else
    {
        logger.err(localConfig.SYSTEM_LOGGING_TAG + localConfig.EXTENSION_NAME + ".sendTrigger:", "Failed to retrieve ", trigger_name);
    }
}
// ============================================================================
//                           FUNCTION: findTriggerByMessageType
// ============================================================================
/**
 * Finds a trigger by name
 * @param {string} messagetype 
 * @returns trigger
 */
function findTriggerByMessageType (messagetype)
{
    for (let i = 0; i < triggersandactions.triggers.length; i++)
    {
        if (triggersandactions.triggers[i].messagetype.toLowerCase() == messagetype.toLowerCase())
            return triggersandactions.triggers[i];
    }
    logger.err(localConfig.SYSTEM_LOGGING_TAG + serverConfig.extensionname +
        ".findTriggerByMessageType", "failed to find action", messagetype);
}
// ============================================================================
//                           FUNCTION: flatten
// ============================================================================
/**
 * Flattens an array to move nested objects to the top level
 * @param {array} data 
 * @returns 1D array
 */
function flatten (data = [])
{
    var ret = [];
    console.log("########## flatten ##########")
    console.log(data)
    //check if we have an Array. if not it is probably an object
    if (Array.isArray(data))
    {
        console.log("array passed")
        for (var i = 0; i < data.length; i++)
        {
            if (Array.isArray(data[i]))
            {
                ret = ret.concat(flatten(data[i]));
            } else
            {
                ret.push(data[i]);
            }
        }
    }
    else if (typeof data === 'object' &&
        !Array.isArray(data) &&
        data !== null
    )
    {
        console.log("got an object");
    }
    else 
    {
        console.log("got a variable");
    }
    return ret;
}
// ============================================================================
//                           FUNCTION: flatten
// ============================================================================
/**
 * Flattens an object to move nested objects to the top level
 * @param {object} data 
 * @returns 1D object
 */
function flattenObject (ob)
{
    var toReturn = {};

    for (var i in ob)
    {
        if (!ob.hasOwnProperty(i)) continue;

        if ((typeof ob[i]) == 'object' && ob[i] !== null)
        {
            var flatObject = flattenObject(ob[i]);
            for (var x in flatObject)
            {
                if (!flatObject.hasOwnProperty(x)) continue;
                toReturn[i + '.' + x] = flatObject[x];
            }
        } else
        {
            toReturn[i] = ob[i];
        }
    }
    return toReturn;
}
// ============================================================================
//                           FUNCTION: updateTrigger
// ============================================================================
/**
 * Updates the trigger parameter fields to what we get from the system (as these will be different on each PC)
 * @param {string} trigger 
 * @param {data} ob 
 */
function updateTrigger (trigger, ob)
{
    for (var triggers_i = 0; triggers_i < triggersandactions.triggers.length; triggers_i++)
    {
        // find this trigger
        if (triggersandactions.triggers[triggers_i].messagetype == trigger)
        {
            // add the fields to the trigger
            for (const property in ob)
            {
                triggersandactions.triggers[triggers_i].parameters[property] = ""
            }
        }
    }

    // send out the update so extensions have the new trigger fields
    sendTriggersAndActions("");
    localConfig.CPUDataTriggerUpdated = true
}
// ============================================================================
//                                  EXPORTS
// ============================================================================
export { initialise, triggersandactions };