@janart19/node-red-fusebox
Version:
A collection of Fusebox-specific custom nodes for Node-RED
617 lines (497 loc) • 24.1 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 queries additional data about the controller and save it to its properties.
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;
// All variables below will be populated dynamically
// Queried from the controller via HTTP
node.services = {};
node.allStates = {};
node.channels = {};
node.status = {};
// Derived from the queried data
node.filteredServices = {};
node.writableServices = {};
// Read and write nodes that are associated with this controller
node.relatedNodes = {};
node.formattedTopics = [];
// Other miscellaneous variables below
// Store the last startup timestamp
let lastStartupTs = 0;
// Store draft configurations here (e.g. oneditsave)
const draftNodes = {};
node.debug(`Controller configuration node started with uniqueId: ${node.uniqueId}`);
// Endpoint to update draft configurations (i.e. not yet deployed)
// PS. Draft configuration(s) will be cleared on controller configuration node re-deploy
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 draft configuration
const controllerId = configData.id;
draftNodes[controllerId] = configData;
// Query, parse, and save the rest of the controller's variables
queryAllControllerData(draftNodes[controllerId]);
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 controllerId = req.query.id; // Get the node ID from the query parameters
// Get specific controller node or list all available controller nodes
if (controllerId) {
const configNode = draftNodes[controllerId] || RED.nodes.getNode(controllerId);
// Edge case: always keep related nodes up to date
filterRelatedNodes(configNode);
formatRelatedNodeTopics(configNode);
if (configNode) {
const {
id,
name,
uniqueId,
host,
udpPort,
httpPort,
services,
allStates,
channels,
status,
filteredServices,
writableServices,
relatedNodes,
formattedTopics
} = configNode;
res.json({
id,
name,
uniqueId,
host,
udpPort,
httpPort,
services,
allStates,
channels,
status,
filteredServices,
writableServices,
relatedNodes,
formattedTopics
});
} else {
res.status(404).json({ error: "Controller not found" });
}
} else {
let configNodes = [];
RED.nodes.eachNode(function (n) {
if (n.type === "fusebox-controller") {
// If the node is a draft, use the draft configuration
const configNode = draftNodes[n.id] || RED.nodes.getNode(n.id);
// Edge case: always keep related nodes up to date
filterRelatedNodes(configNode);
formatRelatedNodeTopics(configNode);
configNodes.push(configNode);
}
});
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,
allStates: node.allStates,
channels: node.channels,
status: node.status,
filteredServices: node.filteredServices,
writableServices: node.writableServices,
relatedNodes: node.relatedNodes,
formattedTopics: node.formattedTopics
}));
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);
node.debug(`Clearing controller draft configuration for id ${node.id}`);
delete draftNodes[node.id];
});
// 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);
filterUsedServices(draftNode);
filterOutputServices(draftNode);
filterRelatedNodes(draftNode);
formatRelatedNodeTopics(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, type) {
for (const input of inputs) {
if (input.mbi == output.mbi && input.mba == output.mba && input.regadd == output.regadd && (type === "discrete" ? input.bit == output.bit : true)) {
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, "analogue");
}
for (const obj of configuredNode.dochannels) {
addOutput(obj, configuredNode.dichannels, "discrete");
}
if (draftNode) {
draftNode.channels = result;
} else {
node.channels = result;
}
}
// Filter services to include only those keys which are mentioned in /allstates
// Same format as that of services
function filterUsedServices(draftNode = null) {
const configuredNode = draftNode || node;
// Format: { key: {values: [], status: int, timestamp: int} }
const allStates = configuredNode.allStates;
// Format: { key: { servicename: "", desc: [], ... } }
const services = configuredNode.services;
const allStateseKeys = Object.keys(allStates);
const servicesKeys = Object.keys(services);
const filteredKeys = allStateseKeys.filter((key) => servicesKeys.includes(key));
// Filter the services based on the keys
const result = filteredKeys.reduce((acc, key) => {
acc[key] = services[key];
return acc;
}, {});
if (draftNode) {
draftNode.filteredServices = result;
} else {
node.filteredServices = result;
}
}
// Only certain services and members are allowed to be written to.
// Retrieve the members and descriptions of the allowed services.
// Requirements:
// 1. all regtype "s" and "s!" members
// 2. all chantype "mb" members and (regtype "h" or "c") and (mbi,mba,regadd existing in output table)
// Structure: { key: [1, 2, ...] }
function filterOutputServices(draftNode = null) {
const configuredNode = draftNode || node;
// Format: { key: { 1: { regtype: "h", chantype: "mb", ... }, 2: {...} } }
const channels = configuredNode.channels;
const result = {};
// 1
for (const key in channels) {
const channel = channels[key];
for (index in channel) {
const member = channel[index];
const indexInt = parseInt(index);
if (["s", "s!"].includes(member.regtype)) {
result[key] = result[key] || [];
if (!result[key].includes(indexInt)) {
result[key].push(indexInt);
}
}
}
}
// 2
for (const key in channels) {
const channel = channels[key];
for (index in channel) {
const member = channel[index];
const indexInt = parseInt(index);
if (member.chantype === "mb" && ["h", "c"].includes(member.regtype) && member._output) {
result[key] = result[key] || [];
if (!result[key].includes(indexInt)) {
result[key].push(indexInt);
}
}
}
}
if (draftNode) {
draftNode.writableServices = result;
} else {
node.writableServices = result;
}
}
// Retrieve the configurations of related nodes (e.g. read and write nodes) that are associated with this controller.
// Structure: { id: { type: "fusebox-static-read", mappings: [{ keyNameSelect, keyNameManual, index, topic }] } }
function filterRelatedNodes(draftNode = null) {
const configuredNode = draftNode || node;
const result = {};
// Iterate over all nodes and find those that are related to this controller
RED.nodes.eachNode(function (n) {
if (n.type === "fusebox-read-static-data-streams" || n.type === "fusebox-write-static-data-streams") {
// Check if the node is related to this controller
if (n.controller === configuredNode.id) {
// Create a mapping for the node
const mappings = n.mappings.map((row) => {
const key = row.keyNameSelect || row.keyNameManual;
const member = row.index;
const topic = row.topic;
return { key, member, topic };
});
result[n.id] = {
type: n.type,
mappings: mappings
};
}
}
});
if (draftNode) {
draftNode.relatedNodes = result;
} else {
node.relatedNodes = result;
}
}
// Format the related nodes' topics for the controller node
// Structure: [ { key: "ABC", member: 1, topic: test/1, label: ... } ]
function formatRelatedNodeTopics(draftNode = null) {
const configuredNode = draftNode || node;
const result = [];
const filteredServices = configuredNode.filteredServices;
const channels = configuredNode.channels;
// Iterate over all related nodes
for (const nodeId in configuredNode.relatedNodes) {
const relatedNodeMappings = configuredNode?.relatedNodes?.[nodeId]?.mappings || [];
// Iterate over all mappings
for (const mapping of relatedNodeMappings) {
const key = mapping.key;
const member = mapping.member;
const topic = mapping.topic;
const keyDesc = filteredServices?.[key]?.servicename || null;
const memberDesc = channels?.[key]?.[member]?.desc || null;
let label = topic;
if (keyDesc && memberDesc) label += `: ${keyDesc} (${memberDesc})`;
// Check unique label
if (!result.some((obj) => obj.label === label)) {
result.push({ key, member, topic, label });
}
}
}
if (draftNode) {
draftNode.formattedTopics = result;
} else {
node.formattedTopics = result;
}
}
// Method to query additional data via HTTP
function httpQuery(options = {}, retries = 4) {
const retryTimes = [1000, 5000, 30000, 60000]; // Retry intervals in milliseconds
const retryTime = retryTimes[4 - retries];
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 === true || parsedData) {
// Successful response
resolve(parsedData);
} else {
// Unsuccessful response
if (retries > 0) {
node.warn(`Retrying... (${retries} attempts left)`);
setTimeout(() => {
resolve(httpQuery(options, retries - 1));
}, retryTime);
} else {
node.error(`Failed to query data: ${parsedData?.message}`);
resolve(false);
}
}
} catch (error) {
node.error(`Failed to parse HTTP response: ${error}`, { error });
// Retry if necessary
if (retries > 0) {
node.warn(`Retrying... (${retries} attempts left)`);
setTimeout(() => {
resolve(httpQuery(options, retries - 1));
}, retryTime);
} else {
node.error(`Failed to parse HTTP response: ${error}`, { error });
reject(error);
}
}
});
});
req.on("error", (error) => {
node.error(`HTTP request error: ${error}`, { error });
// Retry if necessary
if (retries > 0) {
node.warn(`Retrying... (${retries} attempts left)`);
setTimeout(() => {
resolve(httpQuery(options, retries - 1));
}, retryTime);
} else {
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"
};
try {
const parsedData = await httpQuery(options);
if (draftNode) {
draftNode.status = parsedData;
} else {
node.status = parsedData;
}
return parsedData;
} catch (error) {
console.error("queryStatus failed:", error);
throw error; // Propagate the error to be handled by the caller
}
}
async function queryServices(draftNode = null) {
const options = {
hostname: draftNode?.host || node.host,
port: draftNode?.httpPort || node.httpPort,
path: "/services.json",
method: "GET"
};
try {
const parsedData = await httpQuery(options);
const services = parsedData[0].services;
// Update node property
if (draftNode) {
draftNode.services = services;
} else {
node.services = services;
}
return services;
} catch (error) {
console.error("queryServices failed:", error);
}
}
async function queryAllStates(draftNode = null) {
const options = {
hostname: draftNode?.host || node.host,
port: draftNode?.httpPort || node.httpPort,
path: "/allstates",
method: "GET"
};
try {
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
};
// timestamp 5min check
allStates[key] = obj;
}
}
// Update node property
if (draftNode) {
draftNode.allStates = allStates;
} else {
node.allStates = allStates;
}
return allStates;
} catch (error) {
console.error("queryAllStates failed:", error);
}
}
async function querySqlChannels(tableName, draftNode = null) {
const options = {
hostname: draftNode?.host || node.host,
port: draftNode?.httpPort || node.httpPort,
path: `/sql2json?table=${tableName}`,
method: "GET"
};
try {
const parsedData = await httpQuery(options);
// Update node property
if (draftNode) {
draftNode[tableName] = parsedData;
} else {
node[tableName] = parsedData;
}
return parsedData;
} catch (error) {
console.error(`querySqlChannels (${tableName}) failed:`, error);
}
}
}
RED.nodes.registerType("fusebox-controller", ControllerNode);
};