@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,2178 @@
1
+ "use strict";
2
+
3
+ const assert = require("assert/strict");
4
+ const fs = require("fs");
5
+ const net = require("net");
6
+ const EventEmitter = require("events");
7
+
8
+ const {
9
+ atLeast,
10
+ clampNumber,
11
+ roundedAtLeast,
12
+ toFiniteNumber
13
+ } = require("../lib/numbers");
14
+ const {
15
+ buildSummary
16
+ } = require("../fronius-read/lib/sunspec-model-parsers");
17
+ const {
18
+ encodeMetric,
19
+ encodeMetricDetailed
20
+ } = require("../scada-server/lib/encoding");
21
+ const {
22
+ buildRegisterImage,
23
+ validateMetricConfigs
24
+ } = require("../scada-server/lib/register-map");
25
+ const {
26
+ buildAggregatorMetrics
27
+ } = require("../scada-server/lib/aggregator-map");
28
+ const {
29
+ normalizeMessage: normalizeScadaAggregateMessage,
30
+ summarize: summarizeScadaValues
31
+ } = require("../scada-aggregator/lib/aggregate");
32
+ const {
33
+ createModbusServer
34
+ } = require("../scada-server/lib/modbus-server");
35
+ const {
36
+ decodePayload,
37
+ detectPortalId,
38
+ mergeDecodedMessage,
39
+ pruneStale
40
+ } = require("../victron-read/lib/mqtt-state");
41
+ const {
42
+ evaluateRuntimeBatteries
43
+ } = require("../bess-control/lib/runtime");
44
+ const {
45
+ distributionIsValid,
46
+ normalizeBatteries
47
+ } = require("../bess-control/lib/batteries");
48
+ const {
49
+ ENERGY_COUNT,
50
+ INSTANTANEOUS_COUNT,
51
+ decodeFloat32,
52
+ parseEemMa370
53
+ } = require("../phoenix-contact/lib/eem-ma370");
54
+ const {
55
+ aggregate: aggregatePhoenixFields,
56
+ buildInfluxFields: buildPhoenixInfluxFields,
57
+ selectFields: selectPhoenixFields
58
+ } = require("../phoenix-contact/lib/integrations");
59
+ const {
60
+ parseAdvancedBlocks,
61
+ requiredBlocks: requiredPhoenixBlocks
62
+ } = require("../phoenix-contact/lib/eem-ma370-advanced");
63
+ const {
64
+ readHolding: readSmaSunSpecHolding,
65
+ scanModelDescriptors: scanSmaModelDescriptors
66
+ } = require("../sma-read/lib/sunspec-core");
67
+
68
+ const tests = [];
69
+ let activeRuntimeNodes = [];
70
+
71
+ function test(name, fn) {
72
+ tests.push({ name, fn });
73
+ }
74
+
75
+ function createRuntimeNode(modulePath, typeName, config) {
76
+ let RegisteredNode = null;
77
+ const runtimeConfig = Object.assign({}, config || {});
78
+ const initialLocalContext = runtimeConfig.__localContext || {};
79
+ const initialGlobalContext = runtimeConfig.__globalContext || {};
80
+ const runtimeNodes = Object.assign({}, runtimeConfig.__runtimeNodes || {});
81
+ delete runtimeConfig.__localContext;
82
+ delete runtimeConfig.__globalContext;
83
+ delete runtimeConfig.__runtimeNodes;
84
+ if (!runtimeConfig.license) runtimeConfig.license = "__test_license";
85
+ runtimeNodes.__test_license = runtimeNodes.__test_license || {
86
+ getState: function () { return "ACTIVE"; },
87
+ isLicensed: function () { return true; },
88
+ hasFeature: function () { return true; },
89
+ on: function () {},
90
+ removeListener: function () {}
91
+ };
92
+
93
+ const localStore = new Map();
94
+ Object.keys(initialLocalContext).forEach(function (key) {
95
+ localStore.set(key, initialLocalContext[key]);
96
+ });
97
+
98
+ const globalStore = new Map();
99
+ Object.keys(initialGlobalContext).forEach(function (key) {
100
+ globalStore.set(key, initialGlobalContext[key]);
101
+ });
102
+ const RED = {
103
+ util: {
104
+ cloneMessage: function (msg) {
105
+ return JSON.parse(JSON.stringify(msg));
106
+ }
107
+ },
108
+ nodes: {
109
+ createNode: function (node) {
110
+ EventEmitter.call(node);
111
+ Object.setPrototypeOf(node, EventEmitter.prototype);
112
+ node.id = node.id || typeName + "-test";
113
+ node.status = function (status) { node.lastStatus = status; };
114
+ node.warn = function () {};
115
+ node.debug = function () {};
116
+ node.error = function (error) { throw error; };
117
+ node.sentHistory = [];
118
+ node.send = function (outputs) {
119
+ node.sent = outputs;
120
+ node.sentHistory.push(outputs);
121
+ };
122
+ node.context = function () {
123
+ return {
124
+ get: function (key) { return localStore.get(key); },
125
+ set: function (key, value) { localStore.set(key, value); },
126
+ global: {
127
+ get: function (key) { return globalStore.get(key); },
128
+ set: function (key, value) { globalStore.set(key, value); }
129
+ }
130
+ };
131
+ };
132
+ },
133
+ registerType: function (name, constructor) {
134
+ if (name === typeName) RegisteredNode = constructor;
135
+ },
136
+ getNode: function (id) {
137
+ return runtimeNodes[id] || null;
138
+ }
139
+ }
140
+ };
141
+
142
+ require(modulePath)(RED);
143
+ const node = new RegisteredNode(runtimeConfig);
144
+ activeRuntimeNodes.push(node);
145
+ return node;
146
+ }
147
+
148
+ async function closeRuntimeNodes() {
149
+ const nodes = activeRuntimeNodes.splice(0);
150
+
151
+ for (const node of nodes.reverse()) {
152
+ if (!node || typeof node.listeners !== "function") {
153
+ continue;
154
+ }
155
+
156
+ const listeners = node.listeners("close");
157
+ if (!listeners.length) {
158
+ continue;
159
+ }
160
+
161
+ node.removeAllListeners("close");
162
+
163
+ for (const listener of listeners) {
164
+ await new Promise(function (resolve) {
165
+ let settled = false;
166
+ function done() {
167
+ if (!settled) {
168
+ settled = true;
169
+ resolve();
170
+ }
171
+ }
172
+
173
+ try {
174
+ if (listener.length >= 2) {
175
+ listener.call(node, false, done);
176
+ } else {
177
+ listener.call(node, done);
178
+ }
179
+ } catch (error) {
180
+ done();
181
+ }
182
+ });
183
+ }
184
+ }
185
+ }
186
+
187
+ test("toFiniteNumber returns fallback for invalid input", function () {
188
+ assert.equal(toFiniteNumber("bad", 12), 12);
189
+ assert.equal(toFiniteNumber(undefined, 7), 7);
190
+ assert.equal(toFiniteNumber("15.5", 0), 15.5);
191
+ });
192
+
193
+ test("atLeast and clampNumber keep values finite and bounded", function () {
194
+ assert.equal(atLeast("bad", 50, 10), 50);
195
+ assert.equal(atLeast("25", 50, 10), 50);
196
+ assert.equal(clampNumber("bad", 0, 1, 0.4), 0.4);
197
+ assert.equal(clampNumber("2", 0, 1, 0.4), 1);
198
+ });
199
+
200
+ test("roundedAtLeast rounds finite values after fallback", function () {
201
+ assert.equal(roundedAtLeast("2.4", 0, 10), 2);
202
+ assert.equal(roundedAtLeast("bad", 5, 2), 5);
203
+ });
204
+
205
+ test("multi-grid prepares aggregated totals for InfluxDB", function () {
206
+ const received = [];
207
+ const node = createRuntimeNode("../multi-grid/multi-grid.js", "multi-grid", {
208
+ operation: "sum",
209
+ meterIdSource: "topic",
210
+ expectedMeterCount: 2,
211
+ emitPartial: false,
212
+ strictCategoryCheck: true,
213
+ influxDb: true,
214
+ influxMeasurement: "grid_totals",
215
+ influxNode: "influx-target",
216
+ __runtimeNodes: {
217
+ "influx-target": {
218
+ receive: function (msg) { received.push(msg); }
219
+ }
220
+ }
221
+ });
222
+ let output;
223
+ function input(topic, payload) {
224
+ node.emit("input", { topic: topic, payload: payload }, function (msg) {
225
+ output = msg;
226
+ }, function (error) {
227
+ if (error) throw error;
228
+ });
229
+ }
230
+
231
+ node.emit("input", {
232
+ topic: "meter-1", ip: "192.168.1.10", unit_id: 1, device_type: "carlo_gavazzi",
233
+ payload: { power_total_w: 1000, current_total_a: 10 }
234
+ }, function (msg) { output = msg; }, function (error) { if (error) throw error; });
235
+ assert.equal(output, undefined);
236
+ node.emit("input", {
237
+ topic: "meter-2", host: "192.168.1.11", unit_id: 2, device_type: "janitza", model: "umg96",
238
+ payload: { power_total_w: 2500, current_total_a: 15 }
239
+ }, function (msg) { output = msg; }, function (error) { if (error) throw error; });
240
+
241
+ assert.deepEqual(output.payload, { power_total_w: 3500, current_total_a: 25 });
242
+ assert.equal(output.influx, undefined);
243
+ assert.equal(output.influx_meter_ids, undefined);
244
+ assert.equal(received[0].influx.measurement, "grid_totals");
245
+ assert.deepEqual(received[0].influx.fields, { power_total_w: 3500, current_total_a: 25 });
246
+ assert.deepEqual(received[0].influx.tags, {
247
+ source: "multi-grid", operation: "sum", meter_count: "2",
248
+ meter_ids: "meter-1,meter-2", meter_ips: "192.168.1.10,192.168.1.11",
249
+ unit_ids: "1,2", device_types: "carlo_gavazzi,janitza", meter_models: "umg96"
250
+ });
251
+ assert.deepEqual(received[0].influx_meter_ids, ["meter-1", "meter-2"]);
252
+ assert.equal(received[0].influx_meters[1].ip, "192.168.1.11");
253
+ assert.equal(Number.isNaN(Date.parse(received[0].influx.timestamp)), false);
254
+ assert.equal(received.length, 1);
255
+ assert.notEqual(received[0], output);
256
+ });
257
+
258
+ test("fronius-write does not expose fast write in the editor", function () {
259
+ const html = fs.readFileSync(require.resolve("../fronius-write/fronius-write.html"), "utf8");
260
+ assert.equal(html.includes("fastWriteEnabled"), false);
261
+ assert.equal(html.includes("node-input-fastWriteEnabled"), false);
262
+ assert.equal(html.includes("Fast write"), false);
263
+ assert.equal(html.includes("fast_path"), false);
264
+ assert.ok(html.includes("node-input-rampUpValue"));
265
+ assert.ok(html.includes("node-input-rampDownValue"));
266
+ assert.ok(html.includes("node-input-disableReadbackVerification"));
267
+ assert.ok(html.includes("Disable read-back verification"));
268
+ assert.ok(html.includes("clampConfiguredPower"));
269
+ assert.ok(html.includes("this.nominalPower = this.maxPower"));
270
+ assert.equal(html.includes('id="row-ramp-time"'), false);
271
+ assert.equal(html.includes('id="row-ramp-gradient-pct"'), false);
272
+ assert.equal(html.includes('id="row-ramp-gradient-watts"'), false);
273
+ assert.equal(html.includes('id="row-ramp-gradient-vars"'), false);
274
+ });
275
+
276
+ test("IP-driven editor names follow every address change and preserve manual names otherwise", function () {
277
+ const cases = [
278
+ ["fronius-read", "fronius-read/fronius-read.html", /#node-input-host[\s\S]{0,240}\.on\("input\.froniusAutoName change\.froniusAutoName"[\s\S]{0,220}autoNameEnabled = true;[\s\S]{0,160}#node-input-name/],
279
+ ["fronius-write", "fronius-write/fronius-write.html", /#node-input-host[\s\S]{0,240}\.on\("input\.froniusWriteNameSync change\.froniusWriteNameSync"[\s\S]{0,220}autoNameEnabled = true;[\s\S]{0,160}#node-input-name/],
280
+ ["sma-read", "sma-read/sma-read.html", /#node-input-host"\)\.on\("input\.smaRead change\.smaRead"[\s\S]{0,120}autoName = true;[\s\S]{0,120}refreshAutoName/],
281
+ ["sma-write", "sma-write/sma-write.html", /#node-input-host"\)\.on\("input\.smaWrite change\.smaWrite"[\s\S]{0,120}autoNameEnabled = true;[\s\S]{0,120}refreshAutoName/],
282
+ ["carlo-gavazzi", "carlo-gavazzi/carlo-gavazzi.html", /#node-input-host[\s\S]{0,180}\.on\("input\.carloHostSync change\.carloHostSync"[\s\S]{0,300}#node-input-name"\)\.val\(newHost\)/],
283
+ ["janitza", "janitza/janitza.html", /#node-input-host"\)\.on\("input\.janitzaHostSync change\.janitzaHostSync"[\s\S]{0,220}#node-input-name"\)\.val\(newHost\)/],
284
+ ["phoenix-contact", "phoenix-contact/phoenix-contact.html", /#node-input-host"\)\.off\("\.phoenixNameSync"\)\.on\("input\.phoenixNameSync change\.phoenixNameSync"[\s\S]{0,180}#node-input-name/],
285
+ ["advantech-read", "advantech-read/advantech-read.html", /#node-input-host[\s\S]{0,120}\.off\("\.advantechReadName"\)[\s\S]{0,180}#node-input-name/],
286
+ ["advantech-write", "advantech-write/advantech-write.html", /#node-input-host[\s\S]{0,120}\.off\("\.advantechWriteName"\)[\s\S]{0,180}#node-input-name/],
287
+ ["pylontech-read", "pylontech-read/pylontech-read.html", /#node-input-host"\)\.off\("\.pylontechReadName"\)\.on\("input\.pylontechReadName change\.pylontechReadName"[\s\S]{0,180}#node-input-name/],
288
+ ["pylontech-write", "pylontech-write/pylontech-write.html", /#node-input-host"\)\.off\("\.pylontechWriteName"\)\.on\("input\.pylontechWriteName change\.pylontechWriteName"[\s\S]{0,180}#node-input-name/],
289
+ ["scada-server", "scada-server/scada-server.html", /#node-input-name"\)\.val\("SCADA Server"\)\.prop\("readonly", true\)/],
290
+ ["fronius-emulator", "fronius-emulator/fronius-emulator.html", /#node-input-name"\)\.val\("Fronius Emulator"\)\.prop\("readonly", true\)/]
291
+ ];
292
+
293
+ cases.forEach(function ([nodeName, relativePath, pattern]) {
294
+ const html = fs.readFileSync(require.resolve("../" + relativePath), "utf8");
295
+ assert.match(html, pattern, nodeName + " must replace Name when its IP input changes");
296
+ });
297
+
298
+ const advantechWrite = fs.readFileSync(require.resolve("../advantech-write/advantech-write.html"), "utf8");
299
+ assert.equal((advantechWrite.match(/advantechWriteSetAutoName\(\);/g) || []).length, 0, "opening advantech-write must not overwrite a manual name");
300
+ });
301
+
302
+ test("multi-inverter exposes directional ramp fields and no fast path output", function () {
303
+ const html = fs.readFileSync(require.resolve("../multi-inverter/multi-inverter.html"), "utf8");
304
+ const runtime = fs.readFileSync(require.resolve("../multi-inverter/multi-inverter.js"), "utf8");
305
+ const froniusRuntime = fs.readFileSync(require.resolve("../fronius-write/fronius-write.js"), "utf8");
306
+
307
+ assert.ok(html.includes("node-input-rampUpValue"));
308
+ assert.ok(html.includes("node-input-rampDownValue"));
309
+ assert.ok(html.includes("node-input-rampUpGlobalVar"));
310
+ assert.ok(html.includes("node-input-rampDownGlobalVar"));
311
+ assert.equal(html.includes("Ramp Value Variable"), false);
312
+ assert.equal(html.includes('id="node-row-rampValue"'), false);
313
+ assert.ok(froniusRuntime.includes("rampUpValue"));
314
+ assert.ok(froniusRuntime.includes("rampDownValue"));
315
+ assert.ok(froniusRuntime.includes("ramp_direction"));
316
+ assert.equal(runtime.includes("fast_path"), false);
317
+ assert.ok(runtime.includes("multi_inverter"));
318
+ assert.ok(runtime.includes("target.receive"));
319
+ assert.ok(html.includes("node-input-influxNode"));
320
+ });
321
+
322
+ test("fronius-read supports secondary InfluxDB without changing its primary output", function () {
323
+ const runtime = fs.readFileSync(require.resolve("../fronius-read/fronius-read.js"), "utf8");
324
+ const html = fs.readFileSync(require.resolve("../fronius-read/fronius-read.html"), "utf8");
325
+ assert.ok(runtime.includes("compactOutputMode"));
326
+ assert.ok(runtime.includes("target.receive(RED.util.cloneMessage(influxMessage))"));
327
+ assert.ok(html.includes("node-input-influxNode"));
328
+ assert.ok(html.includes("compactIntegrationActive"));
329
+ });
330
+
331
+ test("meter nodes expose source identifiers and Fronius write exposes diagnostics", function () {
332
+ const carlo = fs.readFileSync(require.resolve("../carlo-gavazzi/carlo-gavazzi.js"), "utf8");
333
+ const phoenix = fs.readFileSync(require.resolve("../phoenix-contact/phoenix-contact.js"), "utf8");
334
+ const froniusWrite = fs.readFileSync(require.resolve("../fronius-write/fronius-write.js"), "utf8");
335
+ assert.match(carlo, /source_id:\s*meterId/);
336
+ assert.match(carlo, /timestamp_ms/);
337
+ assert.match(phoenix, /source_id:\s*node\.meterId/);
338
+ assert.match(phoenix, /node\.logReadRetries/);
339
+ assert.ok(phoenix.includes("Phoenix Contact read retry"));
340
+ assert.match(froniusWrite, /setpoint_addr/);
341
+ assert.match(froniusWrite, /selected_enable_readback/);
342
+ assert.ok(froniusWrite.includes("modbus exception 4"));
343
+ assert.ok(froniusWrite.includes("slave device failure"));
344
+ assert.ok(froniusWrite.includes("writeSingleLabeled"));
345
+ assert.ok(froniusWrite.includes("write failed at register"));
346
+ assert.ok(froniusWrite.includes("disableReadbackVerification"));
347
+ assert.ok(froniusWrite.includes("readback_verification_disabled"));
348
+ assert.equal(froniusWrite.includes("post_write_verify_delay_ms"), false);
349
+ });
350
+
351
+ test("read nodes support poll off trigger input mode", function () {
352
+ const nodes = [
353
+ { name: "fronius-read", runtime: "../fronius-read/fronius-read.js", editor: "../fronius-read/fronius-read.html", field: "pollInterval", placeholder: "5000 sau off" },
354
+ { name: "carlo-gavazzi", runtime: "../carlo-gavazzi/carlo-gavazzi.js", editor: "../carlo-gavazzi/carlo-gavazzi.html", field: "timestep", placeholder: "1000 sau off" },
355
+ { name: "phoenix-contact", runtime: "../phoenix-contact/phoenix-contact.js", editor: "../phoenix-contact/phoenix-contact.html", field: "timestep", placeholder: "1000 sau off" },
356
+ { name: "pylontech-read", runtime: "../pylontech-read/pylontech-read.js", editor: "../pylontech-read/pylontech-read.html", field: "pollInterval", placeholder: "5000 sau off" },
357
+ { name: "janitza", runtime: "../janitza/janitza.js", editor: "../janitza/janitza.html", field: "timestep", placeholder: "1000 sau off" },
358
+ { name: "victron-read", runtime: "../victron-read/victron-read.js", editor: "../victron-read/victron-read.html", field: "pollInterval", placeholder: "5000 sau off" }
359
+ ];
360
+
361
+ nodes.forEach(function (item) {
362
+ const runtime = fs.readFileSync(require.resolve(item.runtime), "utf8");
363
+ const editor = fs.readFileSync(require.resolve(item.editor), "utf8");
364
+ const selector = "#node-input-" + item.field;
365
+
366
+ assert.ok(runtime.includes('node.on("input"'), item.name + " runtime handles input triggers");
367
+ assert.ok(runtime.includes('"off"') || runtime.includes("'off'"), item.name + " runtime accepts off");
368
+ assert.ok(runtime.includes("> 0"), item.name + " runtime starts polling only for positive intervals");
369
+ assert.ok(editor.includes(selector), item.name + " editor exposes poll field");
370
+ assert.ok(editor.includes(item.placeholder), item.name + " editor hints off mode");
371
+ assert.ok(editor.includes("this.inputs") || editor.includes("node.inputs"), item.name + " editor updates input count");
372
+ assert.ok(editor.includes('"0" ? 1 : 0'), item.name + " editor maps 0/off to one input");
373
+ if (item.name === "phoenix-contact") {
374
+ assert.ok(editor.includes("node-input-logReadRetries"));
375
+ assert.ok(editor.includes("Log read retries"));
376
+ assert.ok(editor.includes("logReadRetries: { value: false }"));
377
+ }
378
+ });
379
+ });
380
+
381
+ test("advantech nodes expose ADAM-6000 Modbus mappings", function () {
382
+ const pkg = JSON.parse(fs.readFileSync(require.resolve("../package.json"), "utf8").replace(/^\uFEFF/, ""));
383
+ const readRuntime = fs.readFileSync(require.resolve("../advantech-read/advantech-read.js"), "utf8");
384
+ const readHtml = fs.readFileSync(require.resolve("../advantech-read/advantech-read.html"), "utf8");
385
+ const writeRuntime = fs.readFileSync(require.resolve("../advantech-write/advantech-write.js"), "utf8");
386
+ const writeHtml = fs.readFileSync(require.resolve("../advantech-write/advantech-write.html"), "utf8");
387
+ const profiles = require("../lib/adam-6000-profiles");
388
+
389
+ assert.equal(pkg["node-red"].nodes["advantech-read"], "advantech-read/advantech-read.js");
390
+ assert.equal(pkg["node-red"].nodes["advantech-write"], "advantech-write/advantech-write.js");
391
+ assert.ok(readRuntime.includes('RED.nodes.registerType("advantech-read"'));
392
+ assert.ok(writeRuntime.includes('RED.nodes.registerType("advantech-write"'));
393
+ assert.ok(readHtml.includes("40001"));
394
+ assert.ok(readHtml.includes("40011"));
395
+ assert.ok(readHtml.includes("Module Profile"));
396
+ assert.ok(!readHtml.includes("node-input-autoDetect"));
397
+ assert.ok(!readHtml.includes('option value="auto"'));
398
+ assert.ok(readHtml.includes("Separate DI outputs"));
399
+ assert.ok(readHtml.includes("Separate DO outputs"));
400
+ assert.ok(readHtml.includes("Separate AI outputs"));
401
+ assert.ok(readHtml.includes("Separate AO outputs"));
402
+ assert.ok(readHtml.includes("ADAM_CHANNEL_COUNTS"));
403
+ assert.ok(readHtml.includes("validPollValue"));
404
+ assert.ok(readHtml.includes("isPollOff"));
405
+ assert.ok(readHtml.includes("this.inputs = isPollOff"));
406
+ assert.ok(readHtml.includes("5000 sau off"));
407
+ assert.ok(readHtml.includes("Debug output"));
408
+ assert.ok(readHtml.includes("node-input-debugOutput"));
409
+ assert.ok(readHtml.includes('"di1": true'));
410
+ assert.ok(readHtml.includes("prima pentru DI si a doua pentru DO"));
411
+ assert.ok(readHtml.includes("row-read-ai"));
412
+ assert.ok(readHtml.includes("Payload agregat"));
413
+ assert.ok(readRuntime.includes("buildDigitalMessages"));
414
+ assert.ok(readRuntime.includes("buildOutputMessages"));
415
+ assert.ok(readRuntime.includes("buildFamilyPayload"));
416
+ assert.ok(readRuntime.includes("buildFamilyAggregateMessage"));
417
+ assert.ok(readRuntime.includes("cloneAggregatePayload"));
418
+ assert.ok(readRuntime.includes("debugOutput"));
419
+ assert.ok(readRuntime.includes("toPollInterval"));
420
+ assert.ok(readRuntime.includes('trim().toLowerCase() === "off"'));
421
+ assert.ok(readRuntime.includes("node.outputCount > 1"));
422
+ assert.ok(readRuntime.includes("saveDiGlobals"));
423
+ assert.ok(readRuntime.includes("saveDoGlobals"));
424
+ assert.ok(readRuntime.includes("saveAiGlobals"));
425
+ assert.ok(readRuntime.includes("saveAoGlobals"));
426
+ assert.ok(readRuntime.includes("saveChannelGlobals"));
427
+ assert.ok(readHtml.includes('this.diOutputMode = diSplit ? "split" : "aggregate"'));
428
+ assert.ok(readHtml.includes("this.doOutputMode = doSplit"));
429
+ assert.ok(readHtml.includes("selectedSplitOutputCount"));
430
+ assert.ok(readHtml.includes('family.mode === "split"'));
431
+ assert.ok(readHtml.includes("Save DI globals"));
432
+ assert.ok(readHtml.includes("Save DO globals"));
433
+ assert.ok(readHtml.includes("Save AI globals"));
434
+ assert.ok(readHtml.includes("Save AO globals"));
435
+ assert.ok(readHtml.includes("DI global names"));
436
+ assert.ok(readHtml.includes("DO global names"));
437
+ assert.ok(readHtml.includes("AI global names"));
438
+ assert.ok(readHtml.includes("AO global names"));
439
+ assert.ok(readHtml.includes("advantech_di1"));
440
+ assert.ok(readHtml.includes("advantech_do1"));
441
+ assert.ok(readHtml.includes("advantech_ai1"));
442
+ assert.ok(readHtml.includes("advantech_ao1"));
443
+ assert.ok(writeHtml.includes("00017"));
444
+ assert.ok(writeHtml.includes("40303"));
445
+ assert.ok(writeHtml.includes("Module Profile"));
446
+ assert.ok(!writeHtml.includes("node-input-autoDetect"));
447
+ assert.ok(!writeHtml.includes('option value="auto"'));
448
+ assert.ok(writeHtml.includes("Multiple DO from one message"));
449
+ assert.ok(writeHtml.includes("advantechWriteAutoLabel"));
450
+ assert.ok(writeHtml.includes("ADAM_WRITE_CHANNEL_COUNTS"));
451
+ assert.ok(writeHtml.includes("syncSelectedDoOptions"));
452
+ assert.ok(writeHtml.includes("advantechWriteSetAutoName"));
453
+ assert.ok(writeHtml.includes('select id="node-input-defaultChannel"'));
454
+ assert.ok(writeHtml.includes("Nume automat"));
455
+ assert.ok(writeRuntime.includes("writeCoil"));
456
+ assert.ok(writeRuntime.includes("writeRegister"));
457
+ assert.equal(profiles.profileForModel("ADAM-6050").diCount, 12);
458
+ assert.equal(profiles.profileForModel("ADAM-6051").diCount, 12);
459
+ assert.equal(profiles.profileForModel("ADAM-6051").doCount, 2);
460
+ assert.equal(profiles.profileForModel("ADAM-6066").diCount, 6);
461
+ assert.equal(profiles.profileForModel("ADAM-6066").doCount, 6);
462
+ assert.equal(profiles.profileForModel("ADAM-6022").aiCount, 6);
463
+ assert.equal(profiles.profileForModel("ADAM-6024").aoCount, 2);
464
+ assert.ok(readRuntime.includes("profileForModel"));
465
+ assert.ok(writeRuntime.includes("profileForModel"));
466
+ });
467
+
468
+ function writeFloatRegisters(registers, offset, value) {
469
+ const buffer = Buffer.alloc(4);
470
+ buffer.writeFloatBE(value, 0);
471
+ registers[offset] = buffer.readUInt16BE(0);
472
+ registers[offset + 1] = buffer.readUInt16BE(2);
473
+ }
474
+
475
+ test("phoenix-contact decodes EEM-MA370 Float32 word order", function () {
476
+ const registers = [0x4145, 0x8548];
477
+ assert.ok(Math.abs(decodeFloat32(registers, 0, { wordOrder: "msw_first" }) - 12.345) < 0.001);
478
+ assert.ok(Math.abs(decodeFloat32([registers[1], registers[0]], 0, { wordOrder: "lsw_first" }) - 12.345) < 0.001);
479
+ });
480
+
481
+ test("phoenix-contact maps EEM-MA370 measurements and energy", function () {
482
+ const instantaneous = new Array(INSTANTANEOUS_COUNT).fill(0);
483
+ const energy = new Array(ENERGY_COUNT).fill(0);
484
+ writeFloatRegisters(instantaneous, 0x8016 - 0x8000, -1250.5);
485
+ writeFloatRegisters(instantaneous, 0x801E - 0x8000, -400);
486
+ writeFloatRegisters(instantaneous, 0x8006 - 0x8000, 230.2);
487
+ writeFloatRegisters(instantaneous, 0x800C - 0x8000, 50);
488
+ writeFloatRegisters(energy, 0, 123456);
489
+ writeFloatRegisters(energy, 6, 6543);
490
+
491
+ const summary = parseEemMa370(instantaneous, energy);
492
+ assert.ok(Math.abs(summary.power.total_w + 1250.5) < 0.01);
493
+ assert.ok(Math.abs(summary.power.l1_w + 400) < 0.01);
494
+ assert.ok(Math.abs(summary.voltage.l1_n_v - 230.2) < 0.01);
495
+ assert.equal(summary.frequency_hz, 50);
496
+ assert.ok(Math.abs(summary.energy.import_kwh - 123.456) < 0.001);
497
+ assert.ok(Math.abs(summary.energy.export_kwh - 6.543) < 0.001);
498
+ });
499
+
500
+ test("phoenix-contact applies Carlo-compatible integration presets", function () {
501
+ assert.deepEqual(selectPhoenixFields("multiGrid", false, ["full"]), [
502
+ "summary.power.total_w",
503
+ "summary.power.l1_w",
504
+ "summary.power.l2_w",
505
+ "summary.power.l3_w"
506
+ ]);
507
+ assert.equal(selectPhoenixFields("controlExportReactive", false, ["full"]).includes("summary.reactive.total_var"), true);
508
+ assert.deepEqual(selectPhoenixFields("multiGrid", true, ["summary.frequency_hz"]), ["summary.frequency_hz"]);
509
+ });
510
+
511
+ test("phoenix-contact builds compact and SCADA payload contracts", function () {
512
+ const envelope = {
513
+ summary: {
514
+ power: { total_w: -1200, l1_w: -400 },
515
+ reactive: { total_var: 350 },
516
+ voltage: { l3_l1_v: 401 }
517
+ }
518
+ };
519
+ assert.deepEqual(aggregatePhoenixFields(envelope, ["summary.power.total_w", "summary.power.l1_w"], "multiGrid"), {
520
+ power_w: -1200,
521
+ power_l1_w: -400
522
+ });
523
+ assert.deepEqual(aggregatePhoenixFields(envelope, ["summary.power.total_w", "summary.reactive.total_var", "summary.voltage.l3_l1_v"], "scadaServer"), {
524
+ active_power_w: -1200,
525
+ reactive_power_var: 350,
526
+ voltage_l1_l3_v: 401
527
+ });
528
+ });
529
+
530
+ test("phoenix-contact runtime uses the shared compact payload mapping", function () {
531
+ const runtime = fs.readFileSync(require.resolve("../phoenix-contact/phoenix-contact.js"), "utf8");
532
+ assert.match(runtime, /const \{ compactKey \} = require\("\.\/lib\/integrations"\)/);
533
+ assert.match(runtime, /function getCompactPayloadKey\(path\) \{\s*return compactKey\(path\);\s*\}/);
534
+ });
535
+
536
+ test("phoenix-contact flattens finite fields for InfluxDB", function () {
537
+ const fields = buildPhoenixInfluxFields({
538
+ summary: {
539
+ power: { total_w: 500, invalid: null },
540
+ frequency_hz: 50
541
+ }
542
+ }, ["full"]);
543
+ assert.deepEqual(fields, { power_w: 500, frequency_hz: 50 });
544
+ });
545
+
546
+ test("phoenix-contact reads only advanced blocks required by selected fields", function () {
547
+ const blocks = requiredPhoenixBlocks([
548
+ "summary.energy.tariff.t1_import_kwh",
549
+ "summary.thd.current_l1_pct"
550
+ ]);
551
+ assert.deepEqual(blocks.map(function (block) { return block.id; }), ["tariffs", "thd"]);
552
+ assert.deepEqual(requiredPhoenixBlocks(["summary.power.total_w"]), []);
553
+ });
554
+
555
+ test("phoenix-contact decodes advanced tariff, runtime and THD registers", function () {
556
+ const tariff = new Array(49).fill(0);
557
+ const hours = new Array(22).fill(0);
558
+ const thd = new Array(10).fill(0);
559
+ writeFloatRegisters(tariff, 1, 123456);
560
+ hours[0] = 0;
561
+ hours[1] = 2500;
562
+ thd[6] = 123;
563
+ const parsed = parseAdvancedBlocks({ tariffs: tariff, hours: hours, thd: thd });
564
+ assert.ok(Math.abs(parsed.energy.tariff.t1_import_kwh - 123.456) < 0.001);
565
+ assert.equal(parsed.runtime.operating_hours, 2500);
566
+ assert.equal(parsed.thd.current_l1_pct, 12.3);
567
+ });
568
+
569
+ test("battery runtime marks stale telemetry and write errors", function () {
570
+ const batteries = normalizeBatteries([{ id: "bat1", socMin: 20 }], 1);
571
+ const runtime = evaluateRuntimeBatteries(batteries, {
572
+ bat1: {
573
+ soc: 50,
574
+ ts: 1000,
575
+ writeFeedback: { status: "error", ts: 1900 }
576
+ }
577
+ }, {
578
+ staleTimeoutMs: 500,
579
+ feedbackTimeoutMs: 500
580
+ }, 2000);
581
+
582
+ assert.equal(runtime[0].telemetryStale, true);
583
+ assert.equal(runtime[0].writeError, true);
584
+ assert.equal(runtime[0].excluded, true);
585
+ assert.deepEqual(runtime[0].excludedReasons, ["stale", "write_error"]);
586
+ });
587
+
588
+ test("battery distribution validates configured charge and discharge shares", function () {
589
+ assert.equal(distributionIsValid([
590
+ { shareChargePct: 50, shareDischargePct: 60 },
591
+ { shareChargePct: 50, shareDischargePct: 40 }
592
+ ]), true);
593
+ assert.equal(distributionIsValid([
594
+ { shareChargePct: 70, shareDischargePct: 60 },
595
+ { shareChargePct: 20, shareDischargePct: 40 }
596
+ ]), false);
597
+ });
598
+
599
+ test("multi-battery processes telemetry and command through runtime adapter", function () {
600
+ let RegisteredNode = null;
601
+ const RED = {
602
+ nodes: {
603
+ getNode: function () {
604
+ return {
605
+ getState: function () { return "ACTIVE"; },
606
+ isLicensed: function () { return true; },
607
+ hasFeature: function () { return true; },
608
+ on: function () {},
609
+ removeListener: function () {}
610
+ };
611
+ },
612
+ createNode: function (node) {
613
+ EventEmitter.call(node);
614
+ Object.setPrototypeOf(node, EventEmitter.prototype);
615
+ node.status = function () {};
616
+ node.warn = function () {};
617
+ node.debug = function () {};
618
+ node.error = function (error) { throw error; };
619
+ node.send = function (outputs) {
620
+ node.sent = outputs;
621
+ };
622
+ },
623
+ registerType: function (name, constructor) {
624
+ if (name === "multi-battery") RegisteredNode = constructor;
625
+ }
626
+ }
627
+ };
628
+
629
+ require("../multi-battery/multi-battery")(RED);
630
+ const node = new RegisteredNode({
631
+ batteryCount: 1,
632
+ batteries: [{
633
+ id: "bat1",
634
+ shareChargePct: 100,
635
+ shareDischargePct: 100,
636
+ maxChargeW: 10000,
637
+ maxDischargeW: 10000,
638
+ socMin: 20
639
+ }],
640
+ staleTimeoutMs: 0,
641
+ feedbackTimeoutMs: 0
642
+ });
643
+
644
+ node.emit("input", {
645
+ batteryId: "bat1",
646
+ payload: { battery_soc: 80 }
647
+ }, node.send.bind(node), function (error) {
648
+ if (error) throw error;
649
+ });
650
+ node.emit("input", {
651
+ payload: { power_w: 5000, external_control: true }
652
+ }, node.send.bind(node), function (error) {
653
+ if (error) throw error;
654
+ });
655
+
656
+ assert.equal(Array.isArray(node.sent), true);
657
+ assert.equal(node.sent[0].batteryId, "bat1");
658
+ assert.equal(node.sent[0].payload.power_w, 5000);
659
+
660
+ node.emit("input", {
661
+ payload: { power_w: 20000, external_control: true }
662
+ }, node.send.bind(node), function (error) {
663
+ if (error) throw error;
664
+ });
665
+ assert.equal(node.sent[0].payload.power_w, 10000);
666
+ });
667
+
668
+ test("control-export absolute mode requires all three phases", function () {
669
+ const node = createRuntimeNode("../control-export/control-export", "control-export", {
670
+ mode: "zero_feed_in",
671
+ zeroFeedInMode: "absolute",
672
+ staleTimeoutMs: 0,
673
+ minLimit: 0,
674
+ maxLimit: 1000
675
+ });
676
+
677
+ let doneError = null;
678
+ node.emit("input", {
679
+ payload: {
680
+ power_l1_w: -100,
681
+ power_l2_w: -50
682
+ }
683
+ }, function (outputs) {
684
+ node.sent = outputs;
685
+ }, function (error) {
686
+ doneError = error || null;
687
+ });
688
+
689
+ assert.ok(doneError);
690
+ assert.match(doneError.message, /faze/);
691
+ });
692
+
693
+ test("control-export rejects measurements too far in the future", function () {
694
+ const node = createRuntimeNode("../control-export/control-export", "control-export", {
695
+ mode: "zero_feed_in",
696
+ staleTimeoutMs: 0,
697
+ maxFutureTimestampMs: 100,
698
+ minLimit: 0,
699
+ maxLimit: 1000
700
+ });
701
+
702
+ let doneError = null;
703
+ node.emit("input", {
704
+ timestamp_ms: Date.now() + 10000,
705
+ payload: { power_w: -100 }
706
+ }, function (outputs) {
707
+ node.sent = outputs;
708
+ }, function (error) {
709
+ doneError = error || null;
710
+ });
711
+
712
+ assert.ok(doneError);
713
+ assert.match(doneError.message, /viitor/);
714
+ });
715
+
716
+ test("control-export status shows latest meter reference, not filtered reference", function () {
717
+ const node = createRuntimeNode("../control-export/control-export", "control-export", {
718
+ mode: "zero_feed_in",
719
+ staleTimeoutMs: 0,
720
+ filterTauMs: 2800,
721
+ minLimit: 0,
722
+ maxLimit: 10000
723
+ });
724
+
725
+ const firstTimestamp = Date.now() - 1000;
726
+
727
+ node.emit("input", {
728
+ timestamp_ms: firstTimestamp,
729
+ payload: { power_w: -487.5 }
730
+ }, node.send.bind(node), function (error) {
731
+ if (error) throw error;
732
+ });
733
+
734
+ node.emit("input", {
735
+ timestamp_ms: firstTimestamp + 1,
736
+ payload: { power_w: -3290.5 }
737
+ }, node.send.bind(node), function (error) {
738
+ if (error) throw error;
739
+ });
740
+
741
+ assert.ok(node.lastStatus);
742
+ assert.match(node.lastStatus.text, /grid -3290W/);
743
+ });
744
+
745
+ test("control-export limits immediately when negative grid setpoint is exceeded", function () {
746
+ const node = createRuntimeNode("../control-export/control-export", "control-export", {
747
+ mode: "grid_set_point",
748
+ gridSetPoint: -2000,
749
+ staleTimeoutMs: 0,
750
+ transientIgnoreMs: 1200,
751
+ startLimitMode: "last_limit",
752
+ minLimit: 0,
753
+ maxLimit: 10000,
754
+ __localContext: {
755
+ control_export_last_limit_w: 10000
756
+ }
757
+ });
758
+
759
+ node.emit("input", {
760
+ payload: { power_w: -3000 }
761
+ }, node.send.bind(node), function (error) {
762
+ if (error) throw error;
763
+ });
764
+
765
+ const mainMsg = Array.isArray(node.sent) ? node.sent[0] : node.sent;
766
+ assert.ok(mainMsg.payload.site_limit_w < 10000);
767
+ assert.notEqual(mainMsg.payload.reason, "transient_ignore");
768
+ });
769
+
770
+ test("control-export reduces immediately during hold period when export exceeds target", function () {
771
+ const node = createRuntimeNode("../control-export/control-export", "control-export", {
772
+ mode: "grid_set_point",
773
+ gridSetPoint: -8000,
774
+ staleTimeoutMs: 0,
775
+ controlPeriodMs: 60000,
776
+ filterTauMs: 60000,
777
+ startLimitMode: "last_limit",
778
+ minLimit: 0,
779
+ maxLimit: 10000,
780
+ __localContext: {
781
+ control_export_last_limit_w: 4000
782
+ }
783
+ });
784
+
785
+ const firstTimestamp = Date.now() - 1000;
786
+
787
+ node.emit("input", {
788
+ timestamp_ms: firstTimestamp,
789
+ payload: { power_w: -8000 }
790
+ }, node.send.bind(node), function (error) {
791
+ if (error) throw error;
792
+ });
793
+
794
+ const firstMsg = Array.isArray(node.sent) ? node.sent[0] : node.sent;
795
+ assert.equal(firstMsg.payload.site_limit_w, 4000);
796
+
797
+ node.emit("input", {
798
+ timestamp_ms: firstTimestamp + 1,
799
+ payload: { power_w: -8943 }
800
+ }, node.send.bind(node), function (error) {
801
+ if (error) throw error;
802
+ });
803
+
804
+ const secondMsg = Array.isArray(node.sent) ? node.sent[0] : node.sent;
805
+ assert.ok(secondMsg.payload.site_limit_w < 4000);
806
+ assert.notEqual(secondMsg.payload.reason, "hold_period");
807
+ });
808
+
809
+ test("control-export rate limits repeated immediate reductions on fast telemetry", function () {
810
+ const node = createRuntimeNode("../control-export/control-export", "control-export", {
811
+ mode: "grid_set_point",
812
+ gridSetPoint: -8000,
813
+ staleTimeoutMs: 0,
814
+ controlPeriodMs: 60000,
815
+ filterTauMs: 60000,
816
+ transientIgnoreMs: 1200,
817
+ startLimitMode: "last_limit",
818
+ minLimit: 0,
819
+ maxLimit: 10000,
820
+ __localContext: {
821
+ control_export_last_limit_w: 4000
822
+ }
823
+ });
824
+
825
+ const firstTimestamp = Date.now() - 1000;
826
+
827
+ node.emit("input", {
828
+ timestamp_ms: firstTimestamp,
829
+ payload: { power_w: -8000 }
830
+ }, node.send.bind(node), function (error) {
831
+ if (error) throw error;
832
+ });
833
+
834
+ node.emit("input", {
835
+ timestamp_ms: firstTimestamp + 1,
836
+ payload: { power_w: -8943 }
837
+ }, node.send.bind(node), function (error) {
838
+ if (error) throw error;
839
+ });
840
+
841
+ const firstReductionMsg = Array.isArray(node.sent) ? node.sent[0] : node.sent;
842
+ assert.ok(firstReductionMsg.payload.site_limit_w < 4000);
843
+ const outputCountAfterReduction = node.sentHistory.length;
844
+
845
+ node.emit("input", {
846
+ timestamp_ms: firstTimestamp + 201,
847
+ payload: { power_w: -9200 }
848
+ }, node.send.bind(node), function (error) {
849
+ if (error) throw error;
850
+ });
851
+
852
+ assert.equal(node.sentHistory.length, outputCountAfterReduction);
853
+ });
854
+
855
+ test("control-export applies adaptive PI reduction for measured export overshoot", function () {
856
+ const node = createRuntimeNode("../control-export/control-export", "control-export", {
857
+ mode: "grid_set_point",
858
+ gridSetPoint: -8000,
859
+ staleTimeoutMs: 0,
860
+ startLimitMode: "last_limit",
861
+ minLimit: 0,
862
+ maxLimit: 10000,
863
+ __localContext: {
864
+ control_export_last_limit_w: 3995
865
+ }
866
+ });
867
+
868
+ node.emit("input", {
869
+ payload: { power_w: -8943 }
870
+ }, node.send.bind(node), function (error) {
871
+ if (error) throw error;
872
+ });
873
+
874
+ const mainMsg = Array.isArray(node.sent) ? node.sent[0] : node.sent;
875
+ assert.ok(mainMsg.payload.site_limit_w < 3995);
876
+ assert.ok(mainMsg.payload.site_limit_w > 3052);
877
+ assert.equal(mainMsg.payload.reason, "export_limit");
878
+ assert.equal(mainMsg.payload.controller_state, "decreasing_limit");
879
+ });
880
+
881
+ test("control-export recovers upward only within measured grid margin", function () {
882
+ const node = createRuntimeNode("../control-export/control-export", "control-export", {
883
+ mode: "grid_set_point",
884
+ gridSetPoint: -8000,
885
+ staleTimeoutMs: 0,
886
+ transientIgnoreMs: 0,
887
+ recoveryDelayMs: 0,
888
+ startLimitMode: "last_limit",
889
+ minLimit: 0,
890
+ maxLimit: 10000,
891
+ __localContext: {
892
+ control_export_last_limit_w: 3000
893
+ }
894
+ });
895
+
896
+ node.emit("input", {
897
+ payload: { power_w: -7000 }
898
+ }, node.send.bind(node), function (error) {
899
+ if (error) throw error;
900
+ });
901
+
902
+ const mainMsg = Array.isArray(node.sent) ? node.sent[0] : node.sent;
903
+ assert.ok(mainMsg.payload.site_limit_w > 3000);
904
+ assert.ok(mainMsg.payload.site_limit_w <= 4000);
905
+ assert.equal(mainMsg.payload.reason, "recovery");
906
+ assert.equal(mainMsg.payload.controller_state, "increasing_limit");
907
+ });
908
+
909
+ test("control-export stable grid preset applies precision runtime values", function () {
910
+ const node = createRuntimeNode("../control-export/control-export", "control-export", {
911
+ mode: "grid_set_point",
912
+ tuningPreset: "stable_grid",
913
+ gridSetPoint: -8000,
914
+ staleTimeoutMs: 0,
915
+ minLimit: 0,
916
+ maxLimit: 10000,
917
+ debugLevel: "verbose",
918
+ __localContext: {
919
+ control_export_last_limit_w: 5000
920
+ }
921
+ });
922
+
923
+ node.emit("input", {
924
+ payload: { power_w: -8200 }
925
+ }, node.send.bind(node), function (error) {
926
+ if (error) throw error;
927
+ });
928
+
929
+ const mainMsg = Array.isArray(node.sent) ? node.sent[0] : node.sent;
930
+ assert.equal(mainMsg.payload.control_period_ms, 1000);
931
+ assert.equal(mainMsg.payload.filter_tau_ms, 1500);
932
+ assert.equal(mainMsg.payload.max_step_up_w, 1000);
933
+ assert.equal(mainMsg.payload.max_step_down_w, 1800);
934
+ assert.equal(mainMsg.payload.effective_ki > 0, true);
935
+ assert.equal(mainMsg.payload.adaptive_min_scale, 0.45);
936
+ });
937
+
938
+ test("control-export PV cap blocks growth without ratcheting an existing limit down", function () {
939
+ const realNow = Date.now;
940
+ let nowMs = 1700000000000;
941
+ Date.now = function () { return nowMs; };
942
+ try {
943
+ const node = createRuntimeNode("../control-export/control-export", "control-export", {
944
+ mode: "grid_set_point",
945
+ tuningPreset: "custom",
946
+ gridSetPoint: 5000,
947
+ signConvention: "positive_import",
948
+ staleTimeoutMs: 0,
949
+ filterTauMs: 0,
950
+ controlPeriodMs: 1000,
951
+ deadband: 150,
952
+ exportThresholdW: 100,
953
+ recoverThresholdW: 40,
954
+ recoveryDelayMs: 0,
955
+ transientIgnoreMs: 0,
956
+ kp: 0.24,
957
+ ki: 0.008,
958
+ adaptiveGains: false,
959
+ maxStepUpW: 1000,
960
+ maxStepDownW: 1800,
961
+ minLimit: 0,
962
+ maxLimit: 200000,
963
+ useGlobalPvCap: true,
964
+ globalPvTotalVar: "total_pv",
965
+ globalPvCapOffsetW: 5000,
966
+ pvCapOnlyBlocksIncrease: true,
967
+ pvCapHoldMs: 0,
968
+ pvCapReleaseWPerSec: 100000,
969
+ feedForwardEnabled: true,
970
+ feedForwardGain: 0.85,
971
+ debugLevel: "verbose",
972
+ __localContext: { control_export_last_limit_w: 90000 },
973
+ __globalContext: { total_pv: 85000 }
974
+ });
975
+
976
+ node.emit("input", { timestamp_ms: nowMs, payload: { power_w: 40000 } }, node.send.bind(node), function (error) { if (error) throw error; });
977
+ let msg = Array.isArray(node.sent) ? node.sent[0] : node.sent;
978
+ assert.equal(msg.payload.site_limit_w, 90000);
979
+ assert.equal(msg.payload.pv_cap_blocked_increase, true);
980
+ assert.equal(msg.payload.controller_requested_limit_w, 91000);
981
+
982
+ node.context().global.set("total_pv", 80000);
983
+ nowMs += 2000;
984
+ node.emit("input", { timestamp_ms: nowMs, payload: { power_w: 40000 } }, node.send.bind(node), function (error) { if (error) throw error; });
985
+ msg = Array.isArray(node.sent) ? node.sent[0] : node.sent;
986
+ assert.equal(msg.payload.site_limit_w, 90000);
987
+ assert.equal(msg.payload.pv_cap_limit_w, 85000);
988
+ assert.equal(msg.payload.pv_cap_blocked_increase, true);
989
+ assert.ok(msg.payload.feed_forward_target_w > 109000);
990
+ } finally {
991
+ Date.now = realNow;
992
+ }
993
+ });
994
+
995
+ test("control-export output is accepted by multi-inverter", function () {
996
+ const controlNode = createRuntimeNode("../control-export/control-export", "control-export", {
997
+ mode: "grid_set_point",
998
+ gridSetPoint: -8000,
999
+ staleTimeoutMs: 0,
1000
+ startLimitMode: "last_limit",
1001
+ minLimit: 0,
1002
+ maxLimit: 10000,
1003
+ __localContext: {
1004
+ control_export_last_limit_w: 3995
1005
+ }
1006
+ });
1007
+
1008
+ controlNode.emit("input", {
1009
+ payload: { power_w: -8943 }
1010
+ }, controlNode.send.bind(controlNode), function (error) {
1011
+ if (error) throw error;
1012
+ });
1013
+
1014
+ const siteLimitMsg = Array.isArray(controlNode.sent) ? controlNode.sent[0] : controlNode.sent;
1015
+ assert.equal(siteLimitMsg.topic, "site-limit");
1016
+ assert.ok(siteLimitMsg.payload.site_limit_w < 3995);
1017
+ assert.equal(Object.prototype.hasOwnProperty.call(siteLimitMsg.payload, "nominal_power_w"), false);
1018
+ assert.equal(Object.prototype.hasOwnProperty.call(siteLimitMsg.payload, "command_requested_at_ms"), false);
1019
+ assert.equal(Object.prototype.hasOwnProperty.call(siteLimitMsg.payload, "fast_path"), false);
1020
+
1021
+ const inverterNode = createRuntimeNode("../multi-inverter/multi-inverter", "multi-inverter", {
1022
+ strategy: "max_power",
1023
+ siteLimitTopics: "site-limit,control-export",
1024
+ waitForFreshTelemetryOnStartup: false,
1025
+ telemetryTimeoutMs: 0,
1026
+ siteLimitTimeoutMs: 0,
1027
+ publishTotalPvGlobal: false,
1028
+ debugMode: "off",
1029
+ inverters: [
1030
+ { id: "inv1", minW: 0, maxW: 10000, enabled: true }
1031
+ ]
1032
+ });
1033
+
1034
+ inverterNode.emit("input", {
1035
+ topic: "inv1",
1036
+ payload: {
1037
+ inverter_id: "inv1",
1038
+ power_w: 5000
1039
+ }
1040
+ }, inverterNode.send.bind(inverterNode), function (error) {
1041
+ if (error) throw error;
1042
+ });
1043
+
1044
+ inverterNode.emit("input", siteLimitMsg, inverterNode.send.bind(inverterNode), function (error) {
1045
+ if (error) throw error;
1046
+ });
1047
+
1048
+ const outputs = Array.isArray(inverterNode.sent) ? inverterNode.sent : [inverterNode.sent];
1049
+ const aggregateMsg = outputs.filter(function (msg) {
1050
+ return msg && msg.topic === "multi-inverter/limits";
1051
+ })[0];
1052
+ assert.ok(aggregateMsg);
1053
+ assert.equal(aggregateMsg.payload.site_limit_w, Math.round(siteLimitMsg.payload.site_limit_w));
1054
+ assert.equal(aggregateMsg.payload.limits[0].id, "inv1");
1055
+ assert.equal(aggregateMsg.payload.limits[0].limit_w, Math.round(siteLimitMsg.payload.site_limit_w));
1056
+ });
1057
+
1058
+ test("multi-inverter suppresses allocation chatter below configured deadband", function () {
1059
+ const node = createRuntimeNode("../multi-inverter/multi-inverter", "multi-inverter", {
1060
+ strategy: "max_power",
1061
+ waitForFreshTelemetryOnStartup: false,
1062
+ telemetryTimeoutMs: 0,
1063
+ siteLimitTimeoutMs: 0,
1064
+ allocationDeadbandW: 50,
1065
+ publishTotalPvGlobal: false,
1066
+ debugMode: "off",
1067
+ inverters: [{ id: "inv1", minW: 0, maxW: 10000, enabled: true }]
1068
+ });
1069
+
1070
+ node.emit("input", {
1071
+ topic: "inv1",
1072
+ payload: {
1073
+ inverter_id: "inv1",
1074
+ power_w: 6000
1075
+ }
1076
+ }, node.send.bind(node), function (error) { if (error) throw error; });
1077
+
1078
+ node.emit("input", {
1079
+ topic: "site-limit",
1080
+ payload: { site_limit_w: 5000 }
1081
+ }, node.send.bind(node), function (error) { if (error) throw error; });
1082
+
1083
+ const firstMsg = Array.isArray(node.sent) ? node.sent[0] : node.sent;
1084
+ assert.equal(firstMsg.payload.limits[0].limit_w, 5000);
1085
+
1086
+ node.emit("input", {
1087
+ topic: "site-limit",
1088
+ payload: { site_limit_w: 5025 }
1089
+ }, node.send.bind(node), function (error) { if (error) throw error; });
1090
+
1091
+ const secondMsg = Array.isArray(node.sent) ? node.sent[0] : node.sent;
1092
+ assert.equal(secondMsg.payload.limits[0].limit_w, 5000);
1093
+ assert.equal(secondMsg.payload.limits[0].allocation_deadband_applied, true);
1094
+ });
1095
+
1096
+ test("multi-inverter filters current-power weights without changing raw PV telemetry", function () {
1097
+ const node = createRuntimeNode("../multi-inverter/multi-inverter", "multi-inverter", {
1098
+ strategy: "current_power",
1099
+ waitForFreshTelemetryOnStartup: false,
1100
+ telemetryTimeoutMs: 0,
1101
+ siteLimitTimeoutMs: 0,
1102
+ telemetryFilterAlpha: 0.25,
1103
+ telemetryRecalcDebounceMs: 0,
1104
+ allocationSmoothingAlpha: 1,
1105
+ allocationDeadbandW: 0,
1106
+ publishTotalPvGlobal: true,
1107
+ totalPvGlobalVar: "total_pv",
1108
+ debugMode: "off",
1109
+ inverters: [
1110
+ { id: "inv1", minW: 0, maxW: 10000, enabled: true },
1111
+ { id: "inv2", minW: 0, maxW: 10000, enabled: true }
1112
+ ]
1113
+ });
1114
+
1115
+ let telemetryTs = Date.now();
1116
+ function input(id, powerW) {
1117
+ telemetryTs += 1;
1118
+ node.emit("input", { topic: id, source: "fronius", timestamp_ms: telemetryTs, payload: { inverter_id: id, power_w: powerW } }, node.send.bind(node), function (error) { if (error) throw error; });
1119
+ }
1120
+ input("inv1", 8000);
1121
+ input("inv2", 2000);
1122
+ node.emit("input", { topic: "site-limit", payload: { site_limit_w: 10000 } }, node.send.bind(node), function (error) { if (error) throw error; });
1123
+
1124
+ input("inv1", 0);
1125
+ const aggregate = Array.isArray(node.sent) ? node.sent[0] : node.sent;
1126
+ const inv1 = aggregate.payload.limits.find(function (item) { return item.id === "inv1"; });
1127
+ assert.equal(inv1.current_power_w, 0);
1128
+ assert.equal(inv1.allocation_power_w, 6000);
1129
+ assert.equal(inv1.limit_w, 7500);
1130
+ assert.equal(node.context().global.get("total_pv"), 2000);
1131
+ });
1132
+
1133
+ test("fresh grid setpoint bypasses the normal control period and propagates metadata", function () {
1134
+ const controlNode = createRuntimeNode("../control-export/control-export", "control-export", {
1135
+ mode: "grid_set_point",
1136
+ gridSetPoint: -8000,
1137
+ staleTimeoutMs: 2000,
1138
+ fastSetpointMaxTelemetryAgeMs: 600,
1139
+ controlPeriodMs: 60000,
1140
+ startLimitMode: "last_limit",
1141
+ minLimit: 0,
1142
+ maxLimit: 10000,
1143
+ __localContext: { control_export_last_limit_w: 5000 }
1144
+ });
1145
+
1146
+ const measurementTs = Date.now();
1147
+ controlNode.emit("input", {
1148
+ timestamp_ms: measurementTs,
1149
+ payload: { power_w: -8500 }
1150
+ }, controlNode.send.bind(controlNode), function (error) { if (error) throw error; });
1151
+
1152
+ const requestedAtMs = Date.now();
1153
+ controlNode.emit("input", {
1154
+ topic: "control-export/setpoint",
1155
+ timestamp_ms: requestedAtMs,
1156
+ payload: {
1157
+ grid_set_point_w: -7000,
1158
+ command_id: "setpoint-command-1"
1159
+ }
1160
+ }, controlNode.send.bind(controlNode), function (error) { if (error) throw error; });
1161
+
1162
+ const siteLimitMsg = Array.isArray(controlNode.sent) ? controlNode.sent[0] : controlNode.sent;
1163
+ assert.equal(Object.prototype.hasOwnProperty.call(siteLimitMsg.payload, "fast_path"), false);
1164
+ assert.equal(siteLimitMsg.payload.command_id, "setpoint-command-1");
1165
+ assert.equal(siteLimitMsg.payload.grid_target_w, -7000);
1166
+ assert.ok(siteLimitMsg.payload.control_calculated_at_ms >= requestedAtMs);
1167
+
1168
+ const inverterNode = createRuntimeNode("../multi-inverter/multi-inverter", "multi-inverter", {
1169
+ strategy: "max_power",
1170
+ waitForFreshTelemetryOnStartup: false,
1171
+ telemetryTimeoutMs: 0,
1172
+ siteLimitTimeoutMs: 0,
1173
+ publishTotalPvGlobal: false,
1174
+ debugMode: "off",
1175
+ inverters: [{ id: "inv1", minW: 0, maxW: 10000, enabled: true }]
1176
+ });
1177
+ inverterNode.emit("input", siteLimitMsg, inverterNode.send.bind(inverterNode), function (error) {
1178
+ if (error) throw error;
1179
+ });
1180
+
1181
+ const aggregateMsg = Array.isArray(inverterNode.sent) ? inverterNode.sent[0] : inverterNode.sent;
1182
+ assert.equal(aggregateMsg.payload.command_id, "setpoint-command-1");
1183
+ assert.equal(Object.prototype.hasOwnProperty.call(aggregateMsg.payload, "fast_path"), false);
1184
+ assert.equal(aggregateMsg.payload.limits[0].command_id, "setpoint-command-1");
1185
+ assert.equal(Object.prototype.hasOwnProperty.call(aggregateMsg.payload.limits[0], "fast_path"), false);
1186
+ assert.ok(aggregateMsg.payload.limits[0].allocated_at_ms >= requestedAtMs);
1187
+ });
1188
+
1189
+ test("control-export honors positive-export sign convention with normal preset", function () {
1190
+ const node = createRuntimeNode("../control-export/control-export", "control-export", {
1191
+ mode: "zero_feed_in",
1192
+ tuningPreset: "normal",
1193
+ signConvention: "positive_export",
1194
+ staleTimeoutMs: 0,
1195
+ startLimitMode: "last_limit",
1196
+ minLimit: 0,
1197
+ maxLimit: 10000,
1198
+ debugLevel: "verbose",
1199
+ __localContext: { control_export_last_limit_w: 5000 }
1200
+ });
1201
+
1202
+ node.emit("input", { payload: { power_w: 1000 } }, node.send.bind(node), function (error) {
1203
+ if (error) throw error;
1204
+ });
1205
+
1206
+ const msg = Array.isArray(node.sent) ? node.sent[0] : node.sent;
1207
+ assert.ok(msg.payload.site_limit_w < 5000);
1208
+ assert.equal(msg.payload.sign_convention, "positive_export");
1209
+ });
1210
+
1211
+ test("control-export prefers explicit grid reference over generic power_w", function () {
1212
+ const node = createRuntimeNode("../control-export/control-export", "control-export", {
1213
+ mode: "zero_feed_in",
1214
+ staleTimeoutMs: 0,
1215
+ startLimitMode: "last_limit",
1216
+ minLimit: 0,
1217
+ maxLimit: 10000,
1218
+ debugLevel: "verbose",
1219
+ __localContext: { control_export_last_limit_w: 5000 }
1220
+ });
1221
+
1222
+ node.emit("input", {
1223
+ payload: { grid_reference_w: -1000, power_w: 5000 }
1224
+ }, node.send.bind(node), function (error) { if (error) throw error; });
1225
+
1226
+ const msg = Array.isArray(node.sent) ? node.sent[0] : node.sent;
1227
+ assert.equal(msg.payload.raw_grid_reference_w, -1000);
1228
+ assert.ok(msg.payload.site_limit_w < 5000);
1229
+ });
1230
+
1231
+ test("control-export uses asymmetric export and import control bands", function () {
1232
+ const node = createRuntimeNode("../control-export/control-export", "control-export", {
1233
+ mode: "zero_feed_in",
1234
+ staleTimeoutMs: 0,
1235
+ deadband: 180,
1236
+ exportThresholdW: 60,
1237
+ recoverThresholdW: 25,
1238
+ startLimitMode: "last_limit",
1239
+ minLimit: 0,
1240
+ maxLimit: 10000,
1241
+ debugLevel: "verbose",
1242
+ __localContext: { control_export_last_limit_w: 5000 }
1243
+ });
1244
+
1245
+ node.emit("input", { payload: { power_w: -100 } }, node.send.bind(node), function (error) {
1246
+ if (error) throw error;
1247
+ });
1248
+
1249
+ const msg = Array.isArray(node.sent) ? node.sent[0] : node.sent;
1250
+ assert.equal(msg.payload.export_control_band_w, 60);
1251
+ assert.equal(msg.payload.import_control_band_w, 25);
1252
+ assert.equal(msg.payload.reason, "export_limit");
1253
+ });
1254
+
1255
+ test("control-export applies PI gains and anti-windup diagnostics", function () {
1256
+ const node = createRuntimeNode("../control-export/control-export", "control-export", {
1257
+ mode: "grid_set_point",
1258
+ gridSetPoint: 0,
1259
+ staleTimeoutMs: 0,
1260
+ filterTauMs: 0,
1261
+ adaptiveGains: false,
1262
+ kp: 0.3,
1263
+ ki: 0.05,
1264
+ startLimitMode: "last_limit",
1265
+ minLimit: 0,
1266
+ maxLimit: 10000,
1267
+ debugLevel: "verbose",
1268
+ __localContext: { control_export_last_limit_w: 5000 }
1269
+ });
1270
+
1271
+ node.emit("input", { payload: { power_w: -1000 } }, node.send.bind(node), function (error) {
1272
+ if (error) throw error;
1273
+ });
1274
+
1275
+ const msg = Array.isArray(node.sent) ? node.sent[0] : node.sent;
1276
+ assert.equal(msg.payload.effective_kp, 0.3);
1277
+ assert.equal(msg.payload.effective_ki, 0.05);
1278
+ assert.ok(msg.payload.p_term_w > 0);
1279
+ assert.ok(msg.payload.i_term_w > 0);
1280
+ });
1281
+
1282
+ test("control-export suppresses unchanged commands until heartbeat", function () {
1283
+ const realNow = Date.now;
1284
+ let nowMs = 1700000000000;
1285
+ Date.now = function () { return nowMs; };
1286
+ try {
1287
+ const node = createRuntimeNode("../control-export/control-export", "control-export", {
1288
+ mode: "zero_feed_in",
1289
+ staleTimeoutMs: 0,
1290
+ controlPeriodMs: 100,
1291
+ outputHeartbeatMs: 1000,
1292
+ startLimitMode: "last_limit",
1293
+ minLimit: 0,
1294
+ maxLimit: 10000,
1295
+ __localContext: { control_export_last_limit_w: 5000 }
1296
+ });
1297
+
1298
+ function meter() {
1299
+ node.emit("input", { timestamp_ms: nowMs, payload: { power_w: 0 } }, node.send.bind(node), function (error) {
1300
+ if (error) throw error;
1301
+ });
1302
+ }
1303
+
1304
+ meter();
1305
+ assert.equal(node.sentHistory.length, 1);
1306
+ nowMs += 100;
1307
+ meter();
1308
+ assert.equal(node.sentHistory.length, 1);
1309
+ nowMs += 1000;
1310
+ meter();
1311
+ assert.equal(node.sentHistory.length, 2);
1312
+ } finally {
1313
+ Date.now = realNow;
1314
+ }
1315
+ });
1316
+
1317
+ test("control-export restores a persisted runtime grid setpoint", function () {
1318
+ const node = createRuntimeNode("../control-export/control-export", "control-export", {
1319
+ mode: "grid_set_point",
1320
+ gridSetPoint: -2000,
1321
+ staleTimeoutMs: 0,
1322
+ minLimit: 0,
1323
+ maxLimit: 10000,
1324
+ __localContext: {
1325
+ control_export_last_limit_w: 5000,
1326
+ control_export_grid_setpoint_w: -3000,
1327
+ control_export_grid_setpoint_base_w: -2000
1328
+ }
1329
+ });
1330
+
1331
+ node.emit("input", { payload: { power_w: -3000 } }, node.send.bind(node), function (error) {
1332
+ if (error) throw error;
1333
+ });
1334
+ const msg = Array.isArray(node.sent) ? node.sent[0] : node.sent;
1335
+ assert.equal(msg.payload.grid_target_w, -3000);
1336
+ });
1337
+
1338
+ test("control-export emits safe output when a required global is invalid", function () {
1339
+ const node = createRuntimeNode("../control-export/control-export", "control-export", {
1340
+ mode: "zero_feed_in",
1341
+ maxLimitFromGlobal: true,
1342
+ maxLimitGlobalVar: "missing_max",
1343
+ staleTimeoutMs: 0,
1344
+ safeLimitOnStale: 0,
1345
+ minLimit: 0,
1346
+ maxLimit: 10000
1347
+ });
1348
+
1349
+ node.emit("input", { payload: { power_w: -1000 } }, node.send.bind(node), function (error) {
1350
+ if (error) throw error;
1351
+ });
1352
+ const msg = Array.isArray(node.sent) ? node.sent[0] : node.sent;
1353
+ assert.equal(msg.payload.reason, "invalid_global");
1354
+ assert.equal(msg.payload.site_limit_w, 0);
1355
+ assert.equal(msg.payload.stale, true);
1356
+ });
1357
+
1358
+ test("control-export marks an unreachable export target at minimum limit", function () {
1359
+ const node = createRuntimeNode("../control-export/control-export", "control-export", {
1360
+ mode: "zero_feed_in",
1361
+ staleTimeoutMs: 0,
1362
+ minLimit: 0,
1363
+ maxLimit: 10000,
1364
+ startLimitMode: "last_limit",
1365
+ __localContext: { control_export_last_limit_w: 0 }
1366
+ });
1367
+
1368
+ node.emit("input", { payload: { power_w: -1000 } }, node.send.bind(node), function (error) {
1369
+ if (error) throw error;
1370
+ });
1371
+ const msg = Array.isArray(node.sent) ? node.sent[0] : node.sent;
1372
+ assert.equal(msg.payload.controller_state, "target_unreachable_at_min");
1373
+ });
1374
+
1375
+ test("control-export closed loop converges after actuator lag and load step", function () {
1376
+ const realNow = Date.now;
1377
+ let nowMs = 1700000000000;
1378
+ Date.now = function () { return nowMs; };
1379
+ try {
1380
+ const node = createRuntimeNode("../control-export/control-export", "control-export", {
1381
+ mode: "grid_set_point",
1382
+ gridSetPoint: -500,
1383
+ staleTimeoutMs: 0,
1384
+ filterTauMs: 1000,
1385
+ controlPeriodMs: 500,
1386
+ deadband: 80,
1387
+ exportThresholdW: 40,
1388
+ recoverThresholdW: 40,
1389
+ recoveryDelayMs: 300,
1390
+ transientIgnoreMs: 200,
1391
+ kp: 0.3,
1392
+ ki: 0.05,
1393
+ adaptiveGains: false,
1394
+ maxStepUpW: 400,
1395
+ maxStepDownW: 800,
1396
+ outputHeartbeatMs: 500,
1397
+ startLimitMode: "last_limit",
1398
+ minLimit: 0,
1399
+ maxLimit: 10000,
1400
+ __localContext: { control_export_last_limit_w: 6500 }
1401
+ });
1402
+
1403
+ let commandW = 6500;
1404
+ let actualPvW = 6500;
1405
+ let finalGridW = 0;
1406
+ for (let elapsed = 0; elapsed <= 60000; elapsed += 100) {
1407
+ nowMs = 1700000000000 + elapsed;
1408
+ const loadW = elapsed < 10000 ? 6000 : 4000;
1409
+ actualPvW += (commandW - actualPvW) * 0.1;
1410
+ finalGridW = loadW - actualPvW;
1411
+ node.emit("input", {
1412
+ timestamp_ms: nowMs,
1413
+ payload: { power_w: finalGridW }
1414
+ }, node.send.bind(node), function (error) { if (error) throw error; });
1415
+ const msg = Array.isArray(node.sent) ? node.sent[0] : node.sent;
1416
+ commandW = msg.payload.site_limit_w;
1417
+ }
1418
+
1419
+ const finalMsg = Array.isArray(node.sent) ? node.sent[0] : node.sent;
1420
+ assert.ok(
1421
+ Math.abs(finalGridW - (-500)) <= 100,
1422
+ "final grid=" + finalGridW + " command=" + commandW + " reason=" + finalMsg.payload.reason + " state=" + finalMsg.payload.controller_state
1423
+ );
1424
+ } finally {
1425
+ Date.now = realNow;
1426
+ }
1427
+ });
1428
+
1429
+ test("multi-inverter emits zero limits for duplicate configuration", function () {
1430
+ const node = createRuntimeNode("../multi-inverter/multi-inverter", "multi-inverter", {
1431
+ debugMode: "off",
1432
+ publishTotalPvGlobal: false,
1433
+ inverters: [
1434
+ { id: "inv1", minW: 0, maxW: 10000, enabled: true },
1435
+ { id: "inv1", minW: 0, maxW: 10000, enabled: true }
1436
+ ]
1437
+ });
1438
+ node.emit("input", {
1439
+ topic: "site-limit",
1440
+ payload: { site_limit_w: 5000 }
1441
+ }, node.send.bind(node), function (error) { if (error) throw error; });
1442
+
1443
+ const mainMsg = Array.isArray(node.sent) ? node.sent[0] : node.sent;
1444
+ assert.equal(mainMsg.topic, "multi-inverter/limits");
1445
+ assert.equal(mainMsg.payload.reason, "invalid_configuration");
1446
+ assert.equal(mainMsg.payload.limits.every(function (limit) { return limit.limit_w === 0; }), true);
1447
+ });
1448
+
1449
+ test("multi-inverter fails safe on future telemetry timestamp", function () {
1450
+ const node = createRuntimeNode("../multi-inverter/multi-inverter", "multi-inverter", {
1451
+ debugMode: "off",
1452
+ publishTotalPvGlobal: false,
1453
+ maxFutureTimestampMs: 100,
1454
+ inverters: [{ id: "inv1", minW: 0, maxW: 10000, enabled: true }]
1455
+ });
1456
+ node.emit("input", {
1457
+ topic: "inv1",
1458
+ timestamp_ms: Date.now() + 10000,
1459
+ payload: { inverter_id: "inv1", power_w: 5000 }
1460
+ }, node.send.bind(node), function (error) { if (error) throw error; });
1461
+
1462
+ const mainMsg = Array.isArray(node.sent) ? node.sent[0] : node.sent;
1463
+ assert.equal(mainMsg.payload.reason, "future_telemetry_timestamp");
1464
+ assert.equal(mainMsg.payload.limits[0].limit_w, 0);
1465
+ });
1466
+
1467
+ test("control-reactive rejects measurements too far in the future", function () {
1468
+ const node = createRuntimeNode("../control-reactive/control-reactive", "control-reactive", {
1469
+ staleTimeoutMs: 5000,
1470
+ maxFutureTimestampMs: 100,
1471
+ nominalReactivePowerVar: 25000
1472
+ });
1473
+ let doneError = null;
1474
+ node.emit("input", {
1475
+ timestamp_ms: Date.now() + 10000,
1476
+ payload: {
1477
+ reactive_l1_var: 100,
1478
+ reactive_l2_var: 100,
1479
+ reactive_l3_var: 100,
1480
+ reactive_total_var: 300
1481
+ }
1482
+ }, node.send.bind(node), function (error) { doneError = error || null; });
1483
+
1484
+ assert.ok(doneError);
1485
+ assert.match(doneError.message, /Timestamp din viitor/);
1486
+ });
1487
+
1488
+ test("control-reactive treats empty telemetry fields as missing", function () {
1489
+ const node = createRuntimeNode("../control-reactive/control-reactive", "control-reactive", {
1490
+ staleTimeoutMs: 0,
1491
+ nominalReactivePowerVar: 25000
1492
+ });
1493
+ let doneError = null;
1494
+ node.emit("input", {
1495
+ payload: {
1496
+ reactive_l1_var: "",
1497
+ reactive_l2_var: "",
1498
+ reactive_l3_var: "",
1499
+ reactive_total_var: ""
1500
+ }
1501
+ }, node.send.bind(node), function (error) { doneError = error || null; });
1502
+
1503
+ assert.ok(doneError);
1504
+ assert.match(doneError.message, /Lipsesc valori reactive meter/);
1505
+ });
1506
+
1507
+ test("control-reactive keeps the measurement filter after emitting a command", function () {
1508
+ const node = createRuntimeNode("../control-reactive/control-reactive", "control-reactive", {
1509
+ mode: "grid_set_point",
1510
+ tuningPreset: "custom",
1511
+ gridSetPointVar: 0,
1512
+ staleTimeoutMs: 0,
1513
+ filterTauMs: 1000,
1514
+ minCommandIntervalMs: 10000,
1515
+ commandDeadbandVar: 0,
1516
+ nominalReactivePowerVar: 25000
1517
+ });
1518
+
1519
+ const firstTimestamp = Date.now() - 1000;
1520
+ const firstPayload = {
1521
+ reactive_l1_var: 100,
1522
+ reactive_l2_var: 200,
1523
+ reactive_l3_var: 700,
1524
+ reactive_total_var: 1000
1525
+ };
1526
+ const secondPayload = {
1527
+ reactive_l1_var: 200,
1528
+ reactive_l2_var: 400,
1529
+ reactive_l3_var: 1400,
1530
+ reactive_total_var: 2000
1531
+ };
1532
+
1533
+ node.emit("input", {
1534
+ timestamp_ms: firstTimestamp,
1535
+ payload: firstPayload
1536
+ }, node.send.bind(node), function (error) { if (error) throw error; });
1537
+
1538
+ node.emit("input", {
1539
+ timestamp_ms: firstTimestamp + 200,
1540
+ payload: secondPayload
1541
+ }, node.send.bind(node), function (error) { if (error) throw error; });
1542
+
1543
+ assert.ok(node.lastStatus);
1544
+ assert.match(node.lastStatus.text, /q 1167VAr/);
1545
+ });
1546
+
1547
+ test("control-reactive stable grid preset applies conservative runtime values", function () {
1548
+ const node = createRuntimeNode("../control-reactive/control-reactive", "control-reactive", {
1549
+ mode: "grid_set_point",
1550
+ tuningPreset: "stable_grid",
1551
+ gridSetPointVar: 0,
1552
+ staleTimeoutMs: 0,
1553
+ nominalReactivePowerVar: 25000,
1554
+ debugLevel: "verbose"
1555
+ });
1556
+
1557
+ node.emit("input", {
1558
+ payload: {
1559
+ reactive_l1_var: 100,
1560
+ reactive_l2_var: 200,
1561
+ reactive_l3_var: 300,
1562
+ reactive_total_var: 600
1563
+ }
1564
+ }, node.send.bind(node), function (error) { if (error) throw error; });
1565
+
1566
+ const mainMsg = Array.isArray(node.sent) ? node.sent[0] : node.sent;
1567
+ assert.equal(mainMsg.payload.filter_tau_ms, 5000);
1568
+ assert.equal(mainMsg.payload.max_step_up_var, 120);
1569
+ assert.equal(mainMsg.payload.max_step_down_var, 120);
1570
+ });
1571
+
1572
+ test("control-export ignores inverter power telemetry as grid reference", function () {
1573
+ const node = createRuntimeNode("../control-export/control-export", "control-export", {
1574
+ mode: "grid_set_point",
1575
+ gridSetPoint: -5000,
1576
+ staleTimeoutMs: 0,
1577
+ minLimit: 0,
1578
+ maxLimit: 10000
1579
+ });
1580
+
1581
+ node.emit("input", {
1582
+ topic: "meter1",
1583
+ meter_id: "meter1",
1584
+ payload: { power_w: -5853 }
1585
+ }, node.send.bind(node), function (error) {
1586
+ if (error) throw error;
1587
+ });
1588
+
1589
+ const meterStatus = node.lastStatus && node.lastStatus.text;
1590
+ assert.match(meterStatus, /grid -5853W/);
1591
+
1592
+ node.emit("input", {
1593
+ topic: "inv1",
1594
+ inverter_id: "inv1",
1595
+ device_type: "fronius_inverter",
1596
+ payload: { power_w: 998 }
1597
+ }, node.send.bind(node), function (error) {
1598
+ if (error) throw error;
1599
+ });
1600
+
1601
+ assert.equal(node.lastStatus.text, meterStatus);
1602
+ });
1603
+
1604
+ test("control-export ignores inverter-like topic power telemetry", function () {
1605
+ const node = createRuntimeNode("../control-export/control-export", "control-export", {
1606
+ mode: "grid_set_point",
1607
+ gridSetPoint: -5000,
1608
+ staleTimeoutMs: 0,
1609
+ minLimit: 0,
1610
+ maxLimit: 10000
1611
+ });
1612
+
1613
+ node.emit("input", {
1614
+ topic: "meter1",
1615
+ meter_id: "meter1",
1616
+ payload: { power_w: -5853 }
1617
+ }, node.send.bind(node), function (error) {
1618
+ if (error) throw error;
1619
+ });
1620
+
1621
+ const meterStatus = node.lastStatus && node.lastStatus.text;
1622
+ assert.match(meterStatus, /grid -5853W/);
1623
+
1624
+ node.emit("input", {
1625
+ topic: "inv1",
1626
+ payload: { power_w: 998 }
1627
+ }, node.send.bind(node), function (error) {
1628
+ if (error) throw error;
1629
+ });
1630
+
1631
+ assert.equal(node.lastStatus.text, meterStatus);
1632
+ });
1633
+
1634
+ test("control-export ignores site limit command feedback", function () {
1635
+ const node = createRuntimeNode("../control-export/control-export", "control-export", {
1636
+ mode: "grid_set_point",
1637
+ gridSetPoint: -5000,
1638
+ outputTopic: "site-limit",
1639
+ staleTimeoutMs: 0,
1640
+ minLimit: 0,
1641
+ maxLimit: 10000
1642
+ });
1643
+
1644
+ node.emit("input", {
1645
+ topic: "meter1",
1646
+ meter_id: "meter1",
1647
+ payload: { power_w: -5853 }
1648
+ }, node.send.bind(node), function (error) {
1649
+ if (error) throw error;
1650
+ });
1651
+
1652
+ const meterStatus = node.lastStatus && node.lastStatus.text;
1653
+
1654
+ node.emit("input", {
1655
+ topic: "site-limit",
1656
+ payload: {
1657
+ site_limit_w: 997,
1658
+ grid_reference_w: 998,
1659
+ grid_target_w: -5000,
1660
+ controller_state: "hold_period"
1661
+ }
1662
+ }, node.send.bind(node), function (error) {
1663
+ if (error) throw error;
1664
+ });
1665
+
1666
+ assert.equal(node.lastStatus.text, meterStatus);
1667
+ });
1668
+
1669
+ test("control-export watchdog emits safe limit even before first telemetry", async function () {
1670
+ const node = createRuntimeNode("../control-export/control-export", "control-export", {
1671
+ mode: "zero_feed_in",
1672
+ staleTimeoutMs: 60,
1673
+ safeLimitOnStale: 0,
1674
+ minLimit: 0,
1675
+ maxLimit: 1000
1676
+ });
1677
+
1678
+ await new Promise(function (resolve) {
1679
+ setTimeout(resolve, 420);
1680
+ });
1681
+
1682
+ assert.ok(node.sent);
1683
+ const mainMsg = Array.isArray(node.sent) ? node.sent[0] : node.sent;
1684
+ assert.equal(mainMsg.payload.reason, "watchdog_stale");
1685
+ assert.equal(mainMsg.payload.site_limit_w, 0);
1686
+
1687
+ await new Promise(function (resolve) {
1688
+ node.emit("close", resolve);
1689
+ });
1690
+ });
1691
+
1692
+ test("victron-read decodes MQTT values and detects Portal ID", function () {
1693
+ assert.equal(decodePayload(Buffer.from('{"value":12.5}')), 12.5);
1694
+ assert.equal(decodePayload(Buffer.from("null")), null);
1695
+ assert.equal(decodePayload(Buffer.from("true")), true);
1696
+ assert.equal(detectPortalId("N/abc123/system/0/Serial"), "abc123");
1697
+ });
1698
+
1699
+ test("victron-read normalizes runtime MQTT messages consistently", function () {
1700
+ const snapshot = {};
1701
+ const updatedAt = new Map();
1702
+ const first = mergeDecodedMessage(snapshot, updatedAt, "N/abc123/grid/30/Ac/Power", 500, {
1703
+ topicPathMode: "victron",
1704
+ stripPortalId: true,
1705
+ now: 1000
1706
+ });
1707
+ const second = mergeDecodedMessage(snapshot, updatedAt, "N/abc123/ignored", {
1708
+ "N.abc123.battery.512.Dc.0.Voltage": 52.4,
1709
+ "N.abc123.battery.512.Soc": 81,
1710
+ "N.abc123.system.0.Ac.Grid.L1.Power": 123
1711
+ }, {
1712
+ topicPathMode: "victron",
1713
+ stripPortalId: true,
1714
+ now: 1100
1715
+ });
1716
+
1717
+ assert.equal(first.portalId, "abc123");
1718
+ assert.equal(first.changed, true);
1719
+ assert.equal(second.changed, true);
1720
+ assert.equal(snapshot.grid["30"].Ac.Power, 500);
1721
+ assert.equal(snapshot["battery.512.Dc.0.Voltage"], 52.4);
1722
+ assert.equal(snapshot["battery.512.Soc"], 81);
1723
+ });
1724
+
1725
+ test("victron-read expires stale MQTT values", function () {
1726
+ const snapshot = {};
1727
+ const updatedAt = new Map();
1728
+ mergeDecodedMessage(snapshot, updatedAt, "N/portal/grid/30/Ac/Power", 400, {
1729
+ topicPathMode: "victron",
1730
+ stripPortalId: true,
1731
+ now: 1000
1732
+ });
1733
+ mergeDecodedMessage(snapshot, updatedAt, "N/portal/battery/512/Soc", 75, {
1734
+ topicPathMode: "victron",
1735
+ stripPortalId: true,
1736
+ now: 1900
1737
+ });
1738
+
1739
+ const removed = pruneStale(snapshot, updatedAt, 500, 2100);
1740
+ assert.deepEqual(removed, ["grid.30.Ac.Power"]);
1741
+ assert.equal(snapshot.grid, undefined);
1742
+ assert.equal(snapshot.battery["512"].Soc, 75);
1743
+ });
1744
+
1745
+ test("fronius-read parses SunSpec integer smart meter model", function () {
1746
+ const raw = new Array(105).fill(0);
1747
+ raw[4] = 0xFFFF; // A_SF = -1
1748
+ raw[13] = 0; // V_SF
1749
+ raw[15] = 0xFFFE; // Hz_SF = -2
1750
+ raw[20] = 0; // W_SF
1751
+ raw[25] = 0; // VA_SF
1752
+ raw[30] = 0; // VAR_SF
1753
+ raw[35] = 0xFFFE; // PF_SF = -2
1754
+ raw[52] = 0; // WH_SF
1755
+ raw[0] = 123;
1756
+ raw[6] = 230;
1757
+ raw[14] = 5001;
1758
+ raw[16] = 1500;
1759
+ raw[31] = 99;
1760
+ raw[44] = 0;
1761
+ raw[45] = 1000;
1762
+
1763
+ const summary = buildSummary([{ model_id: 203, addr: 40070, len: 105, raw }]);
1764
+
1765
+ assert.equal(summary.meter.model_id, 203);
1766
+ assert.equal(summary.meter.meter_model, "three_phase_wye");
1767
+ assert.equal(summary.meter.current_total_a, 12.3);
1768
+ assert.equal(summary.meter.voltage_l1_v, 230);
1769
+ assert.equal(summary.meter.frequency_hz, 50.01);
1770
+ assert.equal(summary.meter.power_w, 1500);
1771
+ assert.equal(summary.meter.power_factor, 0.99);
1772
+ assert.equal(summary.meter.energy_import_wh, 1000);
1773
+ });
1774
+
1775
+ test("fronius-read parses SunSpec float smart meter model", function () {
1776
+ function putFloat(raw, index, value) {
1777
+ const buffer = Buffer.allocUnsafe(4);
1778
+ buffer.writeFloatBE(value, 0);
1779
+ raw[index] = buffer.readUInt16BE(0);
1780
+ raw[index + 1] = buffer.readUInt16BE(2);
1781
+ }
1782
+
1783
+ const raw = new Array(124).fill(0);
1784
+ putFloat(raw, 0, 12.5);
1785
+ putFloat(raw, 10, 230.25);
1786
+ putFloat(raw, 24, 50);
1787
+ putFloat(raw, 26, -450.5);
1788
+ putFloat(raw, 66, 12345);
1789
+
1790
+ const summary = buildSummary([{ model_id: 213, addr: 40070, len: 124, raw }]);
1791
+
1792
+ assert.equal(summary.meter.model_id, 213);
1793
+ assert.equal(summary.meter.meter_model, "three_phase_wye");
1794
+ assert.equal(summary.meter.current_total_a, 12.5);
1795
+ assert.equal(summary.meter.voltage_l1_v, 230.25);
1796
+ assert.equal(summary.meter.frequency_hz, 50);
1797
+ assert.equal(summary.meter.power_w, -450.5);
1798
+ assert.equal(summary.meter.energy_import_wh, 12345);
1799
+ });
1800
+
1801
+ test("scada-server encodes swapped Modbus register order", function () {
1802
+ assert.deepEqual(
1803
+ encodeMetric(0x01020304, "uint32", 1, { wordOrder: "lsw_first" }),
1804
+ [0x0304, 0x0102]
1805
+ );
1806
+ assert.deepEqual(
1807
+ encodeMetric(0x01020304, "uint32", 1, { byteOrder: "little_endian" }),
1808
+ [0x0201, 0x0403]
1809
+ );
1810
+ });
1811
+
1812
+ test("scada-server reports saturated integer values", function () {
1813
+ const encoded = encodeMetricDetailed(999999, "int16", 1);
1814
+ assert.deepEqual(encoded.words, [32767]);
1815
+ assert.equal(encoded.issues.length, 1);
1816
+ assert.equal(encoded.issues[0].code, "value_saturated");
1817
+ });
1818
+
1819
+ test("scada-server rejects register ranges past Modbus limit", function () {
1820
+ const issues = validateMetricConfigs([
1821
+ {
1822
+ key: "bad",
1823
+ label: "Bad",
1824
+ address: 65535,
1825
+ type: "int32",
1826
+ scale: 1,
1827
+ path: "payload.value",
1828
+ signMode: "as_is",
1829
+ byteOrder: "big_endian",
1830
+ wordOrder: "msw_first"
1831
+ }
1832
+ ]);
1833
+
1834
+ assert.equal(issues.some(function (issue) {
1835
+ return issue.code === "address_out_of_range";
1836
+ }), true);
1837
+ });
1838
+
1839
+ test("scada-server validates register byte and word order", function () {
1840
+ const issues = validateMetricConfigs([
1841
+ {
1842
+ key: "bad_order",
1843
+ label: "Bad order",
1844
+ address: 0,
1845
+ type: "int32",
1846
+ scale: 1,
1847
+ path: "payload.value",
1848
+ signMode: "as_is",
1849
+ byteOrder: "bad",
1850
+ wordOrder: "also_bad"
1851
+ }
1852
+ ]);
1853
+
1854
+ assert.equal(issues.some(function (issue) { return issue.code === "invalid_byte_order"; }), true);
1855
+ assert.equal(issues.some(function (issue) { return issue.code === "invalid_word_order"; }), true);
1856
+ });
1857
+
1858
+ test("scada-server reports encoding issues while building register image", function () {
1859
+ const issues = [];
1860
+ const image = buildRegisterImage([
1861
+ {
1862
+ key: "v",
1863
+ label: "V",
1864
+ address: 0,
1865
+ type: "int16",
1866
+ scale: 1,
1867
+ path: "payload.value",
1868
+ signMode: "as_is",
1869
+ byteOrder: "big_endian",
1870
+ wordOrder: "msw_first"
1871
+ }
1872
+ ], { v: 999999 }, function (issue) {
1873
+ issues.push(issue);
1874
+ });
1875
+
1876
+ assert.equal(image[0], 32767);
1877
+ assert.equal(issues[0].code, "value_saturated");
1878
+ assert.equal(issues[0].key, "v");
1879
+ });
1880
+
1881
+ test("scada aggregator generates stable complete maps and averages voltage", function () {
1882
+ const metrics = buildAggregatorMetrics({
1883
+ aggregatorPrefix: "payload.scada",
1884
+ aggregatorMeterCount: 3,
1885
+ aggregatorFroniusCount: 13,
1886
+ aggregatorIncludeVictron: true,
1887
+ aggregatorVoltageProfile: "complete"
1888
+ });
1889
+ assert.equal(metrics.length, 269);
1890
+ assert.equal(new Set(metrics.map(function (metric) { return metric.key; })).size, metrics.length);
1891
+ assert.equal(metrics.find(function (metric) { return metric.key === "grid_meter_01_p"; }).address, 0);
1892
+ assert.equal(metrics.find(function (metric) { return metric.key === "pv_inverter_01_p"; }).address, 96);
1893
+ assert.equal(metrics.find(function (metric) { return metric.key === "total_site_p"; }).path, "payload.scada.totals.site.p");
1894
+
1895
+ const values = normalizeScadaAggregateMessage({ payload: { summary: { ac: {
1896
+ power_w: 100, reactive_power_var: 20, voltage_l1_v: 230, voltage_l2_v: 231, voltage_l3_v: 229, frequency_hz: 50
1897
+ } } } });
1898
+ const totals = summarizeScadaValues([{ values: values }, { values: Object.assign({}, values, { p: 200, q: 10 }) }]);
1899
+ assert.equal(values.u_ln_avg, 230);
1900
+ assert.equal(totals.p, 300);
1901
+ assert.equal(totals.q, 30);
1902
+ assert.equal(totals.u_ln_avg, 230);
1903
+ });
1904
+
1905
+ test("scada aggregator excludes zero telemetry from phase and fleet averages", function () {
1906
+ const healthy = normalizeScadaAggregateMessage({ payload: { p: 5000, q: 100, u_l1n: 230, u_l2n: 231, u_l3n: 229, u_l12: 400, u_l23: 401, u_l31: 399, f: 50 } });
1907
+ const faulty = normalizeScadaAggregateMessage({ payload: { p: 0, q: 0, u_l1n: 0, u_l2n: 0, u_l3n: 0, u_l12: 0, u_l23: 0, u_l31: 0, f: 0 } });
1908
+ const partial = normalizeScadaAggregateMessage({ payload: { u_l1n: 230, u_l2n: 0, u_l3n: 232, u_l12: 400, u_l23: 0, u_l31: 402 } });
1909
+ const totals = summarizeScadaValues([{ values: healthy }, { values: faulty }]);
1910
+
1911
+ assert.equal(partial.u_ln_avg, 231);
1912
+ assert.equal(partial.u_ll_avg, 401);
1913
+ assert.equal(faulty.u_ln_avg, null);
1914
+ assert.equal(faulty.u_ll_avg, null);
1915
+ assert.equal(totals.u_l1n, 230);
1916
+ assert.equal(totals.u_l2n, 231);
1917
+ assert.equal(totals.u_l3n, 229);
1918
+ assert.equal(totals.u_ln_avg, 230);
1919
+ assert.equal(totals.u_ll_avg, 400);
1920
+ assert.equal(totals.f, 50);
1921
+ assert.equal(totals.p, 5000);
1922
+ assert.equal(totals.q, 100);
1923
+ });
1924
+
1925
+ test("scada aggregator keeps sources separate and emits a normalized snapshot", function () {
1926
+ const node = createRuntimeNode("../scada-aggregator/scada-aggregator.js", "scada-aggregator", {
1927
+ aggregatorGroups: JSON.stringify([{ id: "grid_meter", label: "Grid meter", count: 1 }, { id: "pv_inverter", label: "PV inverter", count: 1 }]), emitIntervalMs: 0, staleAfterMs: 5000, sourceIdPath: "scadaSource", siteTotalSource: "grid_meter"
1928
+ });
1929
+ let output;
1930
+ node.emit("input", { scadaGroup: "grid_meter", scadaSource: "grid_meter", payload: { p: 100, q: 20, u_l12: 400, u_l23: 401, u_l31: 399, f: 50 } }, function (messages) { output = messages; }, function (error) { if (error) throw error; });
1931
+ assert.equal(output[0].payload.scada.grid_meter.grid_meter.p, 100);
1932
+ assert.equal(output[0].payload.scada.totals.grid_meter.p, 100);
1933
+ node.emit("input", { scadaGroup: "pv_inverter", scadaSource: "pv_inverter", payload: { p: 200, q: 10, u_l12: 402, u_l23: 402, u_l31: 402, f: 50.01 } }, function (messages) { output = messages; }, function (error) { if (error) throw error; });
1934
+ assert.equal(output[0].payload.scada.pv_inverter.pv_inverter.p, 200);
1935
+ assert.equal(output[0].payload.scada.totals.site.grid.p, 100);
1936
+ assert.equal(output[0].payload.scada.totals.site.pv.p, 200);
1937
+ assert.equal(output[0].payload.scada.totals.pv_inverter.p, 200);
1938
+ });
1939
+
1940
+ test("sma-read splits long SunSpec models into valid Modbus requests", async function () {
1941
+ const calls = [];
1942
+ const client = {
1943
+ readHoldingRegisters: async function (address, length) {
1944
+ calls.push({ address: address, length: length });
1945
+ return { data: new Array(length).fill(address) };
1946
+ }
1947
+ };
1948
+ const response = await readSmaSunSpecHolding(client, 40000, 250);
1949
+ assert.deepEqual(calls, [
1950
+ { address: 40000, length: 120 },
1951
+ { address: 40120, length: 120 },
1952
+ { address: 40240, length: 10 }
1953
+ ]);
1954
+ assert.equal(response.data.length, 250);
1955
+ });
1956
+
1957
+ test("sma-read rejects corrupt SunSpec model lengths", async function () {
1958
+ const client = {
1959
+ readHoldingRegisters: async function () {
1960
+ return { data: [1, 0] };
1961
+ }
1962
+ };
1963
+ await assert.rejects(scanSmaModelDescriptors(client, 40000), /Invalid SunSpec model length/);
1964
+ });
1965
+
1966
+ test("package registers sma-read Node-RED node", function () {
1967
+ const packageJson = JSON.parse(fs.readFileSync(require.resolve("../package.json"), "utf8").replace(/^\uFEFF/, ""));
1968
+ const runtime = fs.readFileSync(require.resolve("../sma-read/sma-read.js"), "utf8");
1969
+ const editor = fs.readFileSync(require.resolve("../sma-read/sma-read.html"), "utf8");
1970
+ assert.equal(packageJson["node-red"].nodes["sma-read"], "sma-read/sma-read.js");
1971
+ assert.doesNotMatch(runtime, /\.\.\/fronius-read\//);
1972
+ assert.match(runtime, /require\("\.\/lib\/sunspec-core"\)/);
1973
+ assert.match(runtime, /require\("\.\/lib\/sunspec-model-parsers"\)/);
1974
+ assert.ok(fs.existsSync(require.resolve("../sma-read/lib/sunspec-core.js")));
1975
+ assert.ok(fs.existsSync(require.resolve("../sma-read/lib/sunspec-model-parsers.js")));
1976
+ assert.match(runtime, /node\.controlExport = !!config\.controlExport/);
1977
+ assert.match(runtime, /node\.multiGrid = !!config\.multiGrid/);
1978
+ assert.match(runtime, /node\.multiInverterUseSelectedFields/);
1979
+ assert.match(runtime, /node\.multiGridUseSelectedFields/);
1980
+ assert.match(runtime, /Inverter ID este obligatoriu/);
1981
+ assert.match(runtime, /node\.unitId = atLeast\(config\.unitId, 1, 126\)/);
1982
+ assert.match(runtime, /RED\.httpAdmin\.get\("\/sma-read\/probe"/);
1983
+ assert.match(runtime, /configuredUnitId, 126, 3, 1/);
1984
+ assert.match(runtime, /SUPPORTED_MODEL_IDS/);
1985
+ assert.match(runtime, /supportedDescriptors\(sunspecDescriptors\)/);
1986
+ assert.match(editor, /Model detectat:/);
1987
+ assert.match(runtime, /RED\.httpAdmin\.get\("\/sma-read\/state\/:id"/);
1988
+ assert.match(runtime, /nodeRuntimeState\.set\(node\.id/);
1989
+ assert.match(editor, /id="node-input-controlExport"/);
1990
+ assert.match(editor, /id="node-input-multiGrid"/);
1991
+ assert.match(editor, /id="node-input-influxDb"/);
1992
+ assert.match(editor, /id="node-input-debugMode"/);
1993
+ assert.match(editor, /unitId:\s*\{\s*value:\s*126/);
1994
+ assert.match(editor, /node-input-host[\s\S]*node-input-name/);
1995
+ assert.match(editor, /EXPERT_LONG_PRESS_MS = 5000/);
1996
+ assert.match(editor, /multiInverterUseSelectedFields:\s*\{\s*value:\s*false/);
1997
+ assert.match(editor, /multiGridUseSelectedFields:\s*\{\s*value:\s*false/);
1998
+ assert.match(editor, /return !enabled \|\| !!String\(value \|\| ""\)\.trim\(\)/);
1999
+ assert.match(editor, /host \+ " \[" \+ inverterId \+ "\]"/);
2000
+ assert.match(editor, /function persistFields\(fields\)/);
2001
+ assert.match(editor, /expertPayloadFields:\s*\{\s*value:\s*"\[\]"/);
2002
+ assert.match(editor, /savedExpertFields/);
2003
+ assert.match(editor, /node-input-expertPayloadFields/);
2004
+ assert.match(runtime, /payload_fields: selectedFields\.slice\(\)/);
2005
+ assert.match(editor, /Array\.isArray\(data\.payload_fields\)/);
2006
+ assert.match(editor, /persistFields\(this\.payloadFields\)/);
2007
+ assert.match(editor, /id="node-input-multiInverterUseSelectedFields"/);
2008
+ assert.match(editor, /id="node-input-multiGridUseSelectedFields"/);
2009
+ assert.match(editor, /function persistIntegrationSelectionMode\(\)/);
2010
+ assert.match(editor, /const savedPayloadFields = parseFields\(node\.payloadFields\)/);
2011
+ assert.match(editor, /const customFields = payloadSelectionState\.filter/);
2012
+ assert.match(editor, /Detectare in curs\. Campurile apar dupa identificarea dispozitivului SunSpec\./);
2013
+ assert.match(editor, /\$\.getJSON\("sma-read\/probe"/);
2014
+ assert.match(editor, /\$\.getJSON\("sma-read\/state\/"/);
2015
+ assert.match(editor, /differsFromPreset/);
2016
+ assert.match(editor, /--sma-accent:#344054/);
2017
+ assert.match(editor, /color:\s*"#9ebbd1"/);
2018
+ assert.match(editor, /data-help-name="sma-read"[\s\S]*<h3>Detectare SunSpec<\/h3>/);
2019
+ assert.match(editor, /<h3>Moduri de integrare<\/h3>/);
2020
+ assert.match(editor, /<h3>Stare si erori<\/h3>/);
2021
+ assert.doesNotMatch(editor, /Expert Mode|node-sma-expert-note|Tine apasat/i);
2022
+ assert.doesNotMatch(editor, /node-input-protocolProfile|id="node-sma-probe"|Test & Detect|Registre SMA custom/);
2023
+ });
2024
+
2025
+ test("package registers sma-write with SMA SunSpec defaults", function () {
2026
+ const packageJson = JSON.parse(fs.readFileSync(require.resolve("../package.json"), "utf8").replace(/^\uFEFF/, ""));
2027
+ const runtime = fs.readFileSync(require.resolve("../sma-write/sma-write.js"), "utf8");
2028
+ const editor = fs.readFileSync(require.resolve("../sma-write/sma-write.html"), "utf8");
2029
+ assert.equal(packageJson["node-red"].nodes["sma-write"], "sma-write/sma-write.js");
2030
+ assert.match(runtime, /registerType\("sma-write", SmaWriteNode\)/);
2031
+ assert.match(runtime, /atLeast\(config\.unitId, 1, 126\)/);
2032
+ assert.match(editor, /unitId:\s*\{\s*value:\s*126/);
2033
+ assert.match(runtime, /RED\.httpAdmin\.get\("\/sma-write\/probe"/);
2034
+ assert.match(runtime, /async function scanModels/);
2035
+ assert.match(runtime, /id === MODEL_123/);
2036
+ assert.match(runtime, /client\.writeRegisters\(setpointAddr, \[item\.raw\]\)/);
2037
+ assert.match(runtime, /client\.writeRegisters\(enableAddr, \[1\]\)/);
2038
+ assert.match(runtime, /Controlul reactiv nu este disponibil/);
2039
+ assert.match(runtime, /write_skipped_duplicate: duplicate/);
2040
+ assert.match(runtime, /accepted_unverified: true/);
2041
+ assert.match(runtime, /runtime_version: RUNTIME_VERSION/);
2042
+ assert.doesNotMatch(runtime, /reactive_power|rampRegisterValue|otherEnableAddr|verifyRegister/);
2043
+ assert.doesNotMatch(editor, /node-sma-write-probe|Test &amp; Detect/);
2044
+ assert.doesNotMatch(runtime, /SunSpec 123 \| v/);
2045
+ assert.match(editor, /pr-grid pr-grid-2/);
2046
+ assert.match(editor, /data-template-name="sma-write"/);
2047
+ assert.match(editor, /color:\s*"#b5a2c8"/);
2048
+ assert.doesNotMatch(runtime, /fronius/i);
2049
+ });
2050
+
2051
+ test("package registers modbus-tcp-discover network scanner", function () {
2052
+ const packageJson = require("../package.json");
2053
+ const runtime = fs.readFileSync(require.resolve("../modbus-tcp-discover/modbus-tcp-discover.js"), "utf8");
2054
+ const editor = fs.readFileSync(require.resolve("../modbus-tcp-discover/modbus-tcp-discover.html"), "utf8");
2055
+ assert.equal(packageJson["node-red"].nodes["modbus-tcp-discover"], "modbus-tcp-discover/modbus-tcp-discover.js");
2056
+ assert.match(runtime, /registerType\("modbus-tcp-discover", DiscoverNode\)/);
2057
+ assert.match(runtime, /const MAX_HOSTS = 4096/);
2058
+ assert.match(runtime, /const MAX_PORTS = 32/);
2059
+ assert.match(runtime, /socket\.connect\(\{ host: host, port: port \}\)/);
2060
+ assert.match(runtime, /msg && msg\.cancel/);
2061
+ assert.match(runtime, /probeModbus/);
2062
+ assert.match(runtime, /parseDeviceIdentification/);
2063
+ assert.match(runtime, /readSunSpecCommon/);
2064
+ assert.match(runtime, /profileDefaults/);
2065
+ assert.match(runtime, /sunspec_only/);
2066
+ assert.match(runtime, /probeMqtt/);
2067
+ assert.match(runtime, /mqttConnectPacket/);
2068
+ assert.match(runtime, /unit_id_behavior/);
2069
+ assert.match(runtime, /ignored_or_mirrored/);
2070
+ assert.match(runtime, /finalNeighbors\.get\(device\.ip\)/);
2071
+ assert.match(runtime, /const usesArp = node\.discoveryMode === "arp_tcp"/);
2072
+ assert.match(runtime, /const usesPing = node\.discoveryMode === "ping_tcp"/);
2073
+ assert.match(runtime, /confirmed_modbus_hosts/);
2074
+ assert.match(runtime, /detected_sunspec_hosts/);
2075
+ assert.match(runtime, /confirmed_mqtt_hosts/);
2076
+ assert.match(runtime, /indexDevices/);
2077
+ assert.match(runtime, /device_index/);
2078
+ assert.match(runtime, /removeUnavailable/);
2079
+ assert.match(runtime, /item === null \|\| item === undefined/);
2080
+ assert.match(runtime, /sma-native/);
2081
+ assert.match(runtime, /sunspec/);
2082
+ assert.match(runtime, /neighborTable/);
2083
+ assert.match(runtime, /device_offline/);
2084
+ assert.match(runtime, /format === "influx"/);
2085
+ assert.match(runtime, /format === "csv"/);
2086
+ assert.match(editor, /data-template-name="modbus-tcp-discover"/);
2087
+ assert.match(editor, /category: "Atelierul de Tehnologii"/);
2088
+ assert.match(editor, /outputs: 2/);
2089
+ assert.match(editor, /msg\.target/);
2090
+ assert.match(editor, /Ping \+ TCP/);
2091
+ assert.match(editor, /ARP\/neighbour \+ TCP/);
2092
+ assert.match(editor, /node-input-unitIds/);
2093
+ assert.match(editor, /node-input-onlyChanges/);
2094
+ assert.match(editor, /node-input-emitMode/);
2095
+ assert.match(editor, /Doar lista finala/);
2096
+ assert.match(editor, /<option value="sunspec">SunSpec<\/option>/);
2097
+ assert.match(editor, /node-input-detailLevel/);
2098
+ assert.match(editor, /full_discovery/);
2099
+ assert.match(editor, /node-input-deviceCollection/);
2100
+ assert.match(runtime, /ports: indexPortResults\(checks\)/);
2101
+ assert.match(runtime, /mqtt\[String\(mqttPort\)\] = mqttResult/);
2102
+ assert.match(runtime, /device\.arp_neighbor = true/);
2103
+ assert.match(runtime, /identification_confidence/);
2104
+ assert.match(runtime, /categoryCollections/);
2105
+ assert.doesNotMatch(runtime, /full: "matched_hosts"/);
2106
+ });
2107
+
2108
+ function modbusReadRequest(transactionId, unitId, startAddress, quantity) {
2109
+ const buffer = Buffer.alloc(12);
2110
+ buffer.writeUInt16BE(transactionId, 0);
2111
+ buffer.writeUInt16BE(0, 2);
2112
+ buffer.writeUInt16BE(6, 4);
2113
+ buffer.writeUInt8(unitId, 6);
2114
+ buffer.writeUInt8(3, 7);
2115
+ buffer.writeUInt16BE(startAddress, 8);
2116
+ buffer.writeUInt16BE(quantity, 10);
2117
+ return buffer;
2118
+ }
2119
+
2120
+ function readOnce(port, request) {
2121
+ return new Promise(function (resolve, reject) {
2122
+ const socket = net.createConnection({ host: "127.0.0.1", port: port }, function () {
2123
+ socket.write(request);
2124
+ });
2125
+
2126
+ socket.once("data", function (chunk) {
2127
+ socket.destroy();
2128
+ resolve(chunk);
2129
+ });
2130
+
2131
+ socket.once("error", reject);
2132
+ socket.setTimeout(3000, function () {
2133
+ socket.destroy();
2134
+ reject(new Error("timeout"));
2135
+ });
2136
+ });
2137
+ }
2138
+
2139
+ test("scada-server returns Modbus exception for out-of-range reads", async function () {
2140
+ const server = createModbusServer({
2141
+ host: "127.0.0.1",
2142
+ port: 0,
2143
+ unitId: 1,
2144
+ readHoldingRegisters: function () {
2145
+ return { exceptionCode: 2, issueCode: "read_out_of_range" };
2146
+ }
2147
+ });
2148
+
2149
+ await server.start();
2150
+ try {
2151
+ const address = server.address();
2152
+ const response = await readOnce(address.port, modbusReadRequest(7, 1, 10, 2));
2153
+ assert.equal(response.readUInt16BE(0), 7);
2154
+ assert.equal(response.readUInt8(7), 0x83);
2155
+ assert.equal(response.readUInt8(8), 2);
2156
+ } finally {
2157
+ await server.stop();
2158
+ }
2159
+ });
2160
+
2161
+ (async function run() {
2162
+ for (const item of tests) {
2163
+ try {
2164
+ await item.fn();
2165
+ console.log("ok - " + item.name);
2166
+ } catch (error) {
2167
+ console.error("not ok - " + item.name);
2168
+ throw error;
2169
+ } finally {
2170
+ await closeRuntimeNodes();
2171
+ }
2172
+ }
2173
+
2174
+ console.log("OK: tests passed");
2175
+ })().catch(function (error) {
2176
+ console.error(error.stack || error.message);
2177
+ process.exit(1);
2178
+ });