UNPKG

@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
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" }, }, }); };