@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,704 @@
1
+ module.exports = function (RED) {
2
+
3
+ "use strict";
4
+
5
+ const { runLicensedNode } = require("../lib/licensing/node-guard");
6
+
7
+
8
+ const { getByPath } = require("./lib/object-path");
9
+ const { buildRegisterImage, normalizeMetricConfigs, normalizeMetricValue, validateMetricConfigs } = require("./lib/register-map");
10
+ const { buildAggregatorMetrics, SIGNALS, VOLTAGE_PROFILES, normalizeProfile } = require("./lib/aggregator-map");
11
+ const { createModbusServer } = require("./lib/modbus-server");
12
+
13
+ const BIND_HOST = "0.0.0.0";
14
+ const COMMAND_BASE = 128;
15
+ const COMMAND_DEFS = [
16
+ { key: "scada_nominal_active_power", address: 128, type: "float32" },
17
+ { key: "scada_nominal_reactive_power", address: 130, type: "float32" },
18
+ { key: "scada_ramp_type", address: 132, type: "uint16" },
19
+ { key: "scada_ramp_up_value", address: 134, type: "float32" },
20
+ { key: "scada_ramp_down_value", address: 136, type: "float32" }
21
+ ];
22
+ const COMMAND_KEYS = COMMAND_DEFS.map(function (definition) { return definition.key; });
23
+ const runtimeStates = new Map();
24
+ const DEFAULT_AUTO_CATEGORIES = [
25
+ { id: "grid_meter", label: "Grid meter", base: 256, slots: 16 },
26
+ { id: "pv_inverter", label: "PV inverter", base: 768, slots: 64 },
27
+ { id: "bess", label: "BESS", base: 2816, slots: 16 }
28
+ ];
29
+
30
+ function autoCategories(config) {
31
+ let value;
32
+ try { value = typeof config.aggregatorCategories === "string" ? JSON.parse(config.aggregatorCategories) : config.aggregatorCategories; } catch (_) { value = null; }
33
+ const source = Array.isArray(value) && value.length ? value : DEFAULT_AUTO_CATEGORIES;
34
+ return source.map(function (category, index) {
35
+ const id = String(category.id || ("category_" + index)).toLowerCase().replace(/[^a-z0-9_]+/g, "_") || ("category_" + index);
36
+ const base = Math.max(0, Math.round(Number(category.base) || 0));
37
+ const slots = Math.max(2, Math.min(1024, Math.round(Number(category.slots) || 16)));
38
+ return { id: id, label: String(category.label || id), base: base, slots: slots };
39
+ });
40
+ }
41
+
42
+
43
+ function defaultMetrics() {
44
+
45
+ return [
46
+
47
+ { key: "p", label: "P", address: 0, type: "int32", scale: 1, path: "payload.active_power_w", unit: "W", signMode: "as_is" },
48
+ { key: "q", label: "Q", address: 2, type: "int32", scale: 1, path: "payload.reactive_power_var", unit: "VAr", signMode: "as_is" },
49
+ { key: "f", label: "F", address: 4, type: "uint32", scale: 100, path: "payload.frequency_hz", unit: "Hz" },
50
+ { key: "u12", label: "U12", address: 6, type: "uint32", scale: 10, path: "payload.voltage_l1_l2_v", unit: "V" },
51
+ { key: "u23", label: "U23", address: 8, type: "uint32", scale: 10, path: "payload.voltage_l2_l3_v", unit: "V" },
52
+ { key: "u13", label: "U13", address: 10, type: "uint32", scale: 10, path: "payload.voltage_l1_l3_v", unit: "V" },
53
+ { key: "cef_pos", label: "CEF", address: 12, type: "uint16", scale: 1, path: "payload.cef_breaker_position", unit: "" },
54
+ { key: "bess_soc", label: "SOC", address: 13, type: "uint16", scale: 10, path: "payload.bess_soc_pct", unit: "%" },
55
+ { key: "bess_p", label: "BP", address: 14, type: "int32", scale: 1, path: "payload.bess_power_w", unit: "W", signMode: "as_is" },
56
+ { key: "bess_ext", label: "BEX", address: 16, type: "uint16", scale: 1, path: "payload.bess_external_control", unit: "" }
57
+
58
+ ];
59
+
60
+ }
61
+
62
+
63
+ function toIntInRange(value, fallback, min, max) {
64
+
65
+ const numeric = Number(value);
66
+
67
+ if (!Number.isFinite(numeric)) {
68
+ return fallback;
69
+ }
70
+
71
+ return Math.min(max, Math.max(min, Math.round(numeric)));
72
+
73
+ }
74
+
75
+
76
+ function cleanHost(value) {
77
+
78
+ const host = String(value || "").trim();
79
+
80
+ return host || BIND_HOST;
81
+
82
+ }
83
+
84
+
85
+ function buildAddressingInfo(addressingMode) {
86
+
87
+ const normalized = addressingMode === "modbus_40001" ? "modbus_40001" : "zero_based";
88
+ const offset = normalized === "modbus_40001" ? 40001 : 0;
89
+
90
+ return {
91
+ mode: normalized,
92
+ docs_offset: offset,
93
+ hint: normalized === "modbus_40001"
94
+ ? "Documentatia afiseaza adrese 40001+, protocolul Modbus TCP foloseste offset 0 in request."
95
+ : "Documentatia foloseste aceleasi adrese ca request-ul Modbus TCP (offset 0)."
96
+ };
97
+
98
+ }
99
+
100
+
101
+ function ScadaServerNode(config) {
102
+
103
+ RED.nodes.createNode(this, config);
104
+
105
+ const node = this;
106
+
107
+ runLicensedNode(node, RED, config, function startLicensedRuntime() {
108
+
109
+ node.name = "SCADA Server";
110
+ node.host = cleanHost(config.host || BIND_HOST);
111
+ node.port = toIntInRange(config.port, 1502, 1, 65535);
112
+ node.unitId = toIntInRange(config.unitId, 1, 0, 255);
113
+ node.plantId = String(config.plantId || "").trim();
114
+ node.publishIntervalMs = toIntInRange(config.publishIntervalMs, 500, 100, 60000);
115
+ node.publishOnInput = !!config.publishOnInput;
116
+ node.maxReadLogEntries = toIntInRange(config.maxReadLogEntries, 100, 10, 2000);
117
+ node.maxConnections = toIntInRange(config.maxConnections, 20, 1, 2000);
118
+ node.socketIdleTimeoutMs = toIntInRange(config.socketIdleTimeoutMs, 30000, 500, 3600000);
119
+ node.rateLimitPerSecond = toIntInRange(config.rateLimitPerSecond, 0, 0, 10000);
120
+ node.diagIntervalMs = toIntInRange(config.diagIntervalMs, 2000, 500, 60000);
121
+ node.outOfRangeMode = config.outOfRangeMode === "exception" ? "exception" : "zeros";
122
+ node.addressingMode = String(config.addressingMode || "zero_based").trim();
123
+ node.addressingInfo = buildAddressingInfo(node.addressingMode);
124
+ node.mapMode = config.mapMode === "aggregator_auto" ? "aggregator_auto" : (config.mapMode === "aggregator" ? "aggregator" : "direct");
125
+ node.commandBase = node.mapMode === "aggregator_auto" ? 0 : COMMAND_BASE;
126
+ node.aggregatorPrefix = String(config.aggregatorPrefix || "payload.scada").trim().replace(/\.+$/, "") || "payload.scada";
127
+ node.aggregatorVoltageProfile = normalizeProfile(config.aggregatorVoltageProfile);
128
+ const savedAutoMetrics = node.mapMode === "aggregator_auto" && node.context ? node.context().get("scadaAutoMetrics") : null;
129
+
130
+ node.metrics = normalizeMetricConfigs(node.mapMode === "aggregator_auto" ? (Array.isArray(savedAutoMetrics) ? savedAutoMetrics : []) : (node.mapMode === "aggregator"
131
+ ? buildAggregatorMetrics(config)
132
+ : (config.metrics && config.metrics.length ? config.metrics : defaultMetrics())));
133
+ node.validationIssues = validateMetricConfigs(node.metrics);
134
+ if (node.mapMode === "aggregator_auto" && !node.metrics.length) node.validationIssues = [];
135
+
136
+ let liveValues = {};
137
+ let pendingValues = {};
138
+ let registerImage = new Uint16Array(1);
139
+ let lastUpdateTs = null;
140
+ let lastPublishTs = null;
141
+ let clientCount = 0;
142
+ let readLog = [];
143
+ let publishTimer = null;
144
+ let diagnosticsTimer = null;
145
+ let startedAtMs = Date.now();
146
+ let lastClientConnected = null;
147
+ let lastClientDisconnected = null;
148
+ let lastRead = null;
149
+ let lastEncodingIssues = [];
150
+ let autoCategoryList = node.mapMode === "aggregator_auto" ? (node.context().get("scadaAutoCategories") || autoCategories(config)) : [];
151
+ let autoSlots = node.mapMode === "aggregator_auto" ? (node.context().get("scadaAutoSlots") || []) : [];
152
+ const readTimelineMs = [];
153
+ const protocolIssues = {};
154
+ const nodeContext = node.context();
155
+ const globalContext = nodeContext && nodeContext.global && typeof nodeContext.global.get === "function"
156
+ ? nodeContext.global
157
+ : (nodeContext && typeof nodeContext.global === "function" ? nodeContext.global() : null);
158
+ let commandWords = new Uint16Array(10);
159
+
160
+ function commandValue(key) { const value = Number(globalContext && globalContext.get ? globalContext.get(key) : 0); return Number.isFinite(value) ? value : 0; }
161
+ function setCommandValue(key, value) { if (globalContext && globalContext.set) globalContext.set(key, value); }
162
+ function initializeCommandGlobals() {
163
+ if (!globalContext || typeof globalContext.get !== "function" || typeof globalContext.set !== "function") {
164
+ node.warn("scada-server: contextul global Node-RED nu este disponibil; registrele R/W folosesc valoarea temporară 0.");
165
+ return;
166
+ }
167
+ COMMAND_KEYS.forEach(function (key) {
168
+ if (typeof globalContext.get(key) === "undefined") globalContext.set(key, 0);
169
+ });
170
+ }
171
+ function writeFloat(words, offset, value) { const buffer = Buffer.allocUnsafe(4); buffer.writeFloatBE(value, 0); words[offset] = buffer.readUInt16BE(0); words[offset + 1] = buffer.readUInt16BE(2); }
172
+ function readFloat(words, offset) { const buffer = Buffer.allocUnsafe(4); buffer.writeUInt16BE(words[offset], 0); buffer.writeUInt16BE(words[offset + 1], 2); return buffer.readFloatBE(0); }
173
+ function syncCommandRegisters() {
174
+ const next = new Uint16Array(10);
175
+ writeFloat(next, 0, commandValue("scada_nominal_active_power")); writeFloat(next, 2, commandValue("scada_nominal_reactive_power"));
176
+ next[4] = Math.max(0, Math.min(65535, Math.round(commandValue("scada_ramp_type"))));
177
+ writeFloat(next, 6, commandValue("scada_ramp_up_value")); writeFloat(next, 8, commandValue("scada_ramp_down_value"));
178
+ commandWords = next;
179
+ }
180
+ function applyCommandWrite() {
181
+ setCommandValue("scada_nominal_active_power", readFloat(commandWords, 0)); setCommandValue("scada_nominal_reactive_power", readFloat(commandWords, 2));
182
+ setCommandValue("scada_ramp_type", commandWords[4]); setCommandValue("scada_ramp_up_value", readFloat(commandWords, 6)); setCommandValue("scada_ramp_down_value", readFloat(commandWords, 8));
183
+ syncCommandRegisters(); registerImage = rebuildRegisterImage();
184
+ node.send([{ topic: "scada-server/commands", payload: { scada_nominal_active_power: commandValue("scada_nominal_active_power"), scada_nominal_reactive_power: commandValue("scada_nominal_reactive_power"), scada_ramp_type: commandValue("scada_ramp_type"), scada_ramp_up_value: commandValue("scada_ramp_up_value"), scada_ramp_down_value: commandValue("scada_ramp_down_value") } }, null]);
185
+ }
186
+
187
+ function incrementIssueCounter(code) {
188
+ const key = String(code || "unknown");
189
+ protocolIssues[key] = (protocolIssues[key] || 0) + 1;
190
+ }
191
+
192
+ function rebuildRegisterImage() {
193
+ const issues = [];
194
+ const image = buildRegisterImage(node.metrics, liveValues, function (issue) {
195
+ issues.push(issue);
196
+ incrementIssueCounter(issue.code);
197
+ });
198
+ lastEncodingIssues = issues.slice(-50);
199
+ const result = image.length >= (node.commandBase + commandWords.length) ? image : new Uint16Array(node.commandBase + commandWords.length);
200
+ if (result !== image) result.set(image);
201
+ result.set(commandWords, node.commandBase);
202
+ return result;
203
+ }
204
+
205
+ initializeCommandGlobals();
206
+ syncCommandRegisters();
207
+ registerImage = rebuildRegisterImage();
208
+
209
+ function trimReadTimeline(nowMs) {
210
+ while (readTimelineMs.length && (nowMs - readTimelineMs[0]) > 1000) {
211
+ readTimelineMs.shift();
212
+ }
213
+ }
214
+
215
+ function reqPerSecond() {
216
+ const now = Date.now();
217
+ trimReadTimeline(now);
218
+ return Number((readTimelineMs.length).toFixed(1));
219
+ }
220
+
221
+ function appendReadLog(entry) {
222
+
223
+ readLog.unshift(entry);
224
+ if (readLog.length > node.maxReadLogEntries) {
225
+ readLog = readLog.slice(0, node.maxReadLogEntries);
226
+ }
227
+
228
+ }
229
+
230
+ function buildDiagnosticsPayload(reason) {
231
+
232
+ return {
233
+ status: node.validationIssues.length ? "invalid_configuration" : "ok",
234
+ reason: reason || "snapshot",
235
+ uptime_ms: Math.max(0, Date.now() - startedAtMs),
236
+ plant_id: node.plantId || null,
237
+ bind_host: node.host,
238
+ port: node.port,
239
+ unit_id: node.unitId,
240
+ clients: clientCount,
241
+ req_per_sec: reqPerSecond(),
242
+ last_read: lastRead,
243
+ last_client_connected: lastClientConnected,
244
+ last_client_disconnected: lastClientDisconnected,
245
+ protocol_issue_counters: Object.assign({}, protocolIssues),
246
+ hardening: {
247
+ max_connections: node.maxConnections,
248
+ socket_idle_timeout_ms: node.socketIdleTimeoutMs,
249
+ rate_limit_per_sec: node.rateLimitPerSecond,
250
+ out_of_range_mode: node.outOfRangeMode,
251
+ publish_on_input: node.publishOnInput
252
+ },
253
+ addressing: node.addressingInfo,
254
+ read_log_size: readLog.length,
255
+ metrics_count: COMMAND_DEFS.length + node.metrics.length,
256
+ map_mode: node.mapMode,
257
+ register_count: registerImage.length,
258
+ encoding_issues: lastEncodingIssues,
259
+ issues: node.validationIssues
260
+ };
261
+
262
+ }
263
+
264
+ function diagnosticsMessage(reason) {
265
+ return {
266
+ topic: "scada-server/diagnostics",
267
+ payload: buildDiagnosticsPayload(reason)
268
+ };
269
+ }
270
+
271
+ function commandValues() {
272
+ return COMMAND_KEYS.reduce(function (values, key) {
273
+ values[key] = commandValue(key);
274
+ return values;
275
+ }, {});
276
+ }
277
+
278
+ function commandRegisters() {
279
+ return COMMAND_DEFS.map(function (definition) {
280
+ return {
281
+ key: definition.key,
282
+ label: "SCADA command · " + definition.key,
283
+ address: node.commandBase + (definition.address - COMMAND_BASE),
284
+ type: definition.type,
285
+ scale: 1,
286
+ path: "global." + definition.key,
287
+ unit: "",
288
+ value: commandValue(definition.key),
289
+ active: true,
290
+ access: "R/W",
291
+ last_update_ts: null,
292
+ last_publish_ts: lastPublishTs
293
+ };
294
+ });
295
+ }
296
+
297
+ function refreshStatus() {
298
+
299
+ if (node.validationIssues.length) {
300
+
301
+ node.status({
302
+ fill: "red",
303
+ shape: "ring",
304
+ text: "config invalida | " + node.validationIssues.length + " probleme"
305
+ });
306
+
307
+ return;
308
+
309
+ }
310
+
311
+ const rps = reqPerSecond();
312
+ const lastReadText = lastRead ? (lastRead.startAddress + "/" + lastRead.quantity) : "-";
313
+
314
+ node.status({
315
+ fill: "green",
316
+ shape: "dot",
317
+ text: "p " + node.port + " | c " + clientCount + " | rps " + rps + " | last " + lastReadText
318
+ });
319
+
320
+ }
321
+
322
+ function commitSnapshot(force) {
323
+
324
+ const pendingKeys = Object.keys(pendingValues);
325
+ const changed = force || pendingKeys.length > 0;
326
+
327
+ syncCommandRegisters();
328
+ registerImage = rebuildRegisterImage();
329
+ if (!changed) {
330
+ return false;
331
+ }
332
+
333
+ liveValues = Object.assign({}, liveValues, pendingValues);
334
+ pendingValues = {};
335
+ lastPublishTs = Date.now();
336
+ registerImage = rebuildRegisterImage();
337
+ refreshStatus();
338
+ return true;
339
+
340
+ }
341
+
342
+ function updateRegistersFromMessage(msg) {
343
+
344
+ let changed = false;
345
+
346
+ node.metrics.forEach(function (metric) {
347
+
348
+ const rawValue = getByPath(msg, metric.path);
349
+ const nextValue = rawValue === null || rawValue === undefined ? NaN : Number(rawValue);
350
+
351
+ if (Number.isFinite(nextValue)) {
352
+ pendingValues[metric.key] = normalizeMetricValue(metric, nextValue);
353
+ changed = true;
354
+ }
355
+
356
+ });
357
+
358
+ if (changed) {
359
+ lastUpdateTs = Date.now();
360
+ }
361
+
362
+ return changed;
363
+
364
+ }
365
+
366
+ function rebuildAutoMetrics(signals) {
367
+ const metrics = [];
368
+ const addBlock = function (category, source, slot, total) {
369
+ const base = total ? category.base : category.base + (slot * 32);
370
+ const prefix = total ? "total_" + category.id : category.id + "_" + source;
371
+ const path = total ? node.aggregatorPrefix + ".totals." + category.id : node.aggregatorPrefix + "." + category.id + "." + source;
372
+ signals.forEach(function (signal, index) { const info = SIGNALS[signal]; metrics.push({ key: prefix + "_" + signal, label: (total ? "Total " : "") + category.label + " " + (source || "") + " " + info.label, address: base + (index * 2), type: "float32", scale: 1, path: path + "." + signal, unit: info.unit, signMode: "as_is", byteOrder: "big_endian", wordOrder: "msw_first" }); });
373
+ metrics.push({ key: prefix + "_" + (total ? "active" : "online"), label: prefix + " " + (total ? "active" : "online"), address: base + 28, type: "uint16", scale: 1, path: path + "." + (total ? "active" : "online"), unit: "", signMode: "as_is", byteOrder: "big_endian", wordOrder: "msw_first" });
374
+ if (!total) metrics.push({ key: prefix + "_age_s", label: prefix + " age", address: base + 29, type: "uint16", scale: 1, path: path + ".age_s", unit: "s", signMode: "as_is", byteOrder: "big_endian", wordOrder: "msw_first" });
375
+ };
376
+ const addSiteSummary = function () {
377
+ ["grid", "pv"].forEach(function (reference, referenceIndex) {
378
+ signals.forEach(function (signal, signalIndex) {
379
+ const info = SIGNALS[signal];
380
+ metrics.push({ key: "site_" + reference + "_" + signal, label: "Site " + reference.toUpperCase() + " " + info.label, address: 64 + (referenceIndex * 24) + (signalIndex * 2), type: "float32", scale: 1, path: node.aggregatorPrefix + ".totals.site." + reference + "." + signal, unit: info.unit, signMode: "as_is", byteOrder: "big_endian", wordOrder: "msw_first" });
381
+ });
382
+ metrics.push({ key: "site_" + reference + "_active", label: "Site " + reference.toUpperCase() + " active", address: 124 + referenceIndex, type: "uint16", scale: 1, path: node.aggregatorPrefix + ".totals.site." + reference + ".active", unit: "", signMode: "as_is", byteOrder: "big_endian", wordOrder: "msw_first" });
383
+ });
384
+ };
385
+ addSiteSummary();
386
+ autoCategoryList.forEach(function (category) { addBlock(category, "", 0, true); });
387
+ autoSlots.forEach(function (item) { const category = autoCategoryList.find(function (value) { return value.id === item.category; }); if (category) addBlock(category, item.source, item.slot, false); });
388
+ node.metrics = normalizeMetricConfigs(metrics);
389
+ node.validationIssues = validateMetricConfigs(node.metrics);
390
+ node.context().set("scadaAutoMetrics", node.metrics);
391
+ node.context().set("scadaAutoSlots", autoSlots);
392
+ node.context().set("scadaAutoCategories", autoCategoryList);
393
+ }
394
+
395
+ if (node.mapMode === "aggregator_auto" && autoSlots.length) {
396
+ rebuildAutoMetrics(["p", "q"].concat(VOLTAGE_PROFILES[node.aggregatorVoltageProfile], ["f"]));
397
+ }
398
+
399
+ function discoverAggregatorSources(msg) {
400
+ if (node.mapMode !== "aggregator_auto") return false;
401
+ const parts = node.aggregatorPrefix.split(".");
402
+ let root = msg;
403
+ parts.forEach(function (part) { root = root == null ? undefined : root[part]; });
404
+ if (!root || typeof root !== "object") return false;
405
+ const signals = ["p", "q"].concat(VOLTAGE_PROFILES[node.aggregatorVoltageProfile], ["f"]);
406
+ let changed = false;
407
+ Object.keys(root).filter(function (group) { return group !== "totals"; }).forEach(function (group) {
408
+ const sources = root[group];
409
+ if (!sources || typeof sources !== "object") return;
410
+ Object.keys(sources).forEach(function (source) {
411
+ if (!sources[source] || typeof sources[source] !== "object") return;
412
+ const key = group + "." + source;
413
+ if (autoSlots.some(function (item) { return item.key === key; })) return;
414
+ let category = autoCategoryList.find(function (item) { return item.id === group; });
415
+ if (!category) {
416
+ const nextBase = Math.ceil(autoCategoryList.reduce(function (max, item) { return Math.max(max, item.base + (item.slots * 32)); }, 3328) / 512) * 512;
417
+ category = { id: group, label: group, base: nextBase, slots: 16 };
418
+ autoCategoryList.push(category);
419
+ }
420
+ const used = new Set(autoSlots.filter(function (item) { return item.category === category.id; }).map(function (item) { return item.slot; }));
421
+ let slot = 1;
422
+ while (used.has(slot) && slot < category.slots) slot += 1;
423
+ if (slot >= category.slots) { node.warn("scada-server: categoria " + category.id + " este plina"); return; }
424
+ autoSlots.push({ key: key, group: group, source: source, category: category.id, slot: slot });
425
+ changed = true;
426
+ });
427
+ });
428
+ if (changed) rebuildAutoMetrics(signals);
429
+ return changed;
430
+ }
431
+
432
+ function buildStatePayload(changed) {
433
+
434
+ return {
435
+ status: "ok",
436
+ plant_id: node.plantId || null,
437
+ host: node.host,
438
+ port: node.port,
439
+ unit_id: node.unitId,
440
+ clients: clientCount,
441
+ req_per_sec: reqPerSecond(),
442
+ last_read: lastRead,
443
+ updated: changed,
444
+ last_update_ts: lastUpdateTs,
445
+ last_publish_ts: lastPublishTs,
446
+ uptime_ms: Math.max(0, Date.now() - startedAtMs),
447
+ values_live: Object.assign({}, commandValues(), liveValues),
448
+ values_pending: Object.assign({}, pendingValues),
449
+ rw_registers: commandRegisters(),
450
+ metrics: commandRegisters().concat(node.metrics),
451
+ issues: node.validationIssues,
452
+ read_log: readLog,
453
+ diagnostics: {
454
+ last_client_connected: lastClientConnected,
455
+ last_client_disconnected: lastClientDisconnected,
456
+ protocol_issue_counters: Object.assign({}, protocolIssues)
457
+ },
458
+ hardening: {
459
+ max_connections: node.maxConnections,
460
+ socket_idle_timeout_ms: node.socketIdleTimeoutMs,
461
+ rate_limit_per_sec: node.rateLimitPerSecond,
462
+ out_of_range_mode: node.outOfRangeMode,
463
+ publish_on_input: node.publishOnInput
464
+ },
465
+ encoding_issues: lastEncodingIssues,
466
+ addressing: node.addressingInfo
467
+ };
468
+
469
+ }
470
+
471
+ function buildRegisterExport(activeOnly) {
472
+ const hasValue = function (key) { return Object.prototype.hasOwnProperty.call(liveValues, key); };
473
+ const commandRows = commandRegisters();
474
+ const rows = commandRows.concat(node.metrics.filter(function (metric) { return !activeOnly || hasValue(metric.key); }).map(function (metric) {
475
+ return {
476
+ key: metric.key,
477
+ label: metric.label,
478
+ address: metric.address,
479
+ type: metric.type,
480
+ scale: metric.scale,
481
+ path: metric.path,
482
+ unit: metric.unit,
483
+ value: hasValue(metric.key) ? liveValues[metric.key] : null,
484
+ active: hasValue(metric.key), access: "R",
485
+ last_update_ts: lastUpdateTs,
486
+ last_publish_ts: lastPublishTs
487
+ };
488
+ }));
489
+ return { node_id: node.id, generated_at: new Date().toISOString(), active_only: !!activeOnly, map_mode: node.mapMode, rows: rows };
490
+ }
491
+
492
+ node.getRegisterExport = buildRegisterExport;
493
+ runtimeStates.set(node.id, node);
494
+
495
+ function emitDiagnostics(reason) {
496
+ node.send([null, diagnosticsMessage(reason)]);
497
+ }
498
+
499
+ const server = createModbusServer({
500
+
501
+ host: node.host,
502
+ port: node.port,
503
+ unitId: node.unitId,
504
+ maxConnections: node.maxConnections,
505
+ socketIdleTimeoutMs: node.socketIdleTimeoutMs,
506
+ rateLimitPerSecond: node.rateLimitPerSecond,
507
+
508
+ readHoldingRegisters: function (startAddress, quantity) {
509
+
510
+ const result = [];
511
+ const endAddress = startAddress + quantity - 1;
512
+
513
+ if (startAddress < 0 || endAddress >= registerImage.length) {
514
+ if (node.outOfRangeMode === "exception") {
515
+ return {
516
+ exceptionCode: 2,
517
+ issueCode: "read_out_of_range"
518
+ };
519
+ }
520
+ incrementIssueCounter("read_out_of_range");
521
+ }
522
+
523
+ for (let offset = 0; offset < quantity; offset += 1) {
524
+ const address = startAddress + offset;
525
+ result.push(address >= 0 && address < registerImage.length ? registerImage[address] : 0);
526
+ }
527
+
528
+ return result;
529
+
530
+ },
531
+
532
+ writeSingleRegister: function (address, value) {
533
+ return this.writeHoldingRegisters(address, [value]);
534
+ },
535
+
536
+ writeHoldingRegisters: function (startAddress, values) {
537
+ const endAddress = startAddress + values.length - 1;
538
+ if (startAddress < node.commandBase || endAddress >= (node.commandBase + commandWords.length)) return { exceptionCode: 2, issueCode: "write_read_only" };
539
+ values.forEach(function (value, index) { commandWords[startAddress - node.commandBase + index] = value & 0xFFFF; });
540
+ applyCommandWrite();
541
+ return null;
542
+ },
543
+
544
+ onClientChange: function (nextCount) {
545
+ clientCount = nextCount;
546
+ refreshStatus();
547
+ },
548
+
549
+ onClientConnected: function (entry) {
550
+ lastClientConnected = entry;
551
+ refreshStatus();
552
+ },
553
+
554
+ onClientDisconnected: function (entry) {
555
+ lastClientDisconnected = entry;
556
+ refreshStatus();
557
+ },
558
+
559
+ onRead: function (entry) {
560
+ lastRead = entry;
561
+ readTimelineMs.push(entry.timestampMs || Date.now());
562
+ trimReadTimeline(Date.now());
563
+ appendReadLog(entry);
564
+ refreshStatus();
565
+ },
566
+
567
+ onProtocolIssue: function (issue) {
568
+ incrementIssueCounter(issue.code);
569
+ },
570
+
571
+ onError: function (err) {
572
+ incrementIssueCounter("server_error");
573
+ node.error(err.message);
574
+ node.status({ fill: "red", shape: "ring", text: "server error" });
575
+ }
576
+
577
+ });
578
+
579
+
580
+ if (node.validationIssues.length) {
581
+
582
+ node.error("scada-server: configuratie invalida: " + node.validationIssues.map(function (issue) {
583
+ return issue.message;
584
+ }).join(" | "));
585
+
586
+ refreshStatus();
587
+
588
+ } else {
589
+
590
+ server.start().then(function () {
591
+
592
+ startedAtMs = Date.now();
593
+
594
+ publishTimer = setInterval(function () {
595
+ commitSnapshot(false);
596
+ }, node.publishIntervalMs);
597
+
598
+ diagnosticsTimer = setInterval(function () {
599
+ emitDiagnostics("interval");
600
+ }, node.diagIntervalMs);
601
+
602
+ refreshStatus();
603
+
604
+ }).catch(function (err) {
605
+
606
+ incrementIssueCounter("bind_error");
607
+ node.error(err.message);
608
+ node.status({ fill: "red", shape: "ring", text: "bind error" });
609
+
610
+ });
611
+
612
+ }
613
+
614
+
615
+ node.on("input", function (msg, send, done) {
616
+
617
+ try {
618
+
619
+ if (node.validationIssues.length) {
620
+
621
+ send([
622
+ {
623
+ topic: "scada-server/state",
624
+ payload: {
625
+ status: "invalid_configuration",
626
+ issues: node.validationIssues
627
+ }
628
+ },
629
+ diagnosticsMessage("invalid_configuration")
630
+ ]);
631
+
632
+ done(new Error("scada-server: configuratie invalida"));
633
+ return;
634
+
635
+ }
636
+
637
+ discoverAggregatorSources(msg);
638
+ const changed = updateRegistersFromMessage(msg);
639
+
640
+ if (
641
+ (node.publishOnInput && changed) ||
642
+ (msg && (msg.scadaFlush === true || (msg.payload && msg.payload.scada_flush === true)))
643
+ ) {
644
+ commitSnapshot(true);
645
+ }
646
+
647
+ send([
648
+ {
649
+ topic: "scada-server/state",
650
+ payload: buildStatePayload(changed)
651
+ },
652
+ diagnosticsMessage("input")
653
+ ]);
654
+
655
+ done();
656
+
657
+ } catch (err) {
658
+
659
+ incrementIssueCounter("input_error");
660
+ node.status({ fill: "red", shape: "ring", text: err.message });
661
+ done(err);
662
+
663
+ }
664
+
665
+ });
666
+
667
+
668
+ node.on("close", function (done) {
669
+
670
+ if (publishTimer) {
671
+ clearInterval(publishTimer);
672
+ publishTimer = null;
673
+ }
674
+
675
+ if (diagnosticsTimer) {
676
+ clearInterval(diagnosticsTimer);
677
+ diagnosticsTimer = null;
678
+ }
679
+
680
+ server.stop().finally(done);
681
+ runtimeStates.delete(node.id);
682
+
683
+ });
684
+
685
+
686
+ });
687
+ }
688
+
689
+
690
+ if (RED.httpAdmin && typeof RED.httpAdmin.get === "function") {
691
+ RED.httpAdmin.get("/scada-server/register-map/:id", function (req, res) {
692
+ const node = runtimeStates.get(req.params.id);
693
+ if (!node || typeof node.getRegisterExport !== "function") {
694
+ res.status(404).json({ ok: false, error: "SCADA server runtime indisponibil" });
695
+ return;
696
+ }
697
+ const activeOnly = String(req.query && req.query.activeOnly || "").toLowerCase() !== "false";
698
+ res.json(Object.assign({ ok: true }, node.getRegisterExport(activeOnly)));
699
+ });
700
+ }
701
+
702
+ RED.nodes.registerType("scada-server", ScadaServerNode);
703
+
704
+ };