node-red-contrib-operx 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,53 @@
1
- # Temporary Holding Version
1
+ # node-red-contrib-operx
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Un nodo «OperX» para Node-RED: envía a OperX Connect los valores de tus máquinas
4
+ (lo que leas de Modbus, OPC UA, un PLC o un broker MQTT) y guarda en ese
5
+ computador lo que no pueda enviar, hasta que vuelva internet.
6
+
7
+ Necesita Node-RED 4 o más nuevo (Node.js 18 o más). No instala nada más.
8
+
9
+ ## Instalar
10
+
11
+ - En Node-RED: menú › Manage palette › Install, busca «operx» e instala
12
+ `node-red-contrib-operx`.
13
+ - O en una terminal, en la carpeta de Node-RED (normalmente `~/.node-red`):
14
+ `npm install node-red-contrib-operx`, y reinicia Node-RED.
15
+
16
+ ## Usar
17
+
18
+ 1. En OperX: Connect › Agregar dispositivo › «Node-RED u otro sistema». Copia
19
+ la clave que te muestra al final.
20
+ 2. En Node-RED: arrastra el nodo «OperX» (en la sección network), ábrelo y crea
21
+ su dispositivo con esa clave. La clave queda en el archivo de credenciales de
22
+ Node-RED, no en el flujo: exportar el flujo no la comparte.
23
+ 3. Únelo a lo que lea tus máquinas. Hay un ejemplo en menú › Import › Examples ›
24
+ node-red-contrib-operx.
25
+
26
+ ## Qué le llega
27
+
28
+ - Un valor (`80.5`, `true`, `on`) con `msg.topic` como nombre del canal, como lo
29
+ trae un nodo «mqtt in»: `planta/comp2/temp`.
30
+ - Un objeto con un nombre por canal: `{"temperatura": 80.5, "horas": 1200}`.
31
+ Con `msg.topic`, cada nombre va bajo el tema; sin él, los nombres son los
32
+ canales.
33
+ - El JSON de OperX: `{"ts": …, "values": {…}}` o
34
+ `{"readings": [{"channel", "value", "time"}]}`.
35
+
36
+ Una lista de números (como la de un nodo Modbus) no dice qué es cada uno:
37
+ ponles nombre en un nodo «function». Lo que no puede leer lo dice una vez en la
38
+ barra de depuración y debajo del nodo.
39
+
40
+ ## Cuánto envía
41
+
42
+ - De cada canal, el primer valor de cada minuto.
43
+ - Un dispositivo recibe hasta 100.000 valores al día: unos 69 canales a un valor
44
+ por minuto. Para más, otro dispositivo con su propio nodo.
45
+ - Lo que esperó durante un corte de internet cuenta el día en que sale: un corte
46
+ largo puede llenar el día siguiente.
47
+ - Guarda hasta 100.000 valores en la carpeta `operx` de Node-RED; si se llena,
48
+ se van los más viejos.
49
+ - No pasa por un proxy: necesita salida directa por el puerto 443.
50
+
51
+ ## Para desarrollar
52
+
53
+ `npm test` corre las pruebas (sin dependencias, con `node --test`).
@@ -0,0 +1,51 @@
1
+ [
2
+ {
3
+ "id": "opx_example_tab",
4
+ "type": "tab",
5
+ "label": "Enviar a OperX",
6
+ "info": "Cada minuto arma los valores de tu máquina y los envía a OperX. Abre «Dispositivo de OperX» y pega la clave del dispositivo; luego cambia los valores del nodo «Tus valores» por lo que leas de Modbus u OPC UA."
7
+ },
8
+ {
9
+ "id": "opx_example_device",
10
+ "type": "operx-device",
11
+ "name": "Dispositivo de OperX",
12
+ "address": "https://api.operx.pro"
13
+ },
14
+ {
15
+ "id": "opx_example_tick",
16
+ "type": "inject",
17
+ "z": "opx_example_tab",
18
+ "name": "Cada minuto",
19
+ "props": [{ "p": "payload" }],
20
+ "repeat": "60",
21
+ "once": true,
22
+ "onceDelay": "5",
23
+ "topic": "",
24
+ "payload": "",
25
+ "payloadType": "date",
26
+ "x": 130,
27
+ "y": 80,
28
+ "wires": [["opx_example_values"]]
29
+ },
30
+ {
31
+ "id": "opx_example_values",
32
+ "type": "function",
33
+ "z": "opx_example_tab",
34
+ "name": "Tus valores",
35
+ "func": "// Cada nombre será un canal en OperX. Cambia estos valores por los de tu máquina.\nmsg.payload = {\n temperatura: 80.5,\n horas: 1200.5\n};\nreturn msg;",
36
+ "outputs": 1,
37
+ "x": 320,
38
+ "y": 80,
39
+ "wires": [["opx_example_send"]]
40
+ },
41
+ {
42
+ "id": "opx_example_send",
43
+ "type": "operx",
44
+ "z": "opx_example_tab",
45
+ "name": "OperX",
46
+ "device": "opx_example_device",
47
+ "x": 500,
48
+ "y": 80,
49
+ "wires": []
50
+ }
51
+ ]
package/lib/queue.js ADDED
@@ -0,0 +1,120 @@
1
+ // What waits to go to OperX, in files under Node-RED's folder, so it survives a restart, a
2
+ // redeploy and a day without internet. Gateways often run on an SD card, so new values are
3
+ // appended, never the whole file rewritten each time: <name>.jsonl holds one reading a line, and
4
+ // <name>.done how many of its first lines already left. Once enough has left, the file is written
5
+ // again with what still waits. Refused values go to <name>.aparte.jsonl, never sent again.
6
+
7
+ const fs = require("node:fs");
8
+ const path = require("node:path");
9
+
10
+ // A day of a «Node-RED u otro sistema» device's limit (100,000 values a day).
11
+ const MOST_VALUES = 100_000;
12
+ const COMPACT_AFTER = 10_000;
13
+
14
+ function linesOf(file) {
15
+ if (!fs.existsSync(file)) return [];
16
+ return fs.readFileSync(file, "utf8").split("\n").filter((line) => line.trim() !== "");
17
+ }
18
+
19
+ /** The readings of a file's lines. A line cut by a power loss reads as nothing. */
20
+ function readingsIn(lines) {
21
+ const readings = [];
22
+ for (const line of lines) {
23
+ try {
24
+ readings.push(JSON.parse(line));
25
+ } catch {
26
+ // Cut halfway: it never reached the queue.
27
+ }
28
+ }
29
+ return readings;
30
+ }
31
+
32
+ function asLines(readings) {
33
+ return readings.map((reading) => JSON.stringify(reading) + "\n").join("");
34
+ }
35
+
36
+ class Queue {
37
+ constructor(folder, name, most = MOST_VALUES, compactAfter = COMPACT_AFTER) {
38
+ fs.mkdirSync(folder, { recursive: true });
39
+ this.file = path.join(folder, `${name}.jsonl`);
40
+ this.doneFile = path.join(folder, `${name}.done`);
41
+ this.asideFile = path.join(folder, `${name}.aparte.jsonl`);
42
+ this.most = most;
43
+ this.compactAfter = compactAfter;
44
+ this.dropped = 0;
45
+ this.endCutLine();
46
+ const lines = linesOf(this.file);
47
+ this.done = fs.existsSync(this.doneFile) ? Number(fs.readFileSync(this.doneFile, "utf8")) || 0 : 0;
48
+ this.lines = lines.length;
49
+ this.rows = readingsIn(lines.slice(this.done));
50
+ this.asideCount = linesOf(this.asideFile).length;
51
+ }
52
+
53
+ /** A line cut by a power loss gets its end, so the next one starts on its own line. */
54
+ endCutLine() {
55
+ if (!fs.existsSync(this.file)) return;
56
+ const text = fs.readFileSync(this.file, "utf8");
57
+ if (text && !text.endsWith("\n")) fs.appendFileSync(this.file, "\n");
58
+ }
59
+
60
+ add(readings) {
61
+ if (!readings.length) return;
62
+ fs.appendFileSync(this.file, asLines(readings));
63
+ this.rows.push(...readings);
64
+ this.lines += readings.length;
65
+ const over = this.rows.length - this.most;
66
+ if (over > 0) {
67
+ this.dropped += over;
68
+ this.forget(over);
69
+ }
70
+ }
71
+
72
+ oldest(count) {
73
+ return this.rows.slice(0, count);
74
+ }
75
+
76
+ /** The oldest `count` reached OperX. */
77
+ taken(count) {
78
+ this.forget(count);
79
+ }
80
+
81
+ /** OperX read the oldest `count` and won't take them: kept apart, never sent again. */
82
+ setAside(count) {
83
+ const refused = this.rows.slice(0, count);
84
+ fs.appendFileSync(this.asideFile, asLines(refused));
85
+ this.asideCount += refused.length;
86
+ this.forget(count);
87
+ }
88
+
89
+ forget(count) {
90
+ const gone = Math.min(count, this.rows.length);
91
+ this.rows.splice(0, gone);
92
+ this.done = this.lines - this.rows.length;
93
+ if (this.done >= this.compactAfter && this.done >= this.rows.length) {
94
+ this.compact();
95
+ return;
96
+ }
97
+ fs.writeFileSync(this.doneFile, String(this.done));
98
+ }
99
+
100
+ /** The file again with what still waits. The count goes back to 0 first: stopped halfway, some
101
+ * values are sent twice, which OperX skips (a channel's repeated time), and none is lost. */
102
+ compact() {
103
+ const temporary = `${this.file}.tmp`;
104
+ fs.writeFileSync(temporary, asLines(this.rows));
105
+ fs.writeFileSync(this.doneFile, "0");
106
+ fs.renameSync(temporary, this.file);
107
+ this.done = 0;
108
+ this.lines = this.rows.length;
109
+ }
110
+
111
+ waiting() {
112
+ return this.rows.length;
113
+ }
114
+
115
+ aside() {
116
+ return this.asideCount;
117
+ }
118
+ }
119
+
120
+ module.exports = { MOST_VALUES, Queue };
package/lib/read.js ADDED
@@ -0,0 +1,120 @@
1
+ // What one Node-RED message says, as OperX readings. The same rules as `operx connect forward`
2
+ // (cli/operx_cli/forwarding/read.py) and a plant broker's bridge (backend/connect/forward.py), so a
3
+ // channel keeps its name whichever way the plant sends: msg.topic names it, and an object's keys
4
+ // go under the topic. With no topic the keys are the names, as the Node-RED guide's flow has
5
+ // always named them.
6
+
7
+ const MAX_KEY = 100;
8
+ const TIME_KEYS = ["taken_at", "ts", "time", "timestamp", "TS", "Time", "Timestamp"];
9
+ const NUMBER_TEXT = /^-?\d+(\.\d+)?$/;
10
+ const NOT_READABLE = "No es un número, true/false, on/off ni JSON.";
11
+ const NOT_A_NUMBER = "«NaN» e «Infinity» no son números.";
12
+ const NEEDS_TOPIC = "Un valor solo necesita msg.topic: es el nombre del canal.";
13
+ const NAME_THE_LIST = "Una lista de números no dice qué es cada uno: ponles nombre en un nodo function, como {temperatura: msg.payload[0]}.";
14
+
15
+ /** The message can't be read; its message says why, in Spanish. */
16
+ class NotReadable extends Error {}
17
+
18
+ function isObject(value) {
19
+ return value !== null && typeof value === "object" && !Array.isArray(value) && !Buffer.isBuffer(value);
20
+ }
21
+
22
+ /** Nested objects as dot names: {motor: {temp: 1}} gives "motor.temp". Lists inside are skipped. */
23
+ function flatten(body, prefix = "") {
24
+ const pairs = [];
25
+ for (const [key, value] of Object.entries(body)) {
26
+ const name = prefix ? `${prefix}.${key}` : key;
27
+ if (isObject(value)) {
28
+ pairs.push(...flatten(value, name));
29
+ } else if (!Array.isArray(value)) {
30
+ pairs.push([name, value]);
31
+ }
32
+ }
33
+ return pairs;
34
+ }
35
+
36
+ function timeOf(body) {
37
+ for (const key of TIME_KEYS) {
38
+ if (body[key] !== undefined && body[key] !== null && body[key] !== "") return body[key];
39
+ }
40
+ return undefined;
41
+ }
42
+
43
+ /** [name, value, time] of a list: {channel, value} entries, or objects of names. */
44
+ function fromList(entries, time) {
45
+ const values = [];
46
+ for (const entry of entries) {
47
+ if (!isObject(entry)) throw new NotReadable(NAME_THE_LIST);
48
+ if ("channel" in entry) {
49
+ const ownTime = timeOf(entry);
50
+ values.push([String(entry.channel), entry.value, ownTime !== undefined ? ownTime : time]);
51
+ } else {
52
+ values.push(...fromObject(entry));
53
+ }
54
+ }
55
+ return values;
56
+ }
57
+
58
+ /** [name, value, time] of an object: OperX's {readings}, {ts, values}, {ts, data}, or plain names. */
59
+ function fromObject(body) {
60
+ const time = timeOf(body);
61
+ if (Array.isArray(body.readings)) return fromList(body.readings, time);
62
+ if (isObject(body.values)) return flatten(body.values).map(([key, raw]) => [key, raw, time]);
63
+ if (time !== undefined && isObject(body.data)) return flatten(body.data).map(([key, raw]) => [key, raw, time]);
64
+ const rest = Object.fromEntries(Object.entries(body).filter(([key]) => !TIME_KEYS.includes(key)));
65
+ return flatten(rest).map(([key, raw]) => [key, raw, time]);
66
+ }
67
+
68
+ /** A message that is one value, not JSON: 80.5, true, off. undefined for anything else. */
69
+ function bare(text) {
70
+ const lowered = text.toLowerCase();
71
+ if (lowered === "true" || lowered === "false") return lowered === "true";
72
+ if (lowered === "on" || lowered === "off") return text;
73
+ return NUMBER_TEXT.test(text) ? text : undefined;
74
+ }
75
+
76
+ function add(result, channel, raw, time) {
77
+ if (typeof raw === "number" && !Number.isFinite(raw)) throw new NotReadable(NOT_A_NUMBER);
78
+ if (channel.length > MAX_KEY) {
79
+ result.tooLong.push(channel);
80
+ return;
81
+ }
82
+ const reading = { channel, value: raw };
83
+ if (time !== undefined) reading.time = time;
84
+ result.readings.push(reading);
85
+ }
86
+
87
+ function single(result, name, value) {
88
+ if (!name) throw new NotReadable(NEEDS_TOPIC);
89
+ add(result, name, value);
90
+ return result;
91
+ }
92
+
93
+ function named(result, name, values) {
94
+ for (const [key, raw, time] of values) add(result, name ? `${name}/${key}` : key, raw, time);
95
+ return result;
96
+ }
97
+
98
+ /** {readings, tooLong} of a message. Throws NotReadable. */
99
+ function readingsOf(topic, payload) {
100
+ const name = typeof topic === "string" ? topic.trim() : "";
101
+ const result = { readings: [], tooLong: [] };
102
+ let body = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
103
+ if (typeof body === "string") {
104
+ const text = body.trim();
105
+ if (!text) return result;
106
+ const value = bare(text);
107
+ if (value !== undefined) return single(result, name, value);
108
+ try {
109
+ body = JSON.parse(text);
110
+ } catch {
111
+ throw new NotReadable(NOT_READABLE);
112
+ }
113
+ }
114
+ if (body === null || body === undefined) return result;
115
+ if (Array.isArray(body)) return named(result, name, fromList(body));
116
+ if (isObject(body)) return named(result, name, fromObject(body));
117
+ return single(result, name, body);
118
+ }
119
+
120
+ module.exports = { MAX_KEY, NotReadable, readingsOf };
package/lib/send.js ADDED
@@ -0,0 +1,117 @@
1
+ // Sends what waits to the device's address, oldest first, in batches the address takes: the same
2
+ // rules as `operx connect forward` (cli/operx_cli/forwarding/send.py). A batch leaves the queue
3
+ // only once OperX answered: sending one again after a lost answer is safe, because the address
4
+ // skips a channel's repeated times.
5
+
6
+ const MOST_VALUES = 500;
7
+ // The address takes up to 256 KB.
8
+ const MOST_BYTES = 200 * 1024;
9
+ // The address takes 60 messages a minute per key.
10
+ const PACE_SECONDS = 1.1;
11
+ const IDLE_SECONDS = 2;
12
+ const FIRST_WAIT = 5;
13
+ const LONGEST_WAIT = 300;
14
+ const KEY_WAIT = 60;
15
+ const LONGEST_LIMIT_WAIT = 3600;
16
+ const TIMEOUT_MS = 30_000;
17
+
18
+ /** The POST to the device's address, as a function of the body: {status, retryAfter, detail}.
19
+ * status null: no answer at all (no internet, a timeout, or the node stopping). */
20
+ function httpPost(address, key, stopping) {
21
+ const url = `${address.replace(/\/+$/, "")}/api/v1/connect/ingest`;
22
+ return async function post(body) {
23
+ const controller = new AbortController();
24
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
25
+ const stop = () => controller.abort();
26
+ stopping.addEventListener("abort", stop, { once: true });
27
+ try {
28
+ const response = await fetch(url, {
29
+ method: "POST", body, signal: controller.signal,
30
+ headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
31
+ });
32
+ const retryAfter = Number.parseInt(response.headers.get("retry-after") || "", 10);
33
+ const answer = await response.json().catch(() => ({}));
34
+ const detail = answer && typeof answer.detail === "string" ? answer.detail.slice(0, 200) : "";
35
+ return { status: response.status, retryAfter: Number.isNaN(retryAfter) ? null : retryAfter, detail };
36
+ } catch {
37
+ return { status: null };
38
+ } finally {
39
+ clearTimeout(timer);
40
+ stopping.removeEventListener("abort", stop);
41
+ }
42
+ };
43
+ }
44
+
45
+ /** One batch at a time. `state` is what the node's status shows: sending, away, limit or key. */
46
+ class Sender {
47
+ constructor(post, queue, say) {
48
+ this.post = post;
49
+ this.queue = queue;
50
+ this.say = say;
51
+ this.wait = FIRST_WAIT;
52
+ this.state = "sending";
53
+ this.sent = 0;
54
+ }
55
+
56
+ /** The oldest readings, as many as fit in one message. */
57
+ batch() {
58
+ const readings = [];
59
+ let size = 20;
60
+ for (const reading of this.queue.oldest(MOST_VALUES)) {
61
+ const length = Buffer.byteLength(JSON.stringify(reading)) + 1;
62
+ if (readings.length && size + length > MOST_BYTES) break;
63
+ readings.push(reading);
64
+ size += length;
65
+ }
66
+ return readings;
67
+ }
68
+
69
+ noAnswer(status) {
70
+ if (this.state !== "away") {
71
+ this.state = "away";
72
+ const cause = status === null ? "no responde" : `respondió ${status}`;
73
+ this.say(`Sin conexión: OperX ${cause}. Lo que llega se queda aquí, en orden, y sigo intentando.`);
74
+ }
75
+ const wait = this.wait;
76
+ this.wait = Math.min(this.wait * 2, LONGEST_WAIT);
77
+ return wait;
78
+ }
79
+
80
+ delivered(count) {
81
+ this.queue.taken(count);
82
+ this.sent += count;
83
+ this.wait = FIRST_WAIT;
84
+ if (this.state === "away") this.say("Volvió la conexión con OperX: enviando lo que esperaba, en orden.");
85
+ this.state = "sending";
86
+ return PACE_SECONDS;
87
+ }
88
+
89
+ /** One batch. Resolves to how many seconds to wait before the next. */
90
+ async sendOneBatch() {
91
+ const readings = this.batch();
92
+ if (!readings.length) return IDLE_SECONDS;
93
+ const answer = await this.post(JSON.stringify({ readings }));
94
+ const status = answer.status;
95
+ if (status === null || status >= 500) return this.noAnswer(status);
96
+ if (status >= 200 && status < 300) return this.delivered(readings.length);
97
+ if (status === 429) {
98
+ this.state = "limit";
99
+ const wait = Math.min(answer.retryAfter || KEY_WAIT, LONGEST_LIMIT_WAIT);
100
+ this.say(`OperX dice que este dispositivo pasó su límite: espero ${wait} s. ${answer.detail || ""}`.trim());
101
+ return wait;
102
+ }
103
+ if (status === 401 || status === 403) {
104
+ if (this.state !== "key") {
105
+ this.state = "key";
106
+ this.say("OperX no acepta esta clave: revísala en el dispositivo, pestaña Clave. Lo que llega se queda aquí.");
107
+ }
108
+ return KEY_WAIT;
109
+ }
110
+ // OperX read it and won't take it: sending it again would stop everything behind it.
111
+ this.queue.setAside(readings.length);
112
+ this.say(`OperX rechazó ${readings.length} valores (${status}): quedan aparte, en este computador. ${answer.detail || ""}`.trim());
113
+ return PACE_SECONDS;
114
+ }
115
+ }
116
+
117
+ module.exports = { Sender, httpPost };
package/lib/thin.js ADDED
@@ -0,0 +1,50 @@
1
+ // One value per channel per minute is kept, before anything is saved (decision 20, as in
2
+ // `operx connect forward`): a flow that reads every second would otherwise use a day's limit in
3
+ // hours, and fill this computer during an outage.
4
+
5
+ const KEPT_MINUTES = 120;
6
+
7
+ /** "2026-11-05T14:00" of seconds or milliseconds since 1970, or undefined. */
8
+ function epoch(number) {
9
+ if (typeof number !== "number" || number < 1e9) return undefined;
10
+ const seconds = number > 1e11 ? number / 1000 : number;
11
+ const date = new Date(seconds * 1000);
12
+ return Number.isNaN(date.getTime()) ? undefined : date.toISOString().slice(0, 16);
13
+ }
14
+
15
+ /** The minute a value belongs to: its own time's, else when it arrived (an ISO time). A time in
16
+ * text keeps its own clock: comparing a channel's minutes only needs them to agree. */
17
+ function minuteOf(raw, arrival) {
18
+ let minute;
19
+ if (typeof raw === "number") {
20
+ minute = epoch(raw);
21
+ } else if (typeof raw === "string") {
22
+ const text = raw.trim();
23
+ minute = /^\d+$/.test(text) ? epoch(Number(text)) : undefined;
24
+ if (minute === undefined && /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}/.test(text)) minute = text.slice(0, 16).replace(" ", "T");
25
+ }
26
+ return minute || arrival.slice(0, 16);
27
+ }
28
+
29
+ /** Keeps the first value of each channel in each minute, remembering each channel's latest
30
+ * minutes only: a backlog comes in order, and its oldest minutes won't come again. */
31
+ class Thinner {
32
+ constructor() {
33
+ this.kept = new Map();
34
+ }
35
+
36
+ keep(channel, rawTime, arrival) {
37
+ const minute = minuteOf(rawTime, arrival);
38
+ if (!this.kept.has(channel)) this.kept.set(channel, new Set());
39
+ const seen = this.kept.get(channel);
40
+ if (seen.has(minute)) return false;
41
+ seen.add(minute);
42
+ if (seen.size > KEPT_MINUTES) {
43
+ const oldest = [...seen].sort()[0];
44
+ seen.delete(oldest);
45
+ }
46
+ return true;
47
+ }
48
+ }
49
+
50
+ module.exports = { Thinner, minuteOf };
@@ -0,0 +1,48 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType("operx-device", {
3
+ category: "config",
4
+ defaults: {
5
+ name: { value: "" },
6
+ address: { value: "https://api.operx.pro", required: true },
7
+ },
8
+ credentials: {
9
+ key: { type: "password" },
10
+ },
11
+ label: function () {
12
+ return this.name || "Dispositivo de OperX";
13
+ },
14
+ });
15
+ </script>
16
+
17
+ <script type="text/html" data-template-name="operx-device">
18
+ <div class="form-row">
19
+ <label for="node-config-input-name"><i class="fa fa-tag"></i> Nombre</label>
20
+ <input type="text" id="node-config-input-name" placeholder="PC sala de control">
21
+ </div>
22
+ <div class="form-row">
23
+ <label for="node-config-input-key"><i class="fa fa-key"></i> Clave</label>
24
+ <input type="password" id="node-config-input-key" placeholder="opxd_…">
25
+ </div>
26
+ <div class="form-row">
27
+ <label for="node-config-input-address"><i class="fa fa-globe"></i> Dirección</label>
28
+ <input type="text" id="node-config-input-address">
29
+ </div>
30
+ <div class="form-tips">
31
+ La clave del dispositivo, la que OperX muestra al crearlo en Connect › Agregar dispositivo ›
32
+ «Node-RED u otro sistema». Queda guardada aparte del flujo: no sale cuando lo exportas.
33
+ </div>
34
+ </script>
35
+
36
+ <script type="text/html" data-help-name="operx-device">
37
+ <p>El dispositivo de OperX Connect con el que envía un nodo «OperX».</p>
38
+ <h3>Datos</h3>
39
+ <dl class="message-properties">
40
+ <dt>Clave <span class="property-type">texto</span></dt>
41
+ <dd>La del dispositivo, que empieza por <code>opxd_</code>. Sirve igual con o sin «Bearer ».
42
+ Node-RED la guarda en su archivo de credenciales, no en el flujo.</dd>
43
+ <dt>Dirección <span class="property-type">texto</span></dt>
44
+ <dd>La de OperX: <code>https://api.operx.pro</code>. No la cambies.</dd>
45
+ </dl>
46
+ <p>Un dispositivo por nodo «OperX»: dos nodos con la misma clave comparten su límite de 60 envíos
47
+ por minuto y sus valores del día.</p>
48
+ </script>
@@ -0,0 +1,14 @@
1
+ // The device a flow sends as: OperX's address and the device's key. The key is a Node-RED
2
+ // credential, kept in flows_cred.json and never in the flow, so exporting a flow never shares it.
3
+
4
+ const DEFAULT_ADDRESS = "https://api.operx.pro";
5
+
6
+ module.exports = function register(RED) {
7
+ function OperxDevice(config) {
8
+ RED.nodes.createNode(this, config);
9
+ this.name = config.name;
10
+ this.address = (config.address || DEFAULT_ADDRESS).trim();
11
+ }
12
+
13
+ RED.nodes.registerType("operx-device", OperxDevice, { credentials: { key: { type: "password" } } });
14
+ };
@@ -0,0 +1,85 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType("operx", {
3
+ category: "network",
4
+ color: "#FCD34D",
5
+ defaults: {
6
+ name: { value: "" },
7
+ device: { value: "", type: "operx-device", required: true },
8
+ },
9
+ inputs: 1,
10
+ outputs: 0,
11
+ icon: "font-awesome/fa-cloud-upload",
12
+ align: "right",
13
+ paletteLabel: "OperX",
14
+ label: function () {
15
+ return this.name || "OperX";
16
+ },
17
+ });
18
+ </script>
19
+
20
+ <script type="text/html" data-template-name="operx">
21
+ <div class="form-row">
22
+ <label for="node-input-device"><i class="fa fa-key"></i> Dispositivo</label>
23
+ <input type="text" id="node-input-device">
24
+ </div>
25
+ <div class="form-row">
26
+ <label for="node-input-name"><i class="fa fa-tag"></i> Nombre</label>
27
+ <input type="text" id="node-input-name" placeholder="OperX">
28
+ </div>
29
+ <div class="form-tips">
30
+ Envía a OperX lo que le llega y guarda aquí lo que no pueda enviar hasta que vuelva internet.
31
+ Un dispositivo recibe hasta 100.000 valores al día.
32
+ </div>
33
+ </script>
34
+
35
+ <script type="text/html" data-help-name="operx">
36
+ <p>Envía a OperX Connect los valores que le llegan. Lo que no puede enviar se queda en este
37
+ computador, en orden, y sale cuando vuelve internet.</p>
38
+
39
+ <h3>Entradas</h3>
40
+ <dl class="message-properties">
41
+ <dt>payload <span class="property-type">número | texto | objeto</span></dt>
42
+ <dd>
43
+ Un valor (<code>80.5</code>, <code>true</code>, <code>on</code>), o un objeto con un nombre por
44
+ canal (<code>{"temperatura": 80.5, "horas": 1200}</code>). También lee el JSON de OperX:
45
+ <code>{"ts": …, "values": {…}}</code> o <code>{"readings": [{"channel", "value", "time"}]}</code>.
46
+ </dd>
47
+ <dt class="optional">topic <span class="property-type">texto</span></dt>
48
+ <dd>
49
+ El nombre del canal para un valor solo, como lo trae un nodo «mqtt in»:
50
+ <code>planta/comp2/temp</code>. Con un objeto, cada nombre va bajo el tema:
51
+ <code>planta/comp2/temperatura</code>. Sin tema, los nombres del objeto son los canales.
52
+ </dd>
53
+ </dl>
54
+
55
+ <h3>Qué envía</h3>
56
+ <ul>
57
+ <li>De cada canal, el primer valor de cada minuto. Si el objeto trae <code>ts</code>,
58
+ <code>time</code> o <code>timestamp</code>, esa es su hora; si no, la de llegada.</li>
59
+ <li>Un dispositivo recibe hasta <b>100.000 valores al día</b>: unos 69 canales a un valor por
60
+ minuto. Para más, otro dispositivo con su propio nodo.</li>
61
+ <li>Lo que esperó durante un corte de internet cuenta el día en que sale: un corte largo puede
62
+ llenar el día siguiente.</li>
63
+ </ul>
64
+
65
+ <h3>Lo que no lee</h3>
66
+ <ul>
67
+ <li>Una lista de números, como la de un nodo Modbus: no dice qué es cada uno. Ponles nombre en
68
+ un nodo «function»: <code>msg.payload = {temperatura: msg.payload[0]}</code>.</li>
69
+ <li>Un valor solo sin <code>msg.topic</code>, y texto con unidades (<code>80,5 °C</code>).</li>
70
+ </ul>
71
+ <p>Cada caso se dice una vez en la barra de depuración y debajo del nodo.</p>
72
+
73
+ <h3>Si se cae internet</h3>
74
+ <p>Guarda hasta 100.000 valores en la carpeta <code>operx</code> de Node-RED (un día del límite del
75
+ dispositivo); si se llena, se van los más viejos. Sobrevive a un Deploy y a reiniciar Node-RED.
76
+ No pasa por un proxy: necesita salida directa a internet por el puerto 443.</p>
77
+
78
+ <h3>Debajo del nodo</h3>
79
+ <ul>
80
+ <li><b>enviados N · en espera N</b>: todo bien.</li>
81
+ <li><b>sin conexión</b>: OperX no responde; sigue intentando, cada vez más espaciado.</li>
82
+ <li><b>OperX no acepta la clave</b>: fue revocada o no es de este dispositivo.</li>
83
+ <li><b>pasó su límite del día</b>: espera y sigue después.</li>
84
+ </ul>
85
+ </script>
package/nodes/operx.js ADDED
@@ -0,0 +1,129 @@
1
+ // «OperX»: sends what reaches it to OperX Connect, keeping one value per channel per minute, and
2
+ // keeps what can't go yet in a file until the internet comes back (CONNECT_SPEC.md › OperX Agent,
3
+ // step 2). Reading never waits for the internet: a message is saved at once, and a loop sends.
4
+
5
+ const path = require("node:path");
6
+ const { MAX_KEY, NotReadable, readingsOf } = require("../lib/read");
7
+ const { Thinner } = require("../lib/thin");
8
+ const { Queue } = require("../lib/queue");
9
+ const { Sender, httpPost } = require("../lib/send");
10
+
11
+ // A problem with a message shows under the node this long, then the counts come back.
12
+ const PROBLEM_SHOWN_MS = 60_000;
13
+ // Each topic's problem is said once; a flow that publishes thousands must not flood the log.
14
+ const MOST_TOLD = 1000;
15
+
16
+ /** The device's key as pasted, with or without «Bearer ». "" when it isn't one. */
17
+ function cleanKey(text) {
18
+ const key = String(text || "").trim().replace(/^Bearer\s+/i, "").trim();
19
+ return key.startsWith("opxd_") ? key : "";
20
+ }
21
+
22
+ function thousands(count) {
23
+ return String(count).replace(/\B(?=(\d{3})+(?!\d))/g, ".");
24
+ }
25
+
26
+ /** Waits `seconds`, or less if the node stops meanwhile. */
27
+ function pause(seconds, stopping) {
28
+ return new Promise((resolve) => {
29
+ const timer = setTimeout(resolve, seconds * 1000);
30
+ stopping.addEventListener("abort", () => {
31
+ clearTimeout(timer);
32
+ resolve();
33
+ }, { once: true });
34
+ });
35
+ }
36
+
37
+ module.exports = function register(RED) {
38
+ function Operx(config) {
39
+ RED.nodes.createNode(this, config);
40
+ const node = this;
41
+ const device = RED.nodes.getNode(config.device);
42
+ const key = cleanKey(device && device.credentials && device.credentials.key);
43
+ if (!device || !key) {
44
+ node.status({ fill: "red", shape: "ring", text: "falta la clave del dispositivo" });
45
+ node.on("input", (_msg, _send, done) => done());
46
+ return;
47
+ }
48
+
49
+ const stopping = new AbortController();
50
+ const queue = new Queue(path.join(RED.settings.userDir || ".", "operx"), node.id);
51
+ const thin = new Thinner();
52
+ const told = new Set();
53
+ let problem = null;
54
+ // Shutting down aborts the request in flight: that isn't a lost connection to tell about.
55
+ const say = (line) => {
56
+ if (!stopping.signal.aborted) node.warn(line);
57
+ };
58
+ const sender = new Sender(httpPost(device.address, key, stopping.signal), queue, say);
59
+
60
+ function showStatus() {
61
+ const waiting = thousands(queue.waiting());
62
+ if (sender.state === "key") return node.status({ fill: "red", shape: "ring", text: "OperX no acepta la clave" });
63
+ if (sender.state === "away") return node.status({ fill: "yellow", shape: "ring", text: `sin conexión · ${waiting} en espera` });
64
+ if (sender.state === "limit") return node.status({ fill: "yellow", shape: "ring", text: `pasó su límite del día · ${waiting} en espera` });
65
+ if (problem && Date.now() - problem.at < PROBLEM_SHOWN_MS) return node.status({ fill: "yellow", shape: "dot", text: problem.text });
66
+ return node.status({ fill: "green", shape: "dot", text: `enviados ${thousands(sender.sent)} · en espera ${waiting}` });
67
+ }
68
+
69
+ function tellOnce(topic, text) {
70
+ problem = { text: text.slice(0, 80), at: Date.now() };
71
+ if (!told.has(topic) && told.size < MOST_TOLD) {
72
+ told.add(topic);
73
+ node.warn(text);
74
+ }
75
+ }
76
+
77
+ node.on("input", (msg, _send, done) => {
78
+ const arrival = new Date().toISOString();
79
+ const topic = typeof msg.topic === "string" ? msg.topic : "";
80
+ let result;
81
+ try {
82
+ result = readingsOf(topic, msg.payload);
83
+ } catch (error) {
84
+ if (!(error instanceof NotReadable)) return done(error);
85
+ tellOnce(topic, topic ? `«${topic.slice(0, 200)}»: ${error.message}` : error.message);
86
+ showStatus();
87
+ return done();
88
+ }
89
+ for (const name of result.tooLong) tellOnce(name, `«${name.slice(0, 200)}» pasa los ${MAX_KEY} caracteres que puede tener el nombre de un canal.`);
90
+ // Every value goes with a time: what waits through an outage keeps its minute.
91
+ const stamped = result.readings.map((reading) => ("time" in reading ? reading : { ...reading, time: arrival }));
92
+ const kept = stamped.filter((reading) => thin.keep(reading.channel, reading.time, arrival));
93
+ try {
94
+ queue.add(kept);
95
+ } catch (error) {
96
+ return done(error);
97
+ }
98
+ showStatus();
99
+ return done();
100
+ });
101
+
102
+ async function sendForever() {
103
+ while (!stopping.signal.aborted) {
104
+ let seconds;
105
+ try {
106
+ seconds = await sender.sendOneBatch();
107
+ } catch (error) {
108
+ node.error(`No pude enviar ni guardar: ${error.message}`);
109
+ seconds = 5;
110
+ }
111
+ showStatus();
112
+ await pause(seconds, stopping.signal);
113
+ }
114
+ }
115
+
116
+ showStatus();
117
+ if (queue.waiting()) node.log(`Hay ${thousands(queue.waiting())} valores de antes: salen primero.`);
118
+ const sending = sendForever();
119
+
120
+ // Each Deploy closes this node and starts a new one on the same files: stop sending, end the
121
+ // request in flight, and only then let the new one start. The queue is already on disk.
122
+ node.on("close", (_removed, done) => {
123
+ stopping.abort();
124
+ sending.then(() => done(), () => done());
125
+ });
126
+ }
127
+
128
+ RED.nodes.registerType("operx", Operx);
129
+ };
package/package.json CHANGED
@@ -1,6 +1,23 @@
1
1
  {
2
2
  "name": "node-red-contrib-operx",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Envía a OperX Connect los valores de tus máquinas, y guarda lo que no salga hasta que vuelva internet.",
5
+ "author": "OperX",
6
+ "homepage": "https://operx.pro",
7
+ "license": "UNLICENSED",
8
+ "keywords": ["node-red", "operx", "iot", "mantenimiento", "connect"],
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "files": ["nodes", "lib", "examples", "README.md"],
13
+ "scripts": {
14
+ "test": "node --test"
15
+ },
16
+ "node-red": {
17
+ "version": ">=4.0.0",
18
+ "nodes": {
19
+ "operx-device": "nodes/operx-device.js",
20
+ "operx": "nodes/operx.js"
21
+ }
22
+ }
23
+ }