@janart19/node-red-fusebox
Version:
A collection of Fusebox-specific custom nodes for Node-RED
328 lines (264 loc) • 12.6 kB
JavaScript
const http = require("http");
// This configuration node specifies the controller's network parameters, e.g. IP address and port number.
// The configuration is used by other nodes in the flow to query or select the controller.
// The node can also query additional data from the controller and save it to the global context.
module.exports = function (RED) {
function ControllerNode(config) {
RED.nodes.createNode(this, config);
const node = this;
// Retrieve configuration settings
node.name = config.name;
node.uniqueId = config.uniqueId;
node.host = config.host;
node.udpPort = config.udpPort;
node.httpPort = config.httpPort;
node.services = {}; // Will be populated dynamically
node.channels = {}; // Will be populated dynamically
node.status = {}; // Will be populated dynamically
let lastStartupTs = 0; // Store the last startup timestamp
const draftNodes = {}; // Store draft configurations here
// Initialize global context to get and set values
const globalAllStatesKey = `${node.uniqueId}_allstates`;
const globalContext = node.context().global;
// Endpoint to update draft configurations (i.e. not yet deployed)
RED.httpAdmin.post("/fusebox/controllerNodeConfig", RED.auth.needsPermission("controller.write"), function (req, res) {
const configData = req.body;
node.debug(`Updating draft configuration for controller (${configData.uniqueId})...`);
// Save configuration fields
const id = configData.id;
draftNodes[id] = configData;
// Also try to query and save the controller data
queryAllControllerData(draftNodes[id]);
res.status(200).send();
});
// Define HTTP endpoint to serve the controller node's configuration
RED.httpAdmin.get("/fusebox/controllerNodeConfig", RED.auth.needsPermission(["controller.read"]), function (req, res) {
const nodeId = req.query.id; // Get the node ID from the query parameters
// Get specific controller node or list all available controller nodes
if (nodeId) {
const configNode = draftNodes[nodeId] || RED.nodes.getNode(nodeId);
if (configNode) {
const { id, name, uniqueId, host, udpPort, httpPort, services, channels, status } = configNode;
res.json({ id, name, uniqueId, host, udpPort, httpPort, services, channels, status });
} else {
res.status(404).json({ error: "Controller not found" });
}
} else {
let configNodes = [];
RED.nodes.eachNode(function (n) {
if (n.type === "fusebox-controller") {
configNodes.push(n);
}
});
const controllers = configNodes.map((node) => ({
id: node.id,
name: node.name,
uniqueId: node.uniqueId,
host: node.host,
udpPort: node.udpPort,
httpPort: node.httpPort,
services: node.services,
channels: node.channels,
status: node.status,
}));
res.json({ controllers });
}
});
// Set an interval to check the controller status every 1 minute (60000 milliseconds)
const intervalId = setInterval(queryAllControllerData, 60000);
// Fetch additional data on initialization
queryAllControllerData();
// Clear the interval on node close or re-deploy
node.on("close", function () {
node.debug("Clearing controller data query interval...");
clearInterval(intervalId);
});
// Function to re-execute the code
function queryAllControllerData(draftNode = null) {
queryStatus(draftNode)
.then((status) => {
const newStartupTs = status.startup_ts;
// Check if the program has restarted, in which case we need to re-query the data
if (newStartupTs > lastStartupTs || draftNode) {
lastStartupTs = newStartupTs;
const uniqueId = draftNode?.uniqueId || node.uniqueId;
node.debug(`Querying data for controller (${uniqueId})...`);
// Query all data by chaining promises
Promise.all([
queryServices(draftNode),
queryAllStates(draftNode),
querySqlChannels("aicochannels", draftNode),
querySqlChannels("aochannels", draftNode),
querySqlChannels("dichannels", draftNode),
querySqlChannels("dochannels", draftNode),
])
.then(() => {
node.debug("All queries completed successfully!");
formatChannels(draftNode);
})
.catch((error) => {
node.error(`Error fetching one or more controller (${uniqueId}) endpoints: ${error}`, error);
});
}
})
.catch((error) => {
node.error(`Error fetching controller status: ${error}`, error);
});
}
// Create a comprehensive object of all channels
// Structure: { ABCW: { 1: {regtype: "h", ....} , 2: {...} } }
function formatChannels(draftNode = null) {
const result = {};
const configuredNode = draftNode || node;
function addInput(input, type) {
const key = input.val_reg;
const index = parseInt(input.member);
result[key] = result[key] || {};
result[key][index] = input;
// Additional properties
result[key][index]._type = type;
result[key][index]._output = false;
}
function addOutput(output, inputs) {
for (const input of inputs) {
if (input.mbi == output.mbi && input.mba == output.mba && input.regadd == output.regadd) {
const key = input.val_reg;
const index = parseInt(input.member);
result[key][index]._output = true;
break;
}
}
}
// Add AI and DI channels to the object
for (const obj of configuredNode.aicochannels) {
addInput(obj, "analogue");
}
for (const obj of configuredNode.dichannels) {
addInput(obj, "discrete");
}
// Modify the existing channels to include output properties
for (const obj of configuredNode.aochannels) {
addOutput(obj, configuredNode.aicochannels);
}
for (const obj of configuredNode.dochannels) {
addOutput(obj, configuredNode.dichannels);
}
if (draftNode) {
draftNode.channels = result;
} else {
node.channels = result;
}
}
// Method to query additional data via HTTP
function httpQuery(options = {}) {
return new Promise((resolve, reject) => {
node.debug(`Querying HTTP: ${JSON.stringify(options)}`);
const req = http.request(options, (res) => {
let data = "";
res.on("data", (chunk) => {
data += chunk;
});
res.on("end", () => {
try {
const parsedData = JSON.parse(data);
if (parsedData?.success === false) {
node.error(`Failed to query data: ${parsedData?.message}`);
reject(parsedData?.message);
return;
}
resolve(parsedData);
} catch (error) {
node.error(`Failed to parse HTTP response: ${error}`, { error });
reject(error);
}
});
});
req.on("error", (error) => {
node.error(`HTTP request error: ${error}`, { error });
reject(error);
});
req.end();
});
}
async function queryStatus(draftNode = null) {
const options = {
hostname: draftNode?.host || node.host,
port: draftNode?.httpPort || node.httpPort,
path: "/program-status?minimal=true",
method: "GET",
};
const parsedData = await httpQuery(options);
// Update node property
if (draftNode) {
draftNode.status = parsedData;
} else {
node.status = parsedData;
}
return parsedData;
}
async function queryServices(draftNode = null) {
const options = {
hostname: draftNode?.host || node.host,
port: draftNode?.httpPort || node.httpPort,
path: "/services.json",
method: "GET",
};
const parsedData = await httpQuery(options);
const services = parsedData[0].services;
// Update node property
if (draftNode) {
draftNode.services = services;
} else {
node.services = services;
}
return services;
}
async function queryAllStates(draftNode = null) {
const options = {
hostname: draftNode?.host || node.host,
port: draftNode?.httpPort || node.httpPort,
path: "/allstates",
method: "GET",
};
const parsedData = await httpQuery(options);
const localhost = Object.keys(parsedData)[0];
const allStates = {};
// Parse the data
for (const key in parsedData[localhost]) {
if (parsedData[localhost].hasOwnProperty(key)) {
const obj = {
values: parsedData[localhost][key].v ?? parsedData[localhost][key].values,
status: parsedData[localhost][key].s ?? parsedData[localhost][key].status,
timestamp: parsedData[localhost][key].t ?? parsedData[localhost][key].timestamp,
};
allStates[key] = obj;
}
}
// Update global context and node property
if (draftNode) {
draftNode.allStates = allStates;
} else {
globalContext.set(globalAllStatesKey, allStates);
node.allStates = allStates;
}
return allStates;
}
async function querySqlChannels(tableName, draftNode = null) {
const options = {
hostname: draftNode?.host || node.host,
port: draftNode?.httpPort || node.httpPort,
path: `/sql2json?table=${tableName}`,
method: "GET",
};
const parsedData = await httpQuery(options);
// Update node property
if (draftNode) {
draftNode[tableName] = parsedData;
} else {
node[tableName] = parsedData;
}
return parsedData;
}
}
RED.nodes.registerType("fusebox-controller", ControllerNode);
};