@atelieruldetehnologii/node-red-custome-solutions 0.62.22

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.
Files changed (107) hide show
  1. package/NOTICE +56 -0
  2. package/PROPRIETARY-LICENSE.txt +157 -0
  3. package/README.md +105 -0
  4. package/TERMS-AND-CONDITIONS.md +311 -0
  5. package/advantech-read/advantech-read.html +775 -0
  6. package/advantech-read/advantech-read.js +449 -0
  7. package/advantech-write/advantech-write.html +413 -0
  8. package/advantech-write/advantech-write.js +338 -0
  9. package/bess-control/bess-control.html +569 -0
  10. package/bess-control/bess-control.js +541 -0
  11. package/bess-control/lib/allocation.js +149 -0
  12. package/bess-control/lib/batteries.js +86 -0
  13. package/bess-control/lib/helpers.js +157 -0
  14. package/bess-control/lib/messages.js +131 -0
  15. package/bess-control/lib/runtime.js +156 -0
  16. package/carlo-gavazzi/carlo-gavazzi.html +4327 -0
  17. package/carlo-gavazzi/carlo-gavazzi.js +3081 -0
  18. package/control-export/control-export.html +3644 -0
  19. package/control-export/control-export.js +2192 -0
  20. package/control-reactive/control-reactive.html +3136 -0
  21. package/control-reactive/control-reactive.js +2124 -0
  22. package/fronius-emulator/README.md +258 -0
  23. package/fronius-emulator/fronius-emulator.html +170 -0
  24. package/fronius-emulator/fronius-emulator.js +520 -0
  25. package/fronius-emulator/lib/binary-codec.js +65 -0
  26. package/fronius-emulator/lib/fronius-register-map.js +185 -0
  27. package/fronius-emulator/lib/modbus-parser.js +61 -0
  28. package/fronius-emulator/lib/modbus-tcp-server.js +224 -0
  29. package/fronius-emulator/lib/validation.js +206 -0
  30. package/fronius-emulator/lib/virtual-meter.js +132 -0
  31. package/fronius-read/fronius-read.html +3944 -0
  32. package/fronius-read/fronius-read.js +1466 -0
  33. package/fronius-read/lib/inverter-profiles.js +711 -0
  34. package/fronius-read/lib/sunspec-core.js +303 -0
  35. package/fronius-read/lib/sunspec-model-parsers.js +845 -0
  36. package/fronius-write/fronius-write.html +1789 -0
  37. package/fronius-write/fronius-write.js +2531 -0
  38. package/influxdb/influxdb.html +1077 -0
  39. package/influxdb/influxdb.js +1487 -0
  40. package/janitza/janitza.html +2562 -0
  41. package/janitza/janitza.js +1500 -0
  42. package/janitza/lib/janitza-profiles.js +371 -0
  43. package/lib/a3-test-server.js +199 -0
  44. package/lib/adam-6000-profiles.js +173 -0
  45. package/lib/licensing/constants.js +103 -0
  46. package/lib/licensing/device-id.js +251 -0
  47. package/lib/licensing/errors.js +21 -0
  48. package/lib/licensing/license-client.js +377 -0
  49. package/lib/licensing/license-state.js +50 -0
  50. package/lib/licensing/node-guard.js +150 -0
  51. package/lib/licensing/package-integrity.js +167 -0
  52. package/lib/licensing/package-integrity.json +513 -0
  53. package/lib/licensing/public-key.pem +3 -0
  54. package/lib/licensing/public-key.pem.example +2 -0
  55. package/lib/licensing/public-keys.js +18 -0
  56. package/lib/licensing/storage.js +63 -0
  57. package/lib/licensing/token-verifier.js +84 -0
  58. package/lib/numbers.js +26 -0
  59. package/license/atelier-license.html +88 -0
  60. package/license/atelier-license.js +170 -0
  61. package/modbus-tcp-discover/modbus-tcp-discover.html +190 -0
  62. package/modbus-tcp-discover/modbus-tcp-discover.js +706 -0
  63. package/multi-battery/multi-battery.html +649 -0
  64. package/multi-battery/multi-battery.js +668 -0
  65. package/multi-grid/multi-grid.html +745 -0
  66. package/multi-grid/multi-grid.js +1392 -0
  67. package/multi-inverter/lib/engine-core.js +237 -0
  68. package/multi-inverter/multi-inverter.html +2674 -0
  69. package/multi-inverter/multi-inverter.js +1393 -0
  70. package/package.json +109 -0
  71. package/phoenix-contact/lib/eem-ma370-advanced.js +175 -0
  72. package/phoenix-contact/lib/eem-ma370.js +138 -0
  73. package/phoenix-contact/lib/integrations.js +149 -0
  74. package/phoenix-contact/lib/phoenix-contact-profile.js +35030 -0
  75. package/phoenix-contact/phoenix-contact.html +840 -0
  76. package/phoenix-contact/phoenix-contact.js +679 -0
  77. package/pv-generator/pv-generator.html +284 -0
  78. package/pv-generator/pv-generator.js +387 -0
  79. package/pylontech-read/pylontech-read.html +763 -0
  80. package/pylontech-read/pylontech-read.js +518 -0
  81. package/pylontech-write/pylontech-write.html +405 -0
  82. package/pylontech-write/pylontech-write.js +546 -0
  83. package/scada-aggregator/lib/aggregate.js +72 -0
  84. package/scada-aggregator/lib/groups.js +39 -0
  85. package/scada-aggregator/scada-aggregator.html +108 -0
  86. package/scada-aggregator/scada-aggregator.js +117 -0
  87. package/scada-server/lib/aggregator-map.js +58 -0
  88. package/scada-server/lib/encoding.js +235 -0
  89. package/scada-server/lib/modbus-server.js +492 -0
  90. package/scada-server/lib/object-path.js +18 -0
  91. package/scada-server/lib/register-map.js +288 -0
  92. package/scada-server/scada-server.html +1419 -0
  93. package/scada-server/scada-server.js +704 -0
  94. package/scripts/check-syntax.js +66 -0
  95. package/scripts/package-integrity.js +46 -0
  96. package/scripts/package-release.js +116 -0
  97. package/scripts/run-tests.js +2178 -0
  98. package/scripts/tgz-integrity.js +74 -0
  99. package/sma-read/lib/sunspec-core.js +83 -0
  100. package/sma-read/lib/sunspec-model-parsers.js +853 -0
  101. package/sma-read/sma-read.html +659 -0
  102. package/sma-read/sma-read.js +458 -0
  103. package/sma-write/sma-write.html +105 -0
  104. package/sma-write/sma-write.js +270 -0
  105. package/victron-read/lib/mqtt-state.js +176 -0
  106. package/victron-read/victron-read.html +1074 -0
  107. package/victron-read/victron-read.js +1222 -0
@@ -0,0 +1,706 @@
1
+ module.exports = function (RED) {
2
+ "use strict";
3
+
4
+ const { runLicensedNode } = require("../lib/licensing/node-guard");
5
+
6
+ const net = require("net");
7
+ const tls = require("tls");
8
+ const dns = require("dns").promises;
9
+ const os = require("os");
10
+ const { spawn } = require("child_process");
11
+
12
+ const MAX_HOSTS = 4096;
13
+ const MAX_PORTS = 32;
14
+ const MAX_UNIT_IDS = 247;
15
+ const SUNSPEC_BASES = [39999, 40000, 40001, 40002, 40199, 40200, 40201, 40202];
16
+ const OUI_VENDORS = { "0003AC": "Fronius International", "001132": "Synology", "0080F4": "Schneider Electric", "D83ADD": "Raspberry Pi", "E43883": "Ubiquiti", "CCB182": "Huawei" };
17
+
18
+ function clamp(value, min, max, fallback) {
19
+ const n = Number(value);
20
+ return Number.isFinite(n) ? Math.max(min, Math.min(max, Math.round(n))) : fallback;
21
+ }
22
+
23
+ function ipToNumber(ip) {
24
+ const parts = String(ip || "").trim().split(".");
25
+ if (parts.length !== 4) throw new Error("Adresa IPv4 invalida: " + ip);
26
+ const octets = parts.map(function (part) {
27
+ if (!/^\d+$/.test(part)) throw new Error("Adresa IPv4 invalida: " + ip);
28
+ const value = Number(part);
29
+ if (value < 0 || value > 255) throw new Error("Adresa IPv4 invalida: " + ip);
30
+ return value;
31
+ });
32
+ return (((octets[0] * 0x1000000) + (octets[1] << 16) + (octets[2] << 8) + octets[3]) >>> 0);
33
+ }
34
+
35
+ function numberToIp(value) {
36
+ const n = Number(value) >>> 0;
37
+ return [(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join(".");
38
+ }
39
+
40
+ function isPrivateIp(ip) {
41
+ const n = ipToNumber(ip);
42
+ return ((n & 0xff000000) >>> 0) === 0x0a000000 ||
43
+ ((n & 0xfff00000) >>> 0) === 0xac100000 ||
44
+ ((n & 0xffff0000) >>> 0) === 0xc0a80000 ||
45
+ ((n & 0xff000000) >>> 0) === 0x7f000000 ||
46
+ ((n & 0xffff0000) >>> 0) === 0xa9fe0000;
47
+ }
48
+
49
+ function rangeFromTarget(value, allowPublic) {
50
+ const target = String(value || "").trim();
51
+ if (!target) throw new Error("Reteaua sau intervalul IP lipseste");
52
+ let start;
53
+ let end;
54
+ if (target.includes("/")) {
55
+ const parts = target.split("/");
56
+ if (parts.length !== 2 || !/^\d+$/.test(parts[1])) throw new Error("CIDR invalid: " + target);
57
+ const ip = ipToNumber(parts[0]);
58
+ const prefix = Number(parts[1]);
59
+ if (prefix < 0 || prefix > 32) throw new Error("Prefix CIDR invalid: " + prefix);
60
+ const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
61
+ start = (ip & mask) >>> 0;
62
+ end = (start | (~mask >>> 0)) >>> 0;
63
+ if (prefix <= 30) { start += 1; end -= 1; }
64
+ } else if (target.includes("-")) {
65
+ const parts = target.split("-").map(function (part) { return part.trim(); });
66
+ if (parts.length !== 2) throw new Error("Interval IP invalid: " + target);
67
+ start = ipToNumber(parts[0]);
68
+ if (/^\d+$/.test(parts[1])) {
69
+ const last = Number(parts[1]);
70
+ if (last < 0 || last > 255) throw new Error("Ultimul octet este invalid: " + parts[1]);
71
+ end = ((start & 0xffffff00) | last) >>> 0;
72
+ } else end = ipToNumber(parts[1]);
73
+ } else {
74
+ start = ipToNumber(target);
75
+ end = start;
76
+ }
77
+ if (end < start) throw new Error("Sfarsitul intervalului este mai mic decat inceputul");
78
+ const count = end - start + 1;
79
+ if (count > MAX_HOSTS) throw new Error("Intervalul depaseste limita de " + MAX_HOSTS + " gazde");
80
+ if (!allowPublic && (!isPrivateIp(numberToIp(start)) || !isPrivateIp(numberToIp(end)))) {
81
+ throw new Error("Scanarea adreselor publice este blocata. Activeaza explicit optiunea daca ai autorizatie.");
82
+ }
83
+ return { start: start, end: end, count: count };
84
+ }
85
+
86
+ function parseNumericList(value, min, max, maximumItems, label) {
87
+ const values = [];
88
+ String(value || "").split(",").forEach(function (token) {
89
+ const text = token.trim();
90
+ if (!text) return;
91
+ if (text.includes("-")) {
92
+ const bounds = text.split("-").map(Number);
93
+ if (bounds.length !== 2 || !bounds.every(Number.isInteger) || bounds[0] < min || bounds[1] > max || bounds[1] < bounds[0]) {
94
+ throw new Error("Interval " + label + " invalid: " + text);
95
+ }
96
+ for (let value = bounds[0]; value <= bounds[1]; value += 1) values.push(value);
97
+ } else {
98
+ const numeric = Number(text);
99
+ if (!Number.isInteger(numeric) || numeric < min || numeric > max) throw new Error(label + " invalid: " + text);
100
+ values.push(numeric);
101
+ }
102
+ });
103
+ const unique = Array.from(new Set(values));
104
+ if (!unique.length) throw new Error("Lista " + label + " este goala");
105
+ if (unique.length > maximumItems) throw new Error("Lista depaseste limita de " + maximumItems + " " + label);
106
+ return unique;
107
+ }
108
+
109
+ function wait(ms) {
110
+ return new Promise(function (resolve) { setTimeout(resolve, ms); });
111
+ }
112
+
113
+ function pingHost(host, timeoutMs, session) {
114
+ return new Promise(function (resolve) {
115
+ const windows = os.platform() === "win32";
116
+ const args = windows ? ["-n", "1", "-w", String(timeoutMs), host] : ["-c", "1", "-W", String(Math.max(1, Math.ceil(timeoutMs / 1000))), host];
117
+ let child;
118
+ let settled = false;
119
+ function done(online) {
120
+ if (settled) return;
121
+ settled = true;
122
+ if (child) session.children.delete(child);
123
+ resolve(online);
124
+ }
125
+ try {
126
+ child = spawn(windows ? "ping" : "ping", args, { windowsHide: true, stdio: "ignore" });
127
+ session.children.add(child);
128
+ child.once("error", function () { done(false); });
129
+ child.once("exit", function (code) { done(code === 0); });
130
+ } catch (error) { done(false); }
131
+ });
132
+ }
133
+
134
+ function neighborTable(session) {
135
+ return new Promise(function (resolve) {
136
+ const windows = os.platform() === "win32";
137
+ const command = windows ? "arp" : "ip";
138
+ const args = windows ? ["-a"] : ["neigh"];
139
+ let output = "";
140
+ let child;
141
+ function finish() {
142
+ if (child) session.children.delete(child);
143
+ const found = new Map();
144
+ output.split(/\r?\n/).forEach(function (line) {
145
+ const ipMatch = line.match(/\b(?:\d{1,3}\.){3}\d{1,3}\b/);
146
+ const macMatch = line.match(/\b(?:[0-9a-f]{2}[:-]){5}[0-9a-f]{2}\b/i);
147
+ if (!ipMatch) return;
148
+ try {
149
+ ipToNumber(ipMatch[0]);
150
+ found.set(ipMatch[0], macMatch ? macMatch[0].replace(/-/g, ":").toUpperCase() : null);
151
+ } catch (error) { /* ignore */ }
152
+ });
153
+ resolve(found);
154
+ }
155
+ try {
156
+ child = spawn(command, args, { windowsHide: true, stdio: ["ignore", "pipe", "ignore"] });
157
+ session.children.add(child);
158
+ child.stdout.on("data", function (chunk) { output += chunk.toString(); });
159
+ child.once("error", finish);
160
+ child.once("exit", finish);
161
+ } catch (error) { resolve(new Map()); }
162
+ });
163
+ }
164
+
165
+ function checkPort(host, port, timeoutMs, session, rateDelayMs) {
166
+ return new Promise(async function (resolve) {
167
+ if (rateDelayMs > 0) {
168
+ const slot = Math.max(Date.now(), session.nextConnectAt || 0);
169
+ session.nextConnectAt = slot + rateDelayMs;
170
+ if (slot > Date.now()) await wait(slot - Date.now());
171
+ }
172
+ if (session.cancelled) return resolve({ port: port, status: "cancelled", latency_ms: 0, error_code: null });
173
+ const started = Date.now();
174
+ const socket = new net.Socket();
175
+ session.sockets.add(socket);
176
+ let finished = false;
177
+ function done(status, errorCode) {
178
+ if (finished) return;
179
+ finished = true;
180
+ session.sockets.delete(socket);
181
+ socket.destroy();
182
+ resolve({ port: port, status: status, latency_ms: Date.now() - started, error_code: errorCode || null });
183
+ }
184
+ socket.setTimeout(timeoutMs);
185
+ socket.once("connect", function () { done("open"); });
186
+ socket.once("timeout", function () { done("timeout", "ETIMEDOUT"); });
187
+ socket.once("error", function (error) {
188
+ const code = error && error.code ? error.code : "ERROR";
189
+ done(code === "ECONNREFUSED" ? "refused" : "error", code);
190
+ });
191
+ socket.once("close", function () { if (!finished) done(session.cancelled ? "cancelled" : "error", session.cancelled ? null : "ECLOSED"); });
192
+ socket.connect({ host: host, port: port });
193
+ });
194
+ }
195
+
196
+ function modbusRequest(host, port, unitId, pdu, timeoutMs, session) {
197
+ return new Promise(function (resolve) {
198
+ if (session.cancelled) return resolve(null);
199
+ const socket = new net.Socket();
200
+ session.sockets.add(socket);
201
+ const transactionId = (Date.now() + unitId) & 0xffff;
202
+ const header = Buffer.alloc(7);
203
+ header.writeUInt16BE(transactionId, 0);
204
+ header.writeUInt16BE(0, 2);
205
+ header.writeUInt16BE(pdu.length + 1, 4);
206
+ header.writeUInt8(unitId, 6);
207
+ const chunks = [];
208
+ let finished = false;
209
+ function done(value) {
210
+ if (finished) return;
211
+ finished = true;
212
+ session.sockets.delete(socket);
213
+ socket.destroy();
214
+ resolve(value);
215
+ }
216
+ socket.setTimeout(timeoutMs);
217
+ socket.once("connect", function () { socket.write(Buffer.concat([header, pdu])); });
218
+ socket.on("data", function (chunk) {
219
+ chunks.push(chunk);
220
+ const data = Buffer.concat(chunks);
221
+ if (data.length >= 6) {
222
+ const expected = 6 + data.readUInt16BE(4);
223
+ if (data.length >= expected) done(data.slice(0, expected));
224
+ }
225
+ });
226
+ socket.once("timeout", function () { done(null); });
227
+ socket.once("error", function () { done(null); });
228
+ socket.once("close", function () { if (!finished) done(null); });
229
+ socket.connect({ host: host, port: port });
230
+ });
231
+ }
232
+
233
+ function mqttConnectPacket() {
234
+ const clientId = Buffer.from("nodered-discover", "utf8");
235
+ const variable = Buffer.from([0x00, 0x04, 0x4d, 0x51, 0x54, 0x54, 0x04, 0x02, 0x00, 0x0a]);
236
+ const payload = Buffer.concat([Buffer.from([(clientId.length >> 8) & 255, clientId.length & 255]), clientId]);
237
+ const remaining = variable.length + payload.length;
238
+ return Buffer.concat([Buffer.from([0x10, remaining]), variable, payload]);
239
+ }
240
+
241
+ function probeMqtt(host, port, timeoutMs, session) {
242
+ return new Promise(function (resolve) {
243
+ if (session.cancelled) return resolve(null);
244
+ const secure = port === 8883;
245
+ const socket = secure
246
+ ? tls.connect({ host: host, port: port, rejectUnauthorized: false })
247
+ : net.connect({ host: host, port: port });
248
+ session.sockets.add(socket);
249
+ let finished = false;
250
+ function done(value) {
251
+ if (finished) return;
252
+ finished = true;
253
+ session.sockets.delete(socket);
254
+ socket.destroy();
255
+ resolve(value);
256
+ }
257
+ socket.setTimeout(timeoutMs);
258
+ socket.once("connect", function () { if (!secure) socket.write(mqttConnectPacket()); });
259
+ if (secure) socket.once("secureConnect", function () { socket.write(mqttConnectPacket()); });
260
+ socket.once("data", function (data) {
261
+ if (data.length >= 4 && data[0] === 0x20 && data[1] === 0x02) {
262
+ let certificate = null;
263
+ if (secure && socket.getPeerCertificate) {
264
+ const peer = socket.getPeerCertificate();
265
+ certificate = peer && Object.keys(peer).length ? { subject: peer.subject || null, issuer: peer.issuer || null, valid_to: peer.valid_to || null } : null;
266
+ }
267
+ done({ confirmed: true, port: port, tls: secure, connack_code: data[3], authorized: data[3] === 0, certificate: certificate });
268
+ } else done(null);
269
+ });
270
+ socket.once("timeout", function () { done(null); });
271
+ socket.once("error", function () { done(null); });
272
+ socket.once("close", function () { if (!finished) done(null); });
273
+ });
274
+ }
275
+
276
+ function holdingPdu(address, count) {
277
+ const pdu = Buffer.alloc(5);
278
+ pdu[0] = 3;
279
+ pdu.writeUInt16BE(address, 1);
280
+ pdu.writeUInt16BE(count, 3);
281
+ return pdu;
282
+ }
283
+
284
+ function parseHolding(response) {
285
+ if (!response || response.length < 9) return null;
286
+ const fc = response[7];
287
+ if ((fc & 0x80) !== 0) return { exception: response[8], words: [] };
288
+ if (fc !== 3 || response.length < 9 + response[8]) return null;
289
+ const words = [];
290
+ for (let offset = 9; offset + 1 < 9 + response[8]; offset += 2) words.push(response.readUInt16BE(offset));
291
+ return { exception: null, words: words };
292
+ }
293
+
294
+ function wordsToText(words) {
295
+ return words.map(function (word) { return String.fromCharCode((word >> 8) & 255, word & 255); }).join("");
296
+ }
297
+
298
+ function decodeSunSpecString(words) {
299
+ return wordsToText(words).replace(/\0/g, "").trim();
300
+ }
301
+
302
+ async function readSunSpecCommon(host, port, unitId, base, timeoutMs, session) {
303
+ const header = parseHolding(await modbusRequest(host, port, unitId, holdingPdu(base + 2, 2), timeoutMs, session));
304
+ if (!header || header.exception || header.words.length < 2 || header.words[0] !== 1) return null;
305
+ const length = Math.min(65, header.words[1]);
306
+ if (length < 65) return null;
307
+ const payload = parseHolding(await modbusRequest(host, port, unitId, holdingPdu(base + 4, length), timeoutMs, session));
308
+ if (!payload || payload.exception || payload.words.length < 65) return null;
309
+ return {
310
+ manufacturer: decodeSunSpecString(payload.words.slice(0, 16)) || null,
311
+ model: decodeSunSpecString(payload.words.slice(16, 32)) || null,
312
+ options: decodeSunSpecString(payload.words.slice(32, 40)) || null,
313
+ version: decodeSunSpecString(payload.words.slice(40, 48)) || null,
314
+ serial: decodeSunSpecString(payload.words.slice(48, 64)) || null,
315
+ device_address: payload.words[64]
316
+ };
317
+ }
318
+
319
+ function parseDeviceIdentification(response) {
320
+ if (!response || response.length < 14 || response[7] !== 0x2b || response[8] !== 0x0e) return null;
321
+ const count = response[13];
322
+ let offset = 14;
323
+ const objects = {};
324
+ for (let index = 0; index < count && offset + 2 <= response.length; index += 1) {
325
+ const objectId = response[offset++];
326
+ const length = response[offset++];
327
+ if (offset + length > response.length) break;
328
+ objects[objectId] = response.slice(offset, offset + length).toString("utf8").replace(/\0/g, "").trim();
329
+ offset += length;
330
+ }
331
+ return { vendor: objects[0] || null, product_code: objects[1] || null, revision: objects[2] || null, objects: objects };
332
+ }
333
+
334
+ async function probeModbus(host, port, unitIds, timeoutMs, session, probeMode) {
335
+ const devices = [];
336
+ const preferred = [1, 3, 126].filter(function (unitId) { return unitIds.includes(unitId); });
337
+ const scanOrder = preferred.concat(unitIds.filter(function (unitId) { return !preferred.includes(unitId); }));
338
+ const testedUnitIds = [];
339
+ for (const unitId of scanOrder) {
340
+ if (session.cancelled) break;
341
+ testedUnitIds.push(unitId);
342
+ const identification = parseDeviceIdentification(await modbusRequest(host, port, unitId, Buffer.from([0x2b, 0x0e, 0x01, 0x00]), timeoutMs, session));
343
+ const profileResponse = probeMode === "sunspec_only" ? null : parseHolding(await modbusRequest(host, port, unitId, holdingPdu(30001, 2), timeoutMs, session));
344
+ let responds = !!identification || !!(profileResponse && ![10, 11].includes(profileResponse.exception));
345
+ let profileVersion = null;
346
+ let protocol = null;
347
+ if (profileResponse && !profileResponse.exception && profileResponse.words.length >= 2) {
348
+ profileVersion = ((profileResponse.words[0] * 0x10000) + profileResponse.words[1]) >>> 0;
349
+ protocol = "sma-native";
350
+ }
351
+ let sunspecBase = null;
352
+ let sunspecCommon = null;
353
+ if (!protocol && (responds || probeMode === "sunspec_only")) {
354
+ for (const base of SUNSPEC_BASES) {
355
+ const parsed = parseHolding(await modbusRequest(host, port, unitId, holdingPdu(base, 2), timeoutMs, session));
356
+ if (parsed && ![10, 11].includes(parsed.exception)) responds = true;
357
+ if (parsed && !parsed.exception && wordsToText(parsed.words).includes("SunS")) {
358
+ protocol = "sunspec";
359
+ sunspecBase = base;
360
+ sunspecCommon = await readSunSpecCommon(host, port, unitId, base, timeoutMs, session);
361
+ break;
362
+ }
363
+ }
364
+ }
365
+ if (responds) devices.push({ unit_id: unitId, protocol: protocol || "modbus", identification: identification, sma_profile_version: profileVersion, sunspec_base: sunspecBase, sunspec_common: sunspecCommon });
366
+ if (testedUnitIds.length === preferred.length && preferred.length >= 2 && devices.length === preferred.length) {
367
+ const signatures = new Set(devices.map(function (device) { return JSON.stringify({ protocol: device.protocol, identity: device.identification || device.sunspec_common || null, profile: device.sma_profile_version, base: device.sunspec_base }); }));
368
+ if (signatures.size === 1) break;
369
+ }
370
+ }
371
+ const signatures = new Map();
372
+ devices.forEach(function (device) {
373
+ const id = device.identification || device.sunspec_common || null;
374
+ const key = JSON.stringify({ protocol: device.protocol, identity: id, profile: device.sma_profile_version, base: device.sunspec_base });
375
+ if (!signatures.has(key)) signatures.set(key, []);
376
+ signatures.get(key).push(device.unit_id);
377
+ });
378
+ const mirrored = devices.length > 1 && signatures.size === 1;
379
+ const representative = devices[0] || null;
380
+ const identity = representative && (representative.sunspec_common || representative.identification) || null;
381
+ return {
382
+ confirmed: devices.length > 0,
383
+ unit_id_behavior: mirrored ? "ignored_or_mirrored" : "distinct",
384
+ tested_unit_ids: testedUnitIds,
385
+ responsive_unit_ids: devices.map(function (device) { return device.unit_id; }),
386
+ units: mirrored ? [representative] : devices,
387
+ manufacturer: identity && (identity.manufacturer || identity.vendor) || null,
388
+ model: identity && (identity.model || identity.product_code) || null,
389
+ firmware: identity && (identity.version || identity.revision) || null,
390
+ serial: identity && identity.serial || null
391
+ };
392
+ }
393
+
394
+ function profileDefaults(profile, detail, configuredPorts, configuredUnitIds, configuredProbe) {
395
+ const level = ["rapid", "standard", "detailed"].includes(detail) ? detail : "standard";
396
+ const unitSets = {
397
+ rapid: "1,126",
398
+ standard: "1,3,126",
399
+ detailed: "1-20,126"
400
+ };
401
+ const definitions = {
402
+ sunspec: { ports: "502", probe: true, probeMode: "sunspec_only", unitIds: unitSets[level] },
403
+ modbus: { ports: "502", probe: true, probeMode: "auto", unitIds: level === "rapid" ? "1" : (level === "standard" ? "1-10" : "1-50,126") },
404
+ mqtt: { ports: "1883,8883", probe: false, probeMode: "none", unitIds: "1" },
405
+ web: { ports: "80,443,8080,8443", probe: false, probeMode: "none", unitIds: "1" },
406
+ industrial: { ports: "102,502,1883,4840,20000,44818", probe: true, probeMode: "auto", unitIds: level === "detailed" ? "1-20,126" : "1,3,126" },
407
+ full: { ports: "80,102,443,502,1883,4840,8080,8443,8883,20000,44818", probe: true, probeMode: "auto", unitIds: level === "detailed" ? "1-50,126" : "1-10,126" }
408
+ };
409
+ return definitions[profile] || { ports: configuredPorts, probe: configuredProbe, probeMode: "auto", unitIds: configuredUnitIds };
410
+ }
411
+
412
+ function servicesFor(device) {
413
+ const ports = new Set(device.open_ports || []);
414
+ const services = [];
415
+ if (ports.has(80) || ports.has(8080) || ports.has(8443)) services.push("web");
416
+ if (ports.has(443) || ports.has(8443)) services.push("https");
417
+ if (ports.has(1883) || ports.has(8883)) services.push("mqtt-port");
418
+ if (device.mqtt && Object.keys(device.mqtt).some(function (port) { return device.mqtt[port] && device.mqtt[port].confirmed; })) services.push("mqtt");
419
+ if (ports.has(102)) services.push("s7-port");
420
+ if (ports.has(4840)) services.push("opcua-port");
421
+ if (ports.has(20000)) services.push("dnp3-port");
422
+ if (ports.has(44818)) services.push("ethernet-ip-port");
423
+ if (device.modbus && device.modbus.confirmed) services.push("modbus-tcp");
424
+ if (device.sunspec) services.push("sunspec");
425
+ return services;
426
+ }
427
+
428
+ function matchesProfile(device, profile) {
429
+ if (profile === "sunspec") return device.sunspec;
430
+ if (profile === "modbus") return !!(device.modbus && device.modbus.confirmed);
431
+ if (profile === "mqtt") return !!(device.mqtt && Object.keys(device.mqtt).some(function (port) { return device.mqtt[port] && device.mqtt[port].confirmed; }));
432
+ if (profile === "web") return device.open_ports.some(function (port) { return [80, 443, 8080, 8443].includes(port); });
433
+ if (profile === "industrial") return device.open_ports.length > 0;
434
+ return device.online;
435
+ }
436
+
437
+ function indexDevices(devices, mode) {
438
+ if (mode === "array") return devices;
439
+ const indexed = {};
440
+ devices.forEach(function (device) {
441
+ let key = device.ip;
442
+ if (mode === "last_octet") {
443
+ const candidate = device.ip.split(".").pop();
444
+ key = Object.prototype.hasOwnProperty.call(indexed, candidate) ? device.ip : candidate;
445
+ }
446
+ indexed[key] = device;
447
+ });
448
+ return indexed;
449
+ }
450
+
451
+ async function mapLimit(items, limit, worker) {
452
+ let cursor = 0;
453
+ async function runner() {
454
+ while (cursor < items.length) {
455
+ const index = cursor++;
456
+ await worker(items[index], index);
457
+ }
458
+ }
459
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, runner));
460
+ }
461
+
462
+ function csvEscape(value) {
463
+ const text = value === null || value === undefined ? "" : String(value);
464
+ return /[",\n]/.test(text) ? '"' + text.replace(/"/g, '""') + '"' : text;
465
+ }
466
+
467
+ function removeUnavailable(value) {
468
+ if (Array.isArray(value)) return value.map(removeUnavailable);
469
+ if (!value || typeof value !== "object") return value;
470
+ const cleaned = {};
471
+ Object.keys(value).forEach(function (key) {
472
+ const item = value[key];
473
+ if (item === null || item === undefined) return;
474
+ cleaned[key] = removeUnavailable(item);
475
+ });
476
+ return cleaned;
477
+ }
478
+
479
+ function indexPortResults(checks) {
480
+ const indexed = {};
481
+ (checks || []).forEach(function (check) {
482
+ indexed[String(check.port)] = check;
483
+ });
484
+ return indexed;
485
+ }
486
+
487
+ function macVendor(mac) {
488
+ const prefix = String(mac || "").replace(/[^0-9a-f]/gi, "").slice(0, 6).toUpperCase();
489
+ return OUI_VENDORS[prefix] || null;
490
+ }
491
+
492
+ function identificationConfidence(device) {
493
+ const mqttConfirmed = device.mqtt && Object.keys(device.mqtt).some(function (port) { return device.mqtt[port] && device.mqtt[port].confirmed; });
494
+ if ((device.modbus && device.modbus.manufacturer) || device.sunspec || mqttConfirmed) return "confirmed";
495
+ if (device.hostname || device.mac_vendor || device.open_ports.length) return "probable";
496
+ return "unknown";
497
+ }
498
+
499
+ function deviceSummary(device) {
500
+ const identity = device.manufacturer || device.mac_vendor || device.hostname || device.ip;
501
+ const model = device.model && device.model !== identity ? device.model : null;
502
+ return [identity, model].concat(device.services || []).filter(Boolean).join(" | ");
503
+ }
504
+
505
+ function categoryCollections(devices, mode) {
506
+ function selected(predicate) { return indexDevices(devices.filter(predicate), mode); }
507
+ return {
508
+ modbus_devices: selected(function (device) { return !!(device.modbus && device.modbus.confirmed); }),
509
+ sunspec_devices: selected(function (device) { return device.sunspec === true; }),
510
+ mqtt_devices: selected(function (device) { return device.services.includes("mqtt"); }),
511
+ web_devices: selected(function (device) { return device.services.includes("web") || device.services.includes("https"); })
512
+ };
513
+ }
514
+
515
+ function formatPayload(device, format) {
516
+ if (format === "influx") {
517
+ return [{ open_port_count: device.open_ports.length, modbus_unit_count: device.modbus ? device.modbus.units.length : 0 }, { ip: device.ip, hostname: device.hostname || "" }];
518
+ }
519
+ if (format === "csv") {
520
+ return [device.ip, device.hostname || "", device.online, device.open_ports.join("|"), device.modbus && device.modbus.confirmed, device.event || ""].map(csvEscape).join(",");
521
+ }
522
+ return removeUnavailable(device);
523
+ }
524
+
525
+ function DiscoverNode(config) {
526
+ RED.nodes.createNode(this, config);
527
+ const node = this;
528
+
529
+ runLicensedNode(node, RED, config, function startLicensedRuntime() {
530
+ node.target = String(config.target || "192.168.1.0/24").trim();
531
+ node.ports = String(config.ports || "502").trim();
532
+ node.scanProfile = String(config.scanProfile || "custom");
533
+ node.detailLevel = String(config.detailLevel || "standard");
534
+ node.timeout = clamp(config.timeout, 100, 10000, 800);
535
+ node.concurrency = clamp(config.concurrency, 1, 100, 32);
536
+ node.rateLimit = clamp(config.rateLimit, 1, 1000, 100);
537
+ node.discoveryMode = String(config.discoveryMode || "tcp");
538
+ node.resolveHostnames = !!config.resolveHostnames;
539
+ node.emitClosed = !!config.emitClosed;
540
+ node.probeModbus = config.probeModbus !== false;
541
+ node.unitIds = String(config.unitIds || "1-10");
542
+ node.allowPublic = !!config.allowPublic;
543
+ node.outputFormat = String(config.outputFormat || "json");
544
+ node.emitMode = String(config.emitMode || "summary_only");
545
+ node.deviceCollection = String(config.deviceCollection || "last_octet");
546
+ node.onlyChanges = !!config.onlyChanges;
547
+ node.scanOnStart = !!config.scanOnStart;
548
+ node.interval = Math.max(0, Number(config.interval) || 0);
549
+ let session = null;
550
+ let timer = null;
551
+ let closing = false;
552
+ const previous = new Map();
553
+
554
+ function cancelScan() {
555
+ if (!session) return;
556
+ session.cancelled = true;
557
+ session.sockets.forEach(function (socket) { socket.destroy(); });
558
+ session.children.forEach(function (child) { try { child.kill(); } catch (error) { /* ignore */ } });
559
+ session.sockets.clear();
560
+ session.children.clear();
561
+ }
562
+
563
+ function signature(device) {
564
+ return JSON.stringify({ online: device.online, open_ports: device.open_ports, modbus: device.modbus });
565
+ }
566
+
567
+ function eventFor(device) {
568
+ const old = previous.get(device.ip);
569
+ const current = signature(device);
570
+ previous.set(device.ip, current);
571
+ if (old === undefined) return device.discovered ? "device_online" : null;
572
+ if (old === current) return null;
573
+ return device.discovered ? "device_changed" : "device_offline";
574
+ }
575
+
576
+ async function scan(msg, send, done) {
577
+ if (msg && msg.cancel) {
578
+ cancelScan();
579
+ node.status({ fill: "yellow", shape: "ring", text: "anulata" });
580
+ if (done) done();
581
+ return;
582
+ }
583
+ if (session) {
584
+ const error = new Error("O scanare este deja in curs");
585
+ if (done) done(error); else node.warn(error.message);
586
+ return;
587
+ }
588
+ const current = { cancelled: false, sockets: new Set(), children: new Set(), nextConnectAt: 0 };
589
+ session = current;
590
+ const startedAt = Date.now();
591
+ try {
592
+ const target = msg.target !== undefined ? msg.target : node.target;
593
+ const requestedProfile = String(msg.scanProfile || node.scanProfile || "custom");
594
+ const requestedDetail = String(msg.detailLevel || node.detailLevel || "standard");
595
+ const defaults = profileDefaults(requestedProfile, requestedDetail, node.ports, node.unitIds, node.probeModbus);
596
+ const ports = parseNumericList(msg.ports !== undefined ? msg.ports : defaults.ports, 1, 65535, MAX_PORTS, "porturi");
597
+ const timeout = clamp(msg.timeout, 100, 10000, node.timeout);
598
+ const concurrency = clamp(msg.concurrency, 1, 100, node.concurrency);
599
+ const unitIds = parseNumericList(msg.unitIds !== undefined ? msg.unitIds : defaults.unitIds, 1, 247, MAX_UNIT_IDS, "Unit ID");
600
+ const probeEnabled = msg.probeModbus !== undefined ? !!msg.probeModbus : defaults.probe;
601
+ const range = rangeFromTarget(target, node.allowPublic);
602
+ const hosts = [];
603
+ for (let address = range.start; address <= range.end; address += 1) hosts.push(numberToIp(address));
604
+ const estimatedTcpDurationMs = Math.ceil((range.count * ports.length / node.rateLimit) * 1000);
605
+ if (range.count > 1024) node.warn("Scanare extinsa: " + range.count + " gazde. Faza TCP dureaza minimum aproximativ " + estimatedTcpDurationMs + " ms; probele pot prelungi scanarea.");
606
+ const discovered = [];
607
+ let completed = 0;
608
+ const rateDelayMs = Math.ceil(1000 / node.rateLimit);
609
+ const usesArp = node.discoveryMode === "arp_tcp" || node.discoveryMode === "full_discovery" || requestedDetail === "detailed";
610
+ const usesPing = node.discoveryMode === "ping_tcp" || node.discoveryMode === "full_discovery" || requestedDetail === "detailed";
611
+ const neighbors = usesArp ? await neighborTable(current) : new Map();
612
+ node.status({ fill: "blue", shape: "dot", text: "0/" + range.count + " · TCP min " + Math.ceil(estimatedTcpDurationMs / 1000) + "s" });
613
+
614
+ await mapLimit(hosts, concurrency, async function (host) {
615
+ if (current.cancelled) return;
616
+ const pingOnline = usesPing ? await pingHost(host, timeout, current) : null;
617
+ const arpOnline = usesArp ? neighbors.has(host) : null;
618
+ const checks = await Promise.all(ports.map(function (port) { return checkPort(host, port, timeout, current, rateDelayMs); }));
619
+ const openPorts = checks.filter(function (item) { return item.status === "open"; }).map(function (item) { return item.port; });
620
+ const online = pingOnline === true || arpOnline === true || openPorts.length > 0 || checks.some(function (item) { return item.status === "refused"; });
621
+ let hostname = null;
622
+ if ((node.resolveHostnames || requestedDetail === "detailed") && online) { try { hostname = (await dns.reverse(host))[0] || null; } catch (error) { hostname = null; } }
623
+ let modbus = null;
624
+ if (probeEnabled && openPorts.includes(502)) modbus = await probeModbus(host, 502, unitIds, timeout, current, defaults.probeMode);
625
+ const mqtt = {};
626
+ for (const mqttPort of openPorts.filter(function (port) { return port === 1883 || port === 8883; })) {
627
+ const mqttResult = await probeMqtt(host, mqttPort, timeout, current);
628
+ if (mqttResult) mqtt[String(mqttPort)] = mqttResult;
629
+ }
630
+ const device = { ip: host, hostname: hostname, mac: neighbors.get(host) || null, online: online, discovered: online, ping: pingOnline, arp_neighbor: arpOnline, open_ports: openPorts, ports: indexPortResults(checks), modbus: modbus, mqtt: mqtt };
631
+ device.event = eventFor(device);
632
+ if (device.discovered || node.emitClosed || device.event === "device_offline") discovered.push(device);
633
+ if (node.emitMode === "device_and_summary" && ((!node.onlyChanges && (device.discovered || node.emitClosed)) || (node.onlyChanges && device.event))) {
634
+ const outputDevice = removeUnavailable(device);
635
+ send([{ topic: "modbus-tcp-discover/device", payload: formatPayload(outputDevice, node.outputFormat), device: outputDevice }, null]);
636
+ }
637
+ completed += 1;
638
+ node.status({ fill: "blue", shape: "dot", text: completed + "/" + range.count + " · " + discovered.filter(function (item) { return item.discovered; }).length + " gasite" });
639
+ });
640
+
641
+ const finalNeighbors = await neighborTable(current);
642
+ discovered.forEach(function (device) {
643
+ const finalMac = finalNeighbors.get(device.ip) || null;
644
+ device.mac = finalMac || device.mac || null;
645
+ if (finalMac) device.arp_neighbor = true;
646
+ device.manufacturer = device.modbus && device.modbus.manufacturer || null;
647
+ device.model = device.modbus && device.modbus.model || null;
648
+ device.firmware = device.modbus && device.modbus.firmware || null;
649
+ device.serial = device.modbus && device.modbus.serial || null;
650
+ device.sunspec = !!(device.modbus && device.modbus.units.some(function (unit) { return unit && unit.protocol === "sunspec"; }));
651
+ device.services = servicesFor(device);
652
+ device.mac_vendor = macVendor(device.mac);
653
+ device.identification_confidence = identificationConfidence(device);
654
+ device.summary = deviceSummary(device);
655
+ });
656
+ const filteredDevices = discovered.filter(function (device) { return matchesProfile(device, requestedProfile); });
657
+ filteredDevices.sort(function (a, b) { return ipToNumber(a.ip) - ipToNumber(b.ip); });
658
+ const onlineHosts = discovered.filter(function (item) { return item.online; }).length;
659
+ const openPortHosts = discovered.filter(function (item) { return item.open_ports.length > 0; }).length;
660
+ const modbusHosts = discovered.filter(function (item) { return item.modbus && item.modbus.confirmed; }).length;
661
+ const cleanDevices = filteredDevices.map(removeUnavailable);
662
+ const categories = categoryCollections(cleanDevices, node.deviceCollection);
663
+ const summary = { status: current.cancelled ? "cancelled" : "complete", target: String(target), scan_profile: requestedProfile, detail_level: requestedDetail, scanned_hosts: completed, checked_ports: ports, online_hosts: onlineHosts, open_port_hosts: openPortHosts, confirmed_modbus_hosts: modbusHosts, discovered_hosts: onlineHosts, detected_devices: filteredDevices.length, device_count: cleanDevices.length, device_index: node.deviceCollection, estimated_tcp_duration_ms: estimatedTcpDurationMs, duration_ms: Date.now() - startedAt, devices: indexDevices(cleanDevices, node.deviceCollection), categories: categories };
664
+ const matchField = {
665
+ sunspec: "detected_sunspec_hosts",
666
+ mqtt: "confirmed_mqtt_hosts",
667
+ web: "detected_web_hosts",
668
+ industrial: "detected_industrial_hosts",
669
+ full: "detected_devices",
670
+ custom: "detected_devices"
671
+ }[requestedProfile];
672
+ if (matchField && !Object.prototype.hasOwnProperty.call(summary, matchField)) summary[matchField] = filteredDevices.length;
673
+ node.status({ fill: current.cancelled ? "yellow" : "green", shape: "dot", text: summary.discovered_hosts + " dispozitive · " + summary.duration_ms + " ms" });
674
+ send([null, { topic: "modbus-tcp-discover/summary", payload: summary }]);
675
+ if (done) done();
676
+ } catch (error) {
677
+ node.status({ fill: "red", shape: "ring", text: "eroare" });
678
+ send([null, { topic: "modbus-tcp-discover/error", payload: { status: "error", error: error.message || String(error) } }]);
679
+ if (done) done(error);
680
+ } finally {
681
+ if (session === current) session = null;
682
+ }
683
+ }
684
+
685
+ function schedule() {
686
+ if (closing || node.interval <= 0) return;
687
+ timer = setTimeout(function () {
688
+ scan({}, node.send.bind(node), function () { schedule(); });
689
+ }, node.interval);
690
+ }
691
+
692
+ node.on("input", function (msg, send, done) { scan(msg || {}, send || node.send.bind(node), done); });
693
+ if (node.scanOnStart) setTimeout(function () { scan({}, node.send.bind(node), function () { schedule(); }); }, 1000);
694
+ else schedule();
695
+ node.on("close", function (done) {
696
+ closing = true;
697
+ if (timer) clearTimeout(timer);
698
+ cancelScan();
699
+ done();
700
+ });
701
+
702
+ });
703
+ }
704
+
705
+ RED.nodes.registerType("modbus-tcp-discover", DiscoverNode);
706
+ };