@ifp-software/node-red-contrib-oee-ai-connector
Version:
Easily connect your production machines to oee.ai – The Industry 4.0 solution for OEE optimization.
324 lines (275 loc) • 9.9 kB
JavaScript
module.exports = function (RED) {
"use strict";
const mqtt = require("mqtt");
const https = require("https");
const HttpsProxyAgent = require("https-proxy-agent");
const http = require("http");
const path = require("path");
// These error codes are considered fatal and will cause the node to stop
// trying to connect if they occur during the first connection attempt.
// See the list of error codes at https://github.com/mqttjs/MQTT.js/blob/master/lib/client.js.
const fatalErrorCodes = [4, 5, 134, 135, 138, 142, 156, 159, 160];
// We try to build the unix socket path if we are in an ctrlx environment
const CTRLX_LICENSE_SOCKET_PATH = process.env.SNAP_DATA
? path.join(process.env.SNAP_DATA, "licensing-service", "licensing-service.sock")
: null;
const ENGINEERING_LICENSE = "SWL_XCR_ENGINEERING_4H";
const ACTUAL_LICENSE = "SWL-W-XCx-CONNxOEEAIxIFPx-Y1NN";
const hostnames = {
preview: "mqtt.preview.oee.ai",
production: "mqtt.oee.ai",
};
function clientIdFromCredentials(credentials) {
const sensorId = credentials.id;
const randomId = Math.random().toString(32).substring(2);
if (sensorId) {
const shortId = sensorId.split("-")[0];
return `oee-node-red_${shortId}_${randomId}`;
}
}
function topicFromCredentials(credentials) {
const sensorId = credentials.id;
if (sensorId) {
return `oee/sensor/${sensorId}/measurement`;
}
}
function brokerUrlFromConfig(config) {
const hostname = hostnames[config.server];
const [schema, port] = config.protocol.split("-");
const url = `${schema}://${hostname}:${port}`;
if (config.protocol.startsWith("ws")) {
return `${url}/mqtt`;
}
return url;
}
function buildMessage(from, to, count) {
return JSON.stringify({
data: [
{
attributes: {
from: from.toISOString(),
to: to.toISOString(),
count,
},
},
],
});
}
function setupNode(node, config) {
node.status({ fill: "red", shape: "ring", text: "disconnected" });
node.connected = false;
node.connectedOnce = false;
if (!node.credentials.id || !node.credentials.token) {
node.error("Incomplete credentials. Please provide sensor ID and token.");
Promise.resolve().then(() => {
node.status({ fill: "grey", shape: "ring", text: "incomplete credentials" });
});
return;
}
node.options = {
clientId: clientIdFromCredentials(node.credentials),
username: node.credentials.id,
password: node.credentials.token,
proxy: node.credentials.proxy,
proxyUsername: node.credentials.proxyUsername,
proxyPassword: node.credentials.proxyPassword,
keepalive: 60,
reschedulePings: false,
clean: true,
reconnectPeriod: 60_000,
rejectUnauthorized: false,
};
if (node.credentials.proxy) {
const proxyUrl = new URL(node.credentials.proxy);
if (node.credentials.proxyUsername) {
proxyUrl.username = node.credentials.proxyUsername;
}
if (node.credentials.proxyPassword) {
proxyUrl.password = node.credentials.proxyPassword;
}
node.options.wsOptions = {
agent: new HttpsProxyAgent(proxyUrl.toString()),
};
}
node.brokerUrl = brokerUrlFromConfig(config);
node.mqttClient = mqtt.connect(node.brokerUrl, node.options);
node.status({ fill: "yellow", shape: "ring", text: "connecting" });
node.connecting = true;
node.mqttClient.on("offline", () => {
node.warn("Lost connection to broker");
});
node.mqttClient.on("reconnect", () => {
node.log("Attempting to reconnect to broker");
});
node.mqttClient.on("packetsend", (packet) => {
node.trace(`Packet sent: ${JSON.stringify(packet)}`);
});
node.mqttClient.on("packetreceive", (packet) => {
node.trace(`Packet received: ${JSON.stringify(packet)}`);
});
node.mqttClient.on("connect", () => {
node.connecting = false;
node.status({ fill: "green", shape: "dot", text: "connected" });
node.connected = true;
node.connectedOnce = true;
node.log(`Connected to ${node.brokerUrl} as ${node.options.clientId}`);
});
node.mqttClient.on("error", (error) => {
if (!node.connectedOnce && fatalErrorCodes.includes(error.code)) {
node.error(`Fatal ${error} (code ${error.code})`);
node.mqttClient.end();
} else {
node.error(`${error} (code ${error.code})`);
}
node.status({ fill: "red", shape: "ring", text: "error" });
});
node.mqttClient.on("close", () => {
if (node.connected) {
node.connected = false;
node.status({ fill: "red", shape: "ring", text: "disconnected" });
node.log(`Disconnected from ${node.brokerUrl} as ${node.options.clientId}`);
}
});
node.on("input", (msg) => {
const to = msg.to ? new Date(msg.to) : new Date();
const from = msg.from ? new Date(msg.from) : new Date(to.valueOf() - 30000);
const count = parseFloat(msg.count) || 0;
const topic = topicFromCredentials(node.credentials);
const options = { qos: 2, retain: false };
const message = buildMessage(from, to, count);
node.mqttClient.publish(topic, message, options);
node.debug(`Message ${message} sent to topic ${topic}`);
});
node.on("close", (done) => {
node.mqttClient.end();
const license = node.context().ctrlXlicense;
if (license) {
const license = node.context().ctrlXlicense;
releaseLicense(license)
.then((node.context().ctrlXlicense = undefined))
.catch((err) => {
node.error(`ctrlX License release failed: ${err.message}`);
node.status({ fill: "red", shape: "ring", text: "ctrlX license release error" });
})
.finally(done());
} else {
done();
}
});
}
async function releaseLicense(licenseId) {
try {
await buildUnixSocketRequest("DELETE", `/license-manager/api/v1/license/${licenseId}`);
} catch (error) {
throw new Error(`Failed to release license: ${error.message}`);
}
}
async function acquireLicense() {
try {
const allLicenses = await buildUnixSocketRequest("GET", "/license-manager/api/v1/capabilities");
const engineeringLicense = allLicenses.find((license) => license.name === ENGINEERING_LICENSE);
const actualLicense = allLicenses.find((license) => license.name === ACTUAL_LICENSE);
let body = {
name: null,
version: "1.0",
};
if (engineeringLicense) {
body.name = ENGINEERING_LICENSE;
} else if (actualLicense) {
body.name = ACTUAL_LICENSE;
}
if (body.name) {
return await buildUnixSocketRequest("POST", "/license-manager/api/v1/license", body);
}
} catch (error) {
throw new Error(`Failed to acquire license: ${error.message}`);
}
}
async function buildUnixSocketRequest(method, endpoint, body = null) {
const options = {
socketPath: CTRLX_LICENSE_SOCKET_PATH,
path: endpoint,
method,
headers: {
Accept: "application/json",
},
};
if (body) {
options.headers["Content-Type"] = "application/json";
}
try {
const response = await new Promise((resolve, reject) => {
const req = http.request(options, (res) => {
let responseBody = "";
res.on("data", (chunk) => {
responseBody += chunk;
});
res.on("end", () => {
resolve({ statusCode: res.statusCode, body: responseBody });
});
});
req.on("error", (err) => reject(err));
if (body) {
// Add body if necessary
req.write(JSON.stringify(body));
}
req.end();
});
if (response.statusCode >= 200 && response.statusCode < 300) {
try {
return JSON.parse(response.body);
} catch (err) {
throw new Error(`Invalid JSON response: ${response.body}`);
}
} else {
throw new Error(`Request failed with status code ${response.statusCode}: ${response.body}`);
}
} catch (err) {
throw new Error(`Unix socket request error: ${err.message}`);
}
}
function OeeAiConnectorNode(config) {
RED.nodes.createNode(this, config);
const node = this;
// Migrations:
//
// `protocol` was previously:
// - `undefined` (until 2.3.0)
// - "wss" or "tcp" (until 2.9.0)
//
// Please make sure to keep this migration code equivalent to the one in the
// `.html` file of the node if you change it.
if (config.protocol === undefined || config.protocol === "tcp") {
if (config.server === "preview") {
config.protocol = "mqtt-1883";
} else if (config.server === "production") {
config.protocol = "mqtts-8883";
}
} else if (config.protocol === "wss") {
config.protocol = "wss-8084";
}
if (CTRLX_LICENSE_SOCKET_PATH) {
acquireLicense()
.then((license) => {
node.context().ctrlXlicense = license.id;
node.log(`Acquired ctrlX license with ID: ${license.id}`);
setupNode(node, config);
})
.catch((err) => {
node.error(`ctrlX license acquisition failed: ${err.message}`);
node.status({ fill: "red", shape: "ring", text: "ctrlX license error" });
});
} else {
setupNode(node, config);
}
}
RED.nodes.registerType("oee-ai-connector", OeeAiConnectorNode, {
credentials: {
id: { type: "text" },
token: { type: "password" },
proxy: { type: "text" },
proxyUsername: { type: "text" },
proxyPassword: { type: "password" },
},
});
};