@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,1393 @@
1
+ module.exports = function (RED) {
2
+
3
+ "use strict";
4
+
5
+ const { runLicensedNode } = require("../lib/licensing/node-guard");
6
+
7
+ const {
8
+ clamp,
9
+ roundAllocations,
10
+ capacityOf,
11
+ totalFreshPower,
12
+ chooseEffectiveStrategy,
13
+ allocateIterative,
14
+ buildPolicyAllocations,
15
+ applyPolicyToStale,
16
+ smoothAllocations
17
+ } = require("./lib/engine-core");
18
+
19
+ const {
20
+ atLeast,
21
+ clampNumber
22
+ } = require("../lib/numbers");
23
+
24
+
25
+ function firstFinite() {
26
+
27
+ for (let i = 0; i < arguments.length; i++) {
28
+ if (arguments[i] === null || arguments[i] === undefined || arguments[i] === "") continue;
29
+ const n = Number(arguments[i]);
30
+ if (Number.isFinite(n)) return n;
31
+ }
32
+
33
+ return null;
34
+
35
+ }
36
+
37
+
38
+ function normalizeTopicList(text) {
39
+
40
+ return String(text || "")
41
+ .split(",")
42
+ .map(function (s) { return s.trim(); })
43
+ .filter(Boolean)
44
+ .filter(function (v, i, arr) { return arr.indexOf(v) === i; });
45
+
46
+ }
47
+
48
+
49
+ function normalizeDebugMode(mode) {
50
+
51
+ if (mode === "off" || mode === "normal" || mode === "verbose") {
52
+ return mode;
53
+ }
54
+
55
+ if (mode === "always") {
56
+ return "verbose";
57
+ }
58
+
59
+ if (mode === "errors_only" || mode === "state_changes") {
60
+ return "normal";
61
+ }
62
+
63
+ return "off";
64
+
65
+ }
66
+
67
+ function normalizeGlobalVarName(value, fallback) {
68
+ const key = String(value || "").trim();
69
+ return key || String(fallback || "total_pv");
70
+ }
71
+
72
+ function normalizeRampType(value) {
73
+ const raw = String(value || "").trim().toLowerCase();
74
+
75
+ if (raw === "0" || raw === "none" || raw === "off") return "none";
76
+ if (raw === "1" || raw === "time" || raw === "ramp_time") return "time";
77
+ if (raw === "2" || raw === "gradient_pct" || raw === "gradient_percent") return "gradient_pct";
78
+ if (
79
+ raw === "3" ||
80
+ raw === "gradient_w" ||
81
+ raw === "gradient_watts" ||
82
+ raw === "gradient_var" ||
83
+ raw === "gradient_vars"
84
+ ) {
85
+ return "gradient_w";
86
+ }
87
+
88
+ return "none";
89
+ }
90
+
91
+
92
+ function ControlNode(config) {
93
+
94
+ RED.nodes.createNode(this, config);
95
+ const node = this;
96
+
97
+ runLicensedNode(node, RED, config, function startLicensedRuntime() {
98
+
99
+ node.name = config.name || "";
100
+ node.strategy = config.strategy || "current_power";
101
+ node.siteLimitTopics = normalizeTopicList(config.siteLimitTopics || "site-limit,control-export,control-reactive");
102
+
103
+ node.telemetryTimeoutMs = atLeast(config.telemetryTimeoutMs, 0, 6000);
104
+ node.maxFutureTimestampMs = atLeast(config.maxFutureTimestampMs, 0, 2000);
105
+ node.siteLimitTimeoutMs = atLeast(config.siteLimitTimeoutMs, 0, 8000);
106
+ node.missingSiteLimitBehavior = config.missingSiteLimitBehavior || "set_zero";
107
+ node.missingSiteLimitValueW = atLeast(config.missingSiteLimitValueW, 0, 0);
108
+
109
+ node.staleTelemetryBehavior = config.staleTelemetryBehavior || "set_inverter_min";
110
+ node.staleTelemetryFixedValueW = atLeast(config.staleTelemetryFixedValueW, 0, 0);
111
+
112
+ node.waitForFreshTelemetryOnStartup = config.waitForFreshTelemetryOnStartup !== false;
113
+ node.recalculateOnTelemetry = config.recalculateOnTelemetry !== false;
114
+ node.telemetryRecalcDebounceMs = atLeast(config.telemetryRecalcDebounceMs, 0, 350);
115
+ node.telemetryFilterAlpha = clampNumber(config.telemetryFilterAlpha, 0.05, 1, 0.35);
116
+
117
+ node.rampType = normalizeRampType(config.rampType || "none");
118
+ node.rampValue = atLeast(config.rampValue, 0, 0);
119
+ node.rampUpValue = atLeast(config.rampUpValue, 0, 0);
120
+ node.rampDownValue = atLeast(config.rampDownValue, 0, 0);
121
+ node.rampFromGlobal = !!config.rampFromGlobal;
122
+ node.rampTypeGlobalVar = normalizeGlobalVarName(config.rampTypeGlobalVar, "scada_ramp_type");
123
+ node.rampValueGlobalVar = normalizeGlobalVarName(config.rampValueGlobalVar, "scada_ramp_value");
124
+ node.rampUpGlobalVar = normalizeGlobalVarName(config.rampUpGlobalVar, "scada_ramp_up_value");
125
+ node.rampDownGlobalVar = normalizeGlobalVarName(config.rampDownGlobalVar, "scada_ramp_down_value");
126
+
127
+ node.allocationSmoothingAlpha = clampNumber(config.allocationSmoothingAlpha, 0.01, 1, 0.35);
128
+ node.allocationDeadbandW = atLeast(config.allocationDeadbandW, 0, 300);
129
+
130
+ node.debugMode = normalizeDebugMode(config.debugMode);
131
+ node.enableDebugOutput = node.debugMode !== "off";
132
+ node.publishTotalPvGlobal = config.publishTotalPvGlobal !== false;
133
+ node.totalPvGlobalVar = normalizeGlobalVarName(config.totalPvGlobalVar, "total_pv");
134
+ node.influxDb = !!config.influxDb;
135
+ node.influxMeasurement = String(config.influxMeasurement || "multi_inverter").trim() || "multi_inverter";
136
+ node.influxNode = String(config.influxNode || "").trim();
137
+
138
+ node.inverters = Array.isArray(config.inverters)
139
+ ? config.inverters.map(function (inv, idx) {
140
+ return {
141
+ index: idx,
142
+ id: String((inv && inv.id) || "").trim(),
143
+ minW: atLeast(inv && inv.minW, 0, 0),
144
+ maxW: atLeast(inv && inv.maxW, 0, 0),
145
+ nominalPowerW: atLeast(
146
+ inv && inv.nominalPowerW !== undefined ? inv.nominalPowerW : (inv && inv.nominalW),
147
+ 1,
148
+ inv && inv.maxW ? inv.maxW : 1
149
+ ),
150
+ enabled: inv ? inv.enabled !== false : true
151
+ };
152
+ })
153
+ : [];
154
+
155
+
156
+ const telemetry = {};
157
+ const lastSmoothedAllocation = {};
158
+ const lastEmittedLimitById = {};
159
+
160
+ let lastSiteLimit = null;
161
+ let lastSiteLimitMode = "active";
162
+ let lastSiteLimitMsg = null;
163
+ let lastSiteLimitTs = 0;
164
+
165
+ let lastDebugSignature = "";
166
+ let lastReason = "";
167
+ let hasEmittedAllocation = false;
168
+
169
+ let telemetryDebounceTimer = null;
170
+ let watchdogTimer = null;
171
+ let lastSendFn = null;
172
+ const globalCtx = node.context().global;
173
+
174
+
175
+ function emit(outputs) {
176
+ if (node.influxDb && Array.isArray(outputs) && outputs[0] && outputs[0].payload) {
177
+ const payload = outputs[0].payload;
178
+ const fields = {};
179
+ Object.keys(payload).forEach(function (key) {
180
+ if (typeof payload[key] === "number" && Number.isFinite(payload[key])) fields[key] = payload[key];
181
+ });
182
+ (Array.isArray(payload.limits) ? payload.limits : []).forEach(function (item) {
183
+ const id = String(item && item.inverter_id || "").replace(/[^a-zA-Z0-9_]/g, "_");
184
+ if (!id) return;
185
+ ["limit_w", "limit_var", "power_limit_w", "reactive_limit_var"].forEach(function (key) {
186
+ if (typeof item[key] === "number" && Number.isFinite(item[key])) fields[id + "_" + key] = item[key];
187
+ });
188
+ });
189
+ const target = RED.nodes.getNode(node.influxNode);
190
+ if (target && typeof target.receive === "function" && Object.keys(fields).length) {
191
+ target.receive({
192
+ topic: "multi-inverter/influx",
193
+ payload: fields,
194
+ influx: {
195
+ measurement: node.influxMeasurement,
196
+ tags: { source: "multi-inverter", strategy: node.strategy },
197
+ fields: fields,
198
+ timestamp: new Date().toISOString()
199
+ }
200
+ });
201
+ } else if (!target) {
202
+ node.warn("multi-inverter: selecteaza un nod adt-influxdb.");
203
+ }
204
+ }
205
+ if (typeof lastSendFn === "function") {
206
+ lastSendFn(outputs);
207
+ } else {
208
+ node.send(outputs);
209
+ }
210
+ }
211
+
212
+
213
+ function activeInverters() {
214
+ return node.inverters.filter(function (inv) {
215
+ return inv.enabled && inv.id && inv.maxW > 0 && inv.minW <= inv.maxW;
216
+ });
217
+ }
218
+
219
+ function isValidInverter(inv) {
220
+ return !!(inv && inv.id && inv.maxW > 0 && inv.minW <= inv.maxW);
221
+ }
222
+
223
+
224
+ function duplicateIds() {
225
+
226
+ const seen = new Set();
227
+ const dup = new Set();
228
+
229
+ activeInverters().forEach(function (inv) {
230
+ if (seen.has(inv.id)) dup.add(inv.id);
231
+ else seen.add(inv.id);
232
+ });
233
+
234
+ return Array.from(dup);
235
+
236
+ }
237
+
238
+
239
+ function parseTelemetryPower(payload, siteLimitMode) {
240
+
241
+ if (Number.isFinite(Number(payload))) {
242
+ const scalar = Number(payload);
243
+ return siteLimitMode === "reactive" ? Math.abs(scalar) : scalar;
244
+ }
245
+
246
+ if (!payload || typeof payload !== "object") {
247
+ return null;
248
+ }
249
+
250
+ const activeCandidate = firstFinite(
251
+ payload.power_w,
252
+ payload.active_power_w,
253
+ payload.p_w,
254
+ payload.ac_active_power_w,
255
+ payload.summary && payload.summary.ac && payload.summary.ac.power_w
256
+ );
257
+
258
+ const reactiveCandidate = firstFinite(
259
+ payload.power_var,
260
+ payload.reactive_power_var,
261
+ payload.ac_reactive_power_var,
262
+ payload.q_var,
263
+ payload.var,
264
+ payload.vars,
265
+ payload.limit_var,
266
+ payload.site_limit_var,
267
+ payload.summary && payload.summary.ac && payload.summary.ac.reactive_power_var
268
+ );
269
+
270
+ if (siteLimitMode === "reactive") {
271
+ if (reactiveCandidate !== null) return Math.abs(Number(reactiveCandidate));
272
+ if (activeCandidate !== null) return Math.abs(Number(activeCandidate));
273
+ return null;
274
+ }
275
+
276
+ if (activeCandidate !== null) return Number(activeCandidate);
277
+ if (reactiveCandidate !== null) return Number(reactiveCandidate);
278
+ return null;
279
+
280
+ }
281
+
282
+
283
+ function extractTimestampMs(msg) {
284
+
285
+ const candidate = firstFinite(
286
+ msg && msg.timestamp,
287
+ msg && msg.ts,
288
+ msg && msg.time,
289
+ msg && msg.timestamp_ms,
290
+ msg && msg.time_ms,
291
+ msg && msg.payload && msg.payload.timestamp,
292
+ msg && msg.payload && msg.payload.ts,
293
+ msg && msg.payload && msg.payload.time,
294
+ msg && msg.payload && msg.payload.timestamp_ms,
295
+ msg && msg.payload && msg.payload.time_ms
296
+ );
297
+
298
+ if (candidate === null) return Date.now();
299
+ if (candidate < 1e11) return Math.round(candidate * 1000);
300
+ return Math.round(candidate);
301
+
302
+ }
303
+
304
+
305
+ function isFresh(ts, nowMs) {
306
+
307
+ if (node.telemetryTimeoutMs <= 0) return true;
308
+ if (!Number.isFinite(ts)) return false;
309
+ return (nowMs - ts) <= node.telemetryTimeoutMs;
310
+
311
+ }
312
+
313
+
314
+ function isSiteLimitFresh(nowMs) {
315
+
316
+ if (node.siteLimitTimeoutMs <= 0) return Number.isFinite(lastSiteLimit);
317
+ if (!Number.isFinite(lastSiteLimitTs)) return false;
318
+ return (nowMs - lastSiteLimitTs) <= node.siteLimitTimeoutMs;
319
+
320
+ }
321
+
322
+
323
+ function extractSiteLimitInfo(msg) {
324
+
325
+ const topic = String(msg.topic || "").trim();
326
+ const payload = msg.payload;
327
+
328
+ const topicMatch = node.siteLimitTopics.includes(topic) ||
329
+ topic === "site-limit" ||
330
+ topic === "control-export" ||
331
+ topic === "control-reactive";
332
+ const hasObjectPayload = payload && typeof payload === "object";
333
+ const activeCandidate = hasObjectPayload ? Number(payload.site_limit_w) : NaN;
334
+ const reactiveCandidate = hasObjectPayload
335
+ ? firstFinite(payload.limit_var, payload.site_limit_var)
336
+ : null;
337
+ const hasActivePayload = Number.isFinite(activeCandidate);
338
+ const hasReactivePayload = Number.isFinite(reactiveCandidate);
339
+ const hasValidPayload = hasActivePayload || hasReactivePayload;
340
+
341
+ if (!topicMatch || !hasValidPayload) {
342
+ return null;
343
+ }
344
+
345
+ return {
346
+ siteLimit: hasActivePayload ? activeCandidate : Number(reactiveCandidate),
347
+ siteLimitMode: hasActivePayload ? "active" : "reactive",
348
+ reason: payload.reason || null,
349
+ controllerStale: !!payload.stale,
350
+ controllerErrorW: Number.isFinite(Number(payload.error_w)) ? Number(payload.error_w) : null,
351
+ controllerErrorVar: Number.isFinite(Number(payload.error_var)) ? Number(payload.error_var) : null,
352
+ controllerTimestampMs: Number.isFinite(Number(payload.timestamp_ms)) ? Number(payload.timestamp_ms) : null,
353
+ commandId: payload.command_id ? String(payload.command_id) : null,
354
+ commandRequestedAtMs: Number.isFinite(Number(payload.command_requested_at_ms)) ? Number(payload.command_requested_at_ms) : null,
355
+ controlReceivedAtMs: Number.isFinite(Number(payload.control_received_at_ms)) ? Number(payload.control_received_at_ms) : null,
356
+ controlCalculatedAtMs: Number.isFinite(Number(payload.control_calculated_at_ms)) ? Number(payload.control_calculated_at_ms) : null
357
+ };
358
+
359
+ }
360
+
361
+ function siteLimitFieldForMode(mode) {
362
+ return mode === "reactive" ? "site_limit_var" : "site_limit_w";
363
+ }
364
+
365
+ function perInverterLimitFieldForMode(mode) {
366
+ return mode === "reactive" ? "limit_var" : "limit_w";
367
+ }
368
+
369
+
370
+ function effectiveRamp() {
371
+ let type = node.rampType;
372
+ let value = 0;
373
+ let upValue = node.rampUpValue;
374
+ let downValue = node.rampDownValue;
375
+
376
+ if (node.rampFromGlobal) {
377
+ const globalType = globalCtx.get(node.rampTypeGlobalVar);
378
+ const globalUpValue = globalCtx.get(node.rampUpGlobalVar);
379
+ const globalDownValue = globalCtx.get(node.rampDownGlobalVar);
380
+ const legacyGlobalValue = globalCtx.get(node.rampValueGlobalVar);
381
+ type = normalizeRampType(globalType);
382
+ upValue = Number(globalUpValue);
383
+ downValue = Number(globalDownValue);
384
+
385
+ if (!Number.isFinite(upValue)) {
386
+ upValue = Number(legacyGlobalValue);
387
+ }
388
+
389
+ if (!Number.isFinite(downValue)) {
390
+ downValue = Number(legacyGlobalValue);
391
+ }
392
+ }
393
+
394
+ return type !== "none"
395
+ ? {
396
+ type: type,
397
+ value: Math.max(0, Number(value || 0)),
398
+ upValue: Math.max(0, Number.isFinite(upValue) ? upValue : 0),
399
+ downValue: Math.max(0, Number.isFinite(downValue) ? downValue : 0)
400
+ }
401
+ : null;
402
+ }
403
+
404
+ function rampForDirection(baseRamp, direction) {
405
+ const fallbackType = baseRamp && baseRamp.type && baseRamp.type !== "none" ? baseRamp.type : "time";
406
+
407
+ const upValue = node.rampFromGlobal && baseRamp && Number.isFinite(baseRamp.upValue)
408
+ ? baseRamp.upValue
409
+ : node.rampUpValue;
410
+ const downValue = node.rampFromGlobal && baseRamp && Number.isFinite(baseRamp.downValue)
411
+ ? baseRamp.downValue
412
+ : node.rampDownValue;
413
+
414
+ if (direction === "up" && upValue > 0) {
415
+ return { type: fallbackType, value: upValue };
416
+ }
417
+
418
+ if (direction === "down" && downValue > 0) {
419
+ return { type: fallbackType, value: downValue };
420
+ }
421
+
422
+ return baseRamp && baseRamp.type && baseRamp.type !== "none"
423
+ ? { type: baseRamp.type, value: Number(baseRamp.value || 0) }
424
+ : null;
425
+ }
426
+
427
+
428
+ function freshPowerMap(list, nowMs) {
429
+
430
+ const map = {};
431
+
432
+ list.forEach(function (inv) {
433
+ const t = telemetry[inv.id];
434
+ if (t && isFresh(t.ts, nowMs) && Number.isFinite(t.powerW)) {
435
+ map[inv.id] = Number.isFinite(t.filteredPowerW) ? t.filteredPowerW : t.powerW;
436
+ }
437
+ });
438
+
439
+ return map;
440
+
441
+ }
442
+
443
+
444
+ function staleInputs(list, nowMs) {
445
+
446
+ return list.filter(function (inv) {
447
+ const t = telemetry[inv.id];
448
+ return !(t && isFresh(t.ts, nowMs) && Number.isFinite(t.powerW));
449
+ }).map(function (inv) {
450
+ return inv.id;
451
+ });
452
+
453
+ }
454
+
455
+ function computeTotalPvW(nowMs) {
456
+ const validRows = node.inverters.filter(function (inv) {
457
+ return isValidInverter(inv);
458
+ });
459
+
460
+ return validRows.reduce(function (sum, inv) {
461
+ const t = telemetry[inv.id];
462
+ if (!(t && Number.isFinite(t.powerW) && isFresh(t.ts, nowMs))) {
463
+ return sum;
464
+ }
465
+ return sum + Math.max(0, Number(t.powerW));
466
+ }, 0);
467
+ }
468
+
469
+ function publishTotalPv(nowMs) {
470
+ try {
471
+ if (!node.publishTotalPvGlobal) return;
472
+ if (!node.totalPvGlobalVar) return;
473
+ const totalPvW = Math.round(computeTotalPvW(nowMs));
474
+ globalCtx.set(node.totalPvGlobalVar, totalPvW);
475
+ } catch (err) {
476
+ node.warn("[multi-inverter] total_pv_global_write_failed | " + err.message);
477
+ }
478
+ }
479
+
480
+
481
+ function currentPowerHoldLimit(activeList, freshMap) {
482
+
483
+ const total = activeList.reduce(function (sum, inv) {
484
+ const current = Number(freshMap[inv.id] || 0);
485
+ return sum + clamp(current, inv.minW, inv.maxW);
486
+ }, 0);
487
+
488
+ return Math.max(0, Math.round(total));
489
+
490
+ }
491
+
492
+
493
+ function sumAllocations(map) {
494
+ return Object.values(map || {}).reduce(function (sum, v) {
495
+ return sum + Number(v || 0);
496
+ }, 0);
497
+ }
498
+
499
+ function getTelemetrySource(msg) {
500
+ const payload = msg && msg.payload && typeof msg.payload === "object" ? msg.payload : null;
501
+ return String(
502
+ (msg && msg.source) ||
503
+ (payload && payload.source) ||
504
+ (msg && msg.origin) ||
505
+ (payload && payload.origin) ||
506
+ "unknown"
507
+ ).trim() || "unknown";
508
+ }
509
+
510
+ function buildAllZeroLimits(nowMs, reason) {
511
+ const rowList = Array.isArray(node.inverters) ? node.inverters : [];
512
+ const validRowList = rowList.filter(function (inv) { return isValidInverter(inv); });
513
+ const controlMode = lastSiteLimitMode === "reactive" ? "reactive" : "active";
514
+ const limitField = perInverterLimitFieldForMode(controlMode);
515
+
516
+ const limits = validRowList.map(function (inv) {
517
+ const item = {
518
+ id: inv.id,
519
+ enabled: !!inv.enabled,
520
+ disabled: !inv.enabled,
521
+ min_w: inv.minW,
522
+ max_w: inv.maxW,
523
+ nominal_power_w: inv.nominalPowerW,
524
+ nominal_reactive_power_var: inv.nominalPowerW,
525
+ current_power_w: null,
526
+ current_power_fresh: false
527
+ };
528
+ item[limitField] = 0;
529
+ return item;
530
+ });
531
+
532
+ return {
533
+ topic: "multi-inverter/limits",
534
+ payload: {
535
+ limits: limits,
536
+ site_limit_w: controlMode === "active" && Number.isFinite(lastSiteLimit) ? Math.round(lastSiteLimit) : null,
537
+ site_limit_var: controlMode === "reactive" && Number.isFinite(lastSiteLimit) ? Math.round(lastSiteLimit) : null,
538
+ control_mode: controlMode,
539
+ allocation_strategy: node.strategy,
540
+ effective_strategy: "failsafe",
541
+ stale_inputs: validRowList.map(function (inv) { return inv.id; }),
542
+ fresh_power_map: {},
543
+ total_fresh_power_w: 0,
544
+ available_capacity_w: 0,
545
+ unallocated_w: 0,
546
+ clipped: false,
547
+ ramp: null,
548
+ reason: reason || "invalid_telemetry",
549
+ timestamp_ms: nowMs
550
+ }
551
+ };
552
+ }
553
+
554
+ function emitFailsafeAllZero(reason, details) {
555
+ const nowMs = Date.now();
556
+ node.warn("[multi-inverter] " + reason + (details ? " | " + details : ""));
557
+ node.status({ fill: "red", shape: "ring", text: "failsafe | " + reason });
558
+
559
+ const outputs = new Array(1 + (node.enableDebugOutput ? 1 : 0)).fill(null);
560
+ outputs[0] = buildAllZeroLimits(nowMs, reason);
561
+
562
+ if (node.enableDebugOutput) {
563
+ outputs[outputs.length - 1] = debugMessage(
564
+ "invalid_telemetry",
565
+ false,
566
+ Number.isFinite(lastSiteLimit) ? Math.round(lastSiteLimit) : 0,
567
+ nowMs,
568
+ activeInverters(),
569
+ [],
570
+ {},
571
+ {},
572
+ { availableCapacityW: 0, unallocatedW: 0, clipped: false },
573
+ null,
574
+ "failsafe",
575
+ lastSiteLimitMsg ? {
576
+ reason: lastSiteLimitMsg.reason || null,
577
+ stale: !!lastSiteLimitMsg.controllerStale,
578
+ errorW: lastSiteLimitMsg.controllerErrorW,
579
+ errorVar: lastSiteLimitMsg.controllerErrorVar,
580
+ timestampMs: lastSiteLimitMsg.controllerTimestampMs
581
+ } : null,
582
+ "telemetry",
583
+ lastSiteLimitMode
584
+ );
585
+ outputs[outputs.length - 1].payload.failsafe_reason = reason;
586
+ outputs[outputs.length - 1].payload.failsafe_details = details || null;
587
+ }
588
+
589
+ emit(outputs);
590
+ }
591
+
592
+
593
+ function buildOperationalOutputs(activeList, smoothedAllocations, ramp, siteLimitW, effectiveStrategy, staleList, freshMap, result) {
594
+
595
+ const outputs = new Array(activeList.length + (node.enableDebugOutput ? 1 : 0)).fill(null);
596
+ const activeIds = activeList.map(function (inv) { return inv.id; });
597
+ const freshTotal = totalFreshPower(freshMap);
598
+
599
+ activeList.forEach(function (inv, idx) {
600
+
601
+ const limitW = Math.round(Number(smoothedAllocations[inv.id] || 0));
602
+ const t = telemetry[inv.id];
603
+
604
+ const currentPowerW = t && Number.isFinite(t.powerW) ? t.powerW : null;
605
+ const allocationPowerW = t && Number.isFinite(t.filteredPowerW) ? t.filteredPowerW : currentPowerW;
606
+ const currentPowerFresh = !!(t && Number.isFinite(t.powerW) && staleList.indexOf(inv.id) === -1);
607
+
608
+ const payload = { limit_w: limitW };
609
+ payload.nominal_power_w = inv.nominalPowerW;
610
+ payload.nominal_reactive_power_var = inv.nominalPowerW;
611
+
612
+ if (ramp && ramp.type && ramp.type !== "none" && Number.isFinite(Number(ramp.value)) && Number(ramp.value) > 0) {
613
+ payload.ramp = { type: ramp.type, value: Number(ramp.value) };
614
+ }
615
+
616
+ outputs[idx] = {
617
+ topic: inv.id,
618
+ payload: payload,
619
+ inverter_id: inv.id,
620
+ inverter_index: idx,
621
+ site_limit_w: Math.round(siteLimitW),
622
+ current_power_w: currentPowerW,
623
+ allocation_power_w: allocationPowerW,
624
+ current_power_fresh: currentPowerFresh,
625
+ min_w: inv.minW,
626
+ max_w: inv.maxW,
627
+ nominal_power_w: inv.nominalPowerW,
628
+ nominal_reactive_power_var: inv.nominalPowerW,
629
+ allocation_strategy: node.strategy,
630
+ effective_strategy: effectiveStrategy,
631
+ stale_inputs: staleList,
632
+ fresh_power_map: freshMap,
633
+ total_fresh_power_w: freshTotal,
634
+ available_capacity_w: result.availableCapacityW,
635
+ clipped: result.clipped,
636
+ unallocated_w: result.unallocatedW,
637
+ active_inverters: activeIds,
638
+ ramp_type: payload.ramp ? payload.ramp.type : null,
639
+ ramp_value: payload.ramp ? payload.ramp.value : null
640
+ };
641
+
642
+ });
643
+
644
+ return outputs;
645
+
646
+ }
647
+
648
+
649
+ function statusFor(reason, staleList, activeCount, configuredCount, limitW) {
650
+
651
+ const limitText = "limit " + Math.round(limitW || 0) + "W";
652
+ const countsText = "active " + activeCount + "/" + configuredCount;
653
+ const staleText = "stale " + staleList.length;
654
+
655
+ if (reason === "invalid_configuration" || reason === "runtime_error") {
656
+ node.status({ fill: "red", shape: "ring", text: reason + " | " + countsText });
657
+ return;
658
+ }
659
+
660
+ if (reason === "waiting_startup_telemetry") {
661
+ node.status({ fill: "yellow", shape: "ring", text: "waiting | " + countsText + " | " + staleText });
662
+ return;
663
+ }
664
+
665
+ if (reason === "site_limit_missing") {
666
+ node.status({ fill: "yellow", shape: "ring", text: reason + " | " + limitText + " | " + staleText });
667
+ return;
668
+ }
669
+
670
+ if (staleList.length > 0) {
671
+ node.status({ fill: "yellow", shape: "dot", text: limitText + " | " + countsText + " | " + staleText });
672
+ return;
673
+ }
674
+
675
+ node.status({ fill: "green", shape: "dot", text: limitText + " | " + countsText + " | " + staleText });
676
+
677
+ }
678
+
679
+
680
+ function shouldEmitDebug(mode, reason, signatureChanged) {
681
+
682
+ if (!node.enableDebugOutput) return false;
683
+ if (mode === "verbose") return true;
684
+ if (mode === "off") return false;
685
+
686
+ return reason !== "allocated" || signatureChanged;
687
+
688
+ }
689
+
690
+
691
+ function debugMessage(reason, valid, siteLimitW, nowMs, activeList, staleList, freshMap, allocations, result, ramp, effectiveStrategy, controllerInfo, triggerSource, siteLimitMode) {
692
+
693
+ const summary = "limit=" + Math.round(siteLimitW || 0) +
694
+ ", active=" + activeList.length + "/" + node.inverters.length +
695
+ ", stale=" + staleList.length +
696
+ ", strategy=" + String(effectiveStrategy || node.strategy || "-");
697
+ const controlMode = siteLimitMode === "reactive" ? "reactive" : "active";
698
+
699
+ return {
700
+ topic: "multi-inverter/diagnostic",
701
+ payload: {
702
+ valid: valid,
703
+ reason: reason,
704
+ trigger_source: triggerSource || null,
705
+ summary: summary,
706
+ site_limit_w: controlMode === "active" ? siteLimitW : null,
707
+ site_limit_var: controlMode === "reactive" ? siteLimitW : null,
708
+ control_mode: controlMode,
709
+ site_limit_age_ms: Number.isFinite(lastSiteLimitTs) ? Math.max(0, nowMs - lastSiteLimitTs) : null,
710
+ allocation_strategy: node.strategy,
711
+ effective_strategy: effectiveStrategy,
712
+ ramp: ramp,
713
+ startup_wait_for_fresh_telemetry: node.waitForFreshTelemetryOnStartup,
714
+ recalculate_on_telemetry: node.recalculateOnTelemetry,
715
+ telemetry_recalc_debounce_ms: node.telemetryRecalcDebounceMs,
716
+ telemetry_filter_alpha: node.telemetryFilterAlpha,
717
+ has_emitted_allocation: hasEmittedAllocation,
718
+ configured_inverters: node.inverters.map(function (inv) {
719
+ return {
720
+ index: inv.index,
721
+ id: inv.id,
722
+ min_w: inv.minW,
723
+ max_w: inv.maxW,
724
+ nominal_power_w: inv.nominalPowerW,
725
+ nominal_reactive_power_var: inv.nominalPowerW,
726
+ enabled: inv.enabled
727
+ };
728
+ }),
729
+ active_inverters: activeList.map(function (inv) { return inv.id; }),
730
+ stale_inputs: staleList,
731
+ stale_telemetry_behavior: node.staleTelemetryBehavior,
732
+ stale_telemetry_fixed_value_w: node.staleTelemetryFixedValueW,
733
+ missing_site_limit_behavior: node.missingSiteLimitBehavior,
734
+ missing_site_limit_value_w: node.missingSiteLimitValueW,
735
+ fresh_power_map: freshMap,
736
+ allocations_w: allocations,
737
+ available_capacity_w: result ? result.availableCapacityW : 0,
738
+ total_fresh_power_w: totalFreshPower(freshMap),
739
+ unallocated_w: result ? result.unallocatedW : 0,
740
+ clipped: result ? result.clipped : false,
741
+ controller_reason: controllerInfo ? controllerInfo.reason : null,
742
+ controller_stale: controllerInfo ? controllerInfo.stale : null,
743
+ controller_error_w: controllerInfo ? controllerInfo.errorW : null,
744
+ controller_error_var: controllerInfo ? controllerInfo.errorVar : null,
745
+ controller_timestamp_ms: controllerInfo ? controllerInfo.timestampMs : null,
746
+ debug_mode: node.debugMode,
747
+ debug_enabled: node.enableDebugOutput,
748
+ timestamp_ms: nowMs
749
+ }
750
+ };
751
+
752
+ }
753
+
754
+
755
+ function runAllocationCycle(triggerSource) {
756
+
757
+ const nowMs = Date.now();
758
+ publishTotalPv(nowMs);
759
+ const rowList = Array.isArray(node.inverters) ? node.inverters : [];
760
+ const validRowList = rowList.filter(function (inv) {
761
+ return isValidInverter(inv);
762
+ });
763
+ const activeList = rowList.filter(function (inv) {
764
+ return inv.enabled && isValidInverter(inv);
765
+ });
766
+ const configuredCount = rowList.length;
767
+ const dups = duplicateIds();
768
+
769
+ const outputs = new Array(1 + (node.enableDebugOutput ? 1 : 0)).fill(null);
770
+
771
+ if (dups.length > 0 || validRowList.length === 0) {
772
+
773
+ const reason = "invalid_configuration";
774
+ statusFor(reason, [], activeList.length, configuredCount, 0);
775
+
776
+ const dbg = debugMessage(
777
+ reason,
778
+ false,
779
+ 0,
780
+ nowMs,
781
+ activeList,
782
+ [],
783
+ {},
784
+ {},
785
+ { availableCapacityW: 0, unallocatedW: 0, clipped: false },
786
+ null,
787
+ node.strategy,
788
+ null,
789
+ "validation",
790
+ lastSiteLimitMode
791
+ );
792
+
793
+ dbg.payload.duplicate_ids = dups;
794
+ dbg.payload.message = activeList.length === 0 ? "No active inverters configured" : "Duplicate inverter IDs";
795
+
796
+ outputs[0] = buildAllZeroLimits(nowMs, reason);
797
+ if (node.enableDebugOutput) outputs[outputs.length - 1] = dbg;
798
+
799
+ emit(outputs);
800
+ return;
801
+
802
+ }
803
+
804
+ if (activeList.length === 0) {
805
+ const controlMode = lastSiteLimitMode === "reactive" ? "reactive" : "active";
806
+ const limitField = perInverterLimitFieldForMode(controlMode);
807
+ const zeroLimits = validRowList.map(function (inv) {
808
+ const item = {
809
+ id: inv.id,
810
+ enabled: false,
811
+ disabled: true,
812
+ min_w: inv.minW,
813
+ max_w: inv.maxW,
814
+ nominal_power_w: inv.nominalPowerW,
815
+ nominal_reactive_power_var: inv.nominalPowerW,
816
+ current_power_w: null,
817
+ current_power_fresh: false
818
+ };
819
+ item[limitField] = 0;
820
+ return item;
821
+ });
822
+
823
+ outputs[0] = {
824
+ topic: "multi-inverter/limits",
825
+ payload: {
826
+ limits: zeroLimits,
827
+ site_limit_w: controlMode === "active" && Number.isFinite(lastSiteLimit) ? Math.round(lastSiteLimit) : null,
828
+ site_limit_var: controlMode === "reactive" && Number.isFinite(lastSiteLimit) ? Math.round(lastSiteLimit) : null,
829
+ control_mode: controlMode,
830
+ allocation_strategy: node.strategy,
831
+ effective_strategy: "disabled",
832
+ stale_inputs: [],
833
+ fresh_power_map: {},
834
+ total_fresh_power_w: 0,
835
+ available_capacity_w: 0,
836
+ unallocated_w: 0,
837
+ clipped: false,
838
+ ramp: null,
839
+ timestamp_ms: nowMs
840
+ }
841
+ };
842
+
843
+ statusFor("site_limit_missing", [], 0, configuredCount, 0);
844
+ emit(outputs);
845
+ return;
846
+ }
847
+
848
+ const freshMap = freshPowerMap(activeList, nowMs);
849
+ const staleList = staleInputs(activeList, nowMs);
850
+ const freshCount = activeList.length - staleList.length;
851
+ const startupMissingTelemetry = node.waitForFreshTelemetryOnStartup && !hasEmittedAllocation && freshCount === 0;
852
+
853
+ let siteLimitValid = isSiteLimitFresh(nowMs);
854
+ let siteLimitMode = lastSiteLimitMode === "reactive" ? "reactive" : "active";
855
+ let siteLimitW = Number.isFinite(lastSiteLimit) ? lastSiteLimit : 0;
856
+ let allocationBudgetW = siteLimitMode === "reactive"
857
+ ? Math.abs(Number(siteLimitW || 0))
858
+ : Math.max(0, Number(siteLimitW || 0));
859
+ let reason = "allocated";
860
+
861
+ if (startupMissingTelemetry) {
862
+
863
+ reason = "waiting_startup_telemetry";
864
+ statusFor(reason, staleList, activeList.length, configuredCount, siteLimitW);
865
+
866
+ if (node.enableDebugOutput && shouldEmitDebug(node.debugMode, reason, true)) {
867
+ outputs[outputs.length - 1] = debugMessage(
868
+ reason,
869
+ false,
870
+ Math.round(siteLimitW || 0),
871
+ nowMs,
872
+ activeList,
873
+ staleList,
874
+ freshMap,
875
+ {},
876
+ { availableCapacityW: 0, unallocatedW: 0, clipped: false },
877
+ null,
878
+ node.strategy,
879
+ lastSiteLimitMsg ? {
880
+ reason: lastSiteLimitMsg.reason || null,
881
+ stale: !!lastSiteLimitMsg.controllerStale,
882
+ errorW: lastSiteLimitMsg.controllerErrorW,
883
+ errorVar: lastSiteLimitMsg.controllerErrorVar,
884
+ timestampMs: lastSiteLimitMsg.controllerTimestampMs
885
+ } : null,
886
+ triggerSource,
887
+ siteLimitMode
888
+ );
889
+
890
+ emit(outputs);
891
+ }
892
+
893
+ return;
894
+
895
+ }
896
+
897
+ let effectiveStrategy = node.strategy;
898
+ let ramp = effectiveRamp();
899
+ let allocationResult;
900
+ let allocations;
901
+
902
+ if (!siteLimitValid) {
903
+
904
+ reason = "site_limit_missing";
905
+ const missingPolicy = buildPolicyAllocations(activeList, node.missingSiteLimitBehavior, node.missingSiteLimitValueW);
906
+ allocations = missingPolicy.allocations;
907
+ siteLimitW = missingPolicy.totalW;
908
+ siteLimitMode = "active";
909
+ allocationBudgetW = Math.max(0, Number(siteLimitW || 0));
910
+ effectiveStrategy = "missing_site_limit_policy";
911
+ allocationResult = {
912
+ allocations: allocations,
913
+ clipped: false,
914
+ unallocatedW: 0,
915
+ availableCapacityW: capacityOf(activeList),
916
+ minCapacityW: 0
917
+ };
918
+
919
+ } else {
920
+
921
+ const staleSet = new Set(staleList);
922
+ const freshActiveList = activeList.filter(function (inv) {
923
+ return !staleSet.has(inv.id);
924
+ });
925
+ const staleOnlyList = activeList.filter(function (inv) {
926
+ return staleSet.has(inv.id);
927
+ });
928
+
929
+ const staleForced = buildPolicyAllocations(
930
+ staleOnlyList,
931
+ node.staleTelemetryBehavior,
932
+ node.staleTelemetryFixedValueW
933
+ );
934
+ const freshBudgetW = Math.max(0, Math.round(allocationBudgetW - staleForced.totalW));
935
+
936
+ effectiveStrategy = chooseEffectiveStrategy(node.strategy, freshActiveList, freshMap, []);
937
+ const freshResult = allocateIterative(freshBudgetW, freshActiveList, freshMap, effectiveStrategy);
938
+
939
+ const startupCapped = (function () {
940
+ if (hasEmittedAllocation || freshActiveList.length === 0) {
941
+ return { allocations: freshResult.allocations, capped: false };
942
+ }
943
+
944
+ const out = {};
945
+ let capped = false;
946
+
947
+ freshActiveList.forEach(function (inv) {
948
+ const proposed = Math.max(0, Number(freshResult.allocations[inv.id] || 0));
949
+ const t = telemetry[inv.id];
950
+ const hasFreshCurrent = !!(t && Number.isFinite(t.powerW));
951
+
952
+ if (!hasFreshCurrent) {
953
+ out[inv.id] = proposed;
954
+ return;
955
+ }
956
+
957
+ const currentPower = clamp(Number(t.powerW || 0), inv.minW, inv.maxW);
958
+ const bounded = Math.min(proposed, currentPower);
959
+
960
+ if (bounded < proposed) {
961
+ capped = true;
962
+ }
963
+
964
+ out[inv.id] = bounded;
965
+ });
966
+
967
+ return { allocations: out, capped: capped };
968
+ })();
969
+
970
+ allocations = Object.assign({}, startupCapped.allocations, staleForced.allocations);
971
+
972
+ const allocatedTotalW = sumAllocations(allocations);
973
+ allocationResult = {
974
+ allocations: allocations,
975
+ clipped: freshResult.clipped || staleForced.totalW > allocationBudgetW,
976
+ unallocatedW: Math.max(0, Math.round(allocationBudgetW - allocatedTotalW)),
977
+ availableCapacityW: capacityOf(activeList),
978
+ minCapacityW: 0
979
+ };
980
+
981
+ if (allocationResult.clipped) {
982
+ reason = "site_limit_clipped";
983
+ } else if (startupCapped.capped && reason === "allocated") {
984
+ reason = "startup_cap_current_power";
985
+ } else if (staleList.length > 0 && reason === "allocated") {
986
+ reason = "stale_policy_applied";
987
+ }
988
+ }
989
+
990
+ if (!siteLimitValid && staleList.length > 0) {
991
+ const staleApplied = applyPolicyToStale(
992
+ allocations,
993
+ activeList,
994
+ staleList,
995
+ node.staleTelemetryBehavior,
996
+ node.staleTelemetryFixedValueW
997
+ );
998
+ allocations = staleApplied.allocations;
999
+ if (staleApplied.changed && reason === "allocated") {
1000
+ reason = "stale_policy_applied";
1001
+ }
1002
+ }
1003
+
1004
+ // A site-limit command is already the output of the closed-loop controller.
1005
+ // Apply it immediately so multi-inverter does not add another lag that can
1006
+ // make control-export over-correct. Keep smoothing only for telemetry-driven
1007
+ // redistribution between inverters.
1008
+ const effectiveSmoothingAlpha = triggerSource === "site_limit"
1009
+ ? 1
1010
+ : node.allocationSmoothingAlpha;
1011
+ const smoothedAllocations = smoothAllocations(allocations, effectiveSmoothingAlpha, lastSmoothedAllocation);
1012
+ const controlMode = siteLimitMode === "reactive" ? "reactive" : "active";
1013
+ const limitField = perInverterLimitFieldForMode(controlMode);
1014
+ const signedMultiplier = controlMode === "reactive" && Number(siteLimitW) < 0 ? -1 : 1;
1015
+
1016
+ const limits = validRowList.map(function (inv) {
1017
+ const t = telemetry[inv.id];
1018
+ const currentPowerW = t && Number.isFinite(t.powerW) ? t.powerW : null;
1019
+ const allocationPowerW = t && Number.isFinite(t.filteredPowerW) ? t.filteredPowerW : currentPowerW;
1020
+ const isFreshPower = !!(t && Number.isFinite(t.powerW) && staleList.indexOf(inv.id) === -1);
1021
+ const isDisabled = !inv.enabled;
1022
+ let limitW = isDisabled
1023
+ ? 0
1024
+ : Math.round(Number(smoothedAllocations[inv.id] || 0));
1025
+ const previousEmittedLimit = Number(lastEmittedLimitById[inv.id]);
1026
+ const deadbandActive = node.allocationDeadbandW > 0 &&
1027
+ Number.isFinite(previousEmittedLimit) &&
1028
+ !isDisabled &&
1029
+ Math.abs(limitW - previousEmittedLimit) > 0 &&
1030
+ Math.abs(limitW - previousEmittedLimit) < node.allocationDeadbandW;
1031
+
1032
+ if (deadbandActive) {
1033
+ limitW = previousEmittedLimit;
1034
+ }
1035
+
1036
+ const finalLimit = controlMode === "reactive"
1037
+ ? (signedMultiplier * Math.abs(limitW))
1038
+ : limitW;
1039
+ const rampDirection = Number.isFinite(previousEmittedLimit)
1040
+ ? (limitW > previousEmittedLimit ? "up" : (limitW < previousEmittedLimit ? "down" : "hold"))
1041
+ : "hold";
1042
+ const itemRamp = rampForDirection(ramp, rampDirection);
1043
+ lastEmittedLimitById[inv.id] = limitW;
1044
+
1045
+ const item = {
1046
+ id: inv.id,
1047
+ enabled: !isDisabled,
1048
+ disabled: isDisabled,
1049
+ min_w: inv.minW,
1050
+ max_w: inv.maxW,
1051
+ nominal_power_w: inv.nominalPowerW,
1052
+ nominal_reactive_power_var: inv.nominalPowerW,
1053
+ current_power_w: currentPowerW,
1054
+ allocation_power_w: allocationPowerW,
1055
+ current_power_fresh: isFreshPower,
1056
+ allocation_deadband_applied: deadbandActive,
1057
+ ramp_direction: rampDirection
1058
+ };
1059
+ item[limitField] = finalLimit;
1060
+ item.nominal_power_w = inv.nominalPowerW;
1061
+ item.nominal_reactive_power_var = inv.nominalPowerW;
1062
+ if (lastSiteLimitMsg && lastSiteLimitMsg.commandId) {
1063
+ item.command_id = lastSiteLimitMsg.commandId;
1064
+ item.command_requested_at_ms = lastSiteLimitMsg.commandRequestedAtMs;
1065
+ item.control_received_at_ms = lastSiteLimitMsg.controlReceivedAtMs;
1066
+ item.control_calculated_at_ms = lastSiteLimitMsg.controlCalculatedAtMs;
1067
+ item.allocated_at_ms = nowMs;
1068
+ }
1069
+
1070
+ if (!isDisabled && itemRamp && itemRamp.type && itemRamp.type !== "none" && Number.isFinite(Number(itemRamp.value)) && Number(itemRamp.value) > 0) {
1071
+ item.ramp = { type: itemRamp.type, value: Number(itemRamp.value) };
1072
+ }
1073
+
1074
+ return item;
1075
+ });
1076
+
1077
+ outputs[0] = {
1078
+ topic: "multi-inverter/limits",
1079
+ payload: {
1080
+ limits: limits,
1081
+ site_limit_w: controlMode === "active" ? Math.round(siteLimitW) : null,
1082
+ site_limit_var: controlMode === "reactive" ? Math.round(siteLimitW) : null,
1083
+ control_mode: controlMode,
1084
+ allocation_strategy: node.strategy,
1085
+ effective_strategy: effectiveStrategy,
1086
+ telemetry_filter_alpha: node.telemetryFilterAlpha,
1087
+ allocation_smoothing_applied: effectiveSmoothingAlpha < 0.999,
1088
+ allocation_deadband_w: node.allocationDeadbandW,
1089
+ stale_inputs: staleList,
1090
+ fresh_power_map: freshMap,
1091
+ total_fresh_power_w: totalFreshPower(freshMap),
1092
+ available_capacity_w: allocationResult.availableCapacityW,
1093
+ unallocated_w: allocationResult.unallocatedW,
1094
+ clipped: allocationResult.clipped,
1095
+ active_inverters: activeList.map(function (a) { return a.id; }),
1096
+ ramp: (ramp && ramp.type && ramp.type !== "none") ? { type: ramp.type, value: Number(ramp.value || 0) } : null,
1097
+ timestamp_ms: nowMs
1098
+ }
1099
+ };
1100
+ if (lastSiteLimitMsg && lastSiteLimitMsg.commandId) {
1101
+ outputs[0].payload.command_id = lastSiteLimitMsg.commandId;
1102
+ outputs[0].payload.command_requested_at_ms = lastSiteLimitMsg.commandRequestedAtMs;
1103
+ outputs[0].payload.control_received_at_ms = lastSiteLimitMsg.controlReceivedAtMs;
1104
+ outputs[0].payload.control_calculated_at_ms = lastSiteLimitMsg.controlCalculatedAtMs;
1105
+ outputs[0].payload.allocated_at_ms = nowMs;
1106
+ }
1107
+ outputs[0].payload[siteLimitFieldForMode(controlMode)] = Math.round(siteLimitW);
1108
+
1109
+ statusFor(reason, staleList, activeList.length, configuredCount, siteLimitW);
1110
+
1111
+ const controllerInfo = lastSiteLimitMsg ? {
1112
+ reason: lastSiteLimitMsg.reason || null,
1113
+ stale: !!lastSiteLimitMsg.controllerStale,
1114
+ errorW: lastSiteLimitMsg.controllerErrorW,
1115
+ errorVar: lastSiteLimitMsg.controllerErrorVar,
1116
+ timestampMs: lastSiteLimitMsg.controllerTimestampMs
1117
+ } : null;
1118
+
1119
+ const signature = JSON.stringify({
1120
+ reason: reason,
1121
+ siteLimitW: Math.round(siteLimitW),
1122
+ strategy: effectiveStrategy,
1123
+ stale: staleList,
1124
+ alloc: roundAllocations(smoothedAllocations),
1125
+ ramp: ramp
1126
+ });
1127
+
1128
+ const signatureChanged = signature !== lastDebugSignature || reason !== lastReason;
1129
+ lastDebugSignature = signature;
1130
+ lastReason = reason;
1131
+
1132
+ if (node.enableDebugOutput && shouldEmitDebug(node.debugMode, reason, signatureChanged)) {
1133
+ outputs[outputs.length - 1] = debugMessage(
1134
+ reason,
1135
+ true,
1136
+ Math.round(siteLimitW),
1137
+ nowMs,
1138
+ activeList,
1139
+ staleList,
1140
+ freshMap,
1141
+ roundAllocations(smoothedAllocations),
1142
+ allocationResult,
1143
+ ramp,
1144
+ effectiveStrategy,
1145
+ controllerInfo,
1146
+ triggerSource,
1147
+ siteLimitMode
1148
+ );
1149
+ }
1150
+
1151
+ hasEmittedAllocation = true;
1152
+ emit(outputs);
1153
+
1154
+ }
1155
+
1156
+
1157
+ node.on("input", function (msg, send, done) {
1158
+
1159
+ lastSendFn = send;
1160
+
1161
+ try {
1162
+ const topic = String(msg.topic || "").trim();
1163
+ const ts = extractTimestampMs(msg);
1164
+ const nowMs = Date.now();
1165
+
1166
+ const maybeSiteLimit = extractSiteLimitInfo(msg);
1167
+
1168
+ if (maybeSiteLimit) {
1169
+ const incomingMode = maybeSiteLimit.siteLimitMode === "reactive" ? "reactive" : "active";
1170
+ const incomingLimit = Number(maybeSiteLimit.siteLimit || 0);
1171
+ lastSiteLimitMode = incomingMode;
1172
+ lastSiteLimit = incomingMode === "reactive"
1173
+ ? incomingLimit
1174
+ : Math.max(0, incomingLimit);
1175
+ lastSiteLimitMsg = maybeSiteLimit;
1176
+ lastSiteLimitTs = Date.now();
1177
+
1178
+ if (telemetryDebounceTimer) {
1179
+ clearTimeout(telemetryDebounceTimer);
1180
+ telemetryDebounceTimer = null;
1181
+ }
1182
+
1183
+ runAllocationCycle("site_limit");
1184
+ done();
1185
+ return;
1186
+ }
1187
+
1188
+ const activeList = activeInverters();
1189
+ const payloadObj = (msg.payload && typeof msg.payload === "object") ? msg.payload : null;
1190
+ const payloadPowerW = parseTelemetryPower(msg.payload, lastSiteLimitMode);
1191
+ const telemetryLike = Number.isFinite(payloadPowerW);
1192
+ const payloadInverterId = payloadObj
1193
+ ? String(payloadObj.inverter_id || payloadObj.id || "").trim()
1194
+ : "";
1195
+ const msgInverterId = String(msg.inverter_id || msg.id || "").trim();
1196
+ const resolvedInverterId = payloadInverterId || msgInverterId || topic;
1197
+
1198
+ if (telemetryLike && (ts - nowMs) > node.maxFutureTimestampMs) {
1199
+ emitFailsafeAllZero(
1200
+ "future_telemetry_timestamp",
1201
+ "Timestamp telemetrie din viitor peste limita admisa"
1202
+ );
1203
+ done();
1204
+ return;
1205
+ }
1206
+
1207
+ if (telemetryLike && !resolvedInverterId) {
1208
+ emitFailsafeAllZero(
1209
+ "missing_inverter_id",
1210
+ "Mesaj de telemetrie fara inverter_id (payload/msg/topic)"
1211
+ );
1212
+ done();
1213
+ return;
1214
+ }
1215
+
1216
+ if (
1217
+ telemetryLike &&
1218
+ payloadInverterId &&
1219
+ msgInverterId &&
1220
+ payloadInverterId !== msgInverterId
1221
+ ) {
1222
+ emitFailsafeAllZero(
1223
+ "inverter_id_mismatch",
1224
+ "payload.inverter_id='" + payloadInverterId + "' difera de msg.inverter_id='" + msgInverterId + "'"
1225
+ );
1226
+ done();
1227
+ return;
1228
+ }
1229
+
1230
+ if (
1231
+ telemetryLike &&
1232
+ topic &&
1233
+ resolvedInverterId &&
1234
+ topic !== resolvedInverterId
1235
+ ) {
1236
+ emitFailsafeAllZero(
1237
+ "inverter_id_mismatch",
1238
+ "topic='" + topic + "' difera de inverter_id='" + resolvedInverterId + "'"
1239
+ );
1240
+ done();
1241
+ return;
1242
+ }
1243
+
1244
+ const telemetryId = resolvedInverterId;
1245
+ const knownIds = node.inverters
1246
+ .filter(function (inv) { return isValidInverter(inv); })
1247
+ .map(function (inv) { return inv.id; });
1248
+
1249
+ if (
1250
+ telemetryLike &&
1251
+ telemetryId &&
1252
+ knownIds.indexOf(telemetryId) === -1
1253
+ ) {
1254
+ emitFailsafeAllZero(
1255
+ "unknown_inverter_id",
1256
+ "ID '" + telemetryId + "' nu exista in lista de invertoare configurata"
1257
+ );
1258
+ done();
1259
+ return;
1260
+ }
1261
+
1262
+ if (telemetryLike && telemetryId && activeList.some(function (inv) { return inv.id === telemetryId; })) {
1263
+ const powerW = Number(payloadPowerW);
1264
+ const source = getTelemetrySource(msg);
1265
+ const prev = telemetry[telemetryId];
1266
+
1267
+ if (prev && Number.isFinite(prev.ts) && ts < prev.ts) {
1268
+ node.status({ fill: "yellow", shape: "ring", text: "ignored older telemetry | " + telemetryId });
1269
+ done();
1270
+ return;
1271
+ }
1272
+ const potentialUnknownSourceConflict = !!(
1273
+ prev &&
1274
+ prev.source === "unknown" &&
1275
+ source === "unknown" &&
1276
+ Number.isFinite(prev.ts) &&
1277
+ Math.abs(Number(ts) - Number(prev.ts)) <= 250 &&
1278
+ Math.abs(Number(prev.powerW) - powerW) > 0.001
1279
+ );
1280
+
1281
+ if (
1282
+ prev &&
1283
+ Number.isFinite(prev.powerW) &&
1284
+ Number.isFinite(powerW) &&
1285
+ (
1286
+ (prev.source && source && prev.source !== source) ||
1287
+ potentialUnknownSourceConflict
1288
+ ) &&
1289
+ Math.abs(Number(prev.powerW) - powerW) > 0.001
1290
+ ) {
1291
+ emitFailsafeAllZero(
1292
+ "duplicate_inverter_id_conflict",
1293
+ "ID '" + telemetryId + "' primit din surse diferite cu valori diferite (" + prev.powerW + " vs " + powerW + ")"
1294
+ );
1295
+ done();
1296
+ return;
1297
+ }
1298
+
1299
+ const filteredPowerW = prev && Number.isFinite(prev.filteredPowerW)
1300
+ ? node.telemetryFilterAlpha * powerW + (1 - node.telemetryFilterAlpha) * prev.filteredPowerW
1301
+ : powerW;
1302
+ telemetry[telemetryId] = {
1303
+ powerW: powerW,
1304
+ filteredPowerW: filteredPowerW,
1305
+ ts: ts,
1306
+ source: source
1307
+ };
1308
+ publishTotalPv(Date.now());
1309
+
1310
+ if (!node.recalculateOnTelemetry) {
1311
+ done();
1312
+ return;
1313
+ }
1314
+
1315
+ if (node.telemetryRecalcDebounceMs <= 0) {
1316
+ runAllocationCycle("telemetry");
1317
+ } else {
1318
+ if (telemetryDebounceTimer) {
1319
+ clearTimeout(telemetryDebounceTimer);
1320
+ }
1321
+
1322
+ telemetryDebounceTimer = setTimeout(function () {
1323
+ telemetryDebounceTimer = null;
1324
+ runAllocationCycle("telemetry");
1325
+ }, node.telemetryRecalcDebounceMs);
1326
+ }
1327
+
1328
+ done();
1329
+ return;
1330
+ }
1331
+
1332
+ done();
1333
+ } catch (err) {
1334
+ node.status({ fill: "red", shape: "ring", text: err.message });
1335
+
1336
+ const outputs = new Array(1 + (node.enableDebugOutput ? 1 : 0)).fill(null);
1337
+
1338
+ if (node.enableDebugOutput) {
1339
+ outputs[outputs.length - 1] = {
1340
+ topic: "multi-inverter/diagnostic",
1341
+ payload: {
1342
+ valid: false,
1343
+ reason: "runtime_error",
1344
+ error: err.message,
1345
+ timestamp_ms: Date.now(),
1346
+ debug_mode: node.debugMode,
1347
+ debug_enabled: node.enableDebugOutput
1348
+ }
1349
+ };
1350
+
1351
+ emit(outputs);
1352
+ }
1353
+
1354
+ done(err);
1355
+ }
1356
+
1357
+ });
1358
+
1359
+ const watchdogIntervalMs = Math.max(
1360
+ 250,
1361
+ Math.min(
1362
+ node.telemetryTimeoutMs > 0 ? node.telemetryTimeoutMs : 1000,
1363
+ node.siteLimitTimeoutMs > 0 ? node.siteLimitTimeoutMs : 1000,
1364
+ 1000
1365
+ )
1366
+ );
1367
+ watchdogTimer = setInterval(function () {
1368
+ if (hasEmittedAllocation || Number.isFinite(lastSiteLimit)) {
1369
+ runAllocationCycle("watchdog");
1370
+ }
1371
+ }, watchdogIntervalMs);
1372
+
1373
+
1374
+ node.on("close", function (done) {
1375
+ if (telemetryDebounceTimer) {
1376
+ clearTimeout(telemetryDebounceTimer);
1377
+ telemetryDebounceTimer = null;
1378
+ }
1379
+ if (watchdogTimer) {
1380
+ clearInterval(watchdogTimer);
1381
+ watchdogTimer = null;
1382
+ }
1383
+ done();
1384
+ });
1385
+
1386
+
1387
+ });
1388
+ }
1389
+
1390
+
1391
+ RED.nodes.registerType("multi-inverter", ControlNode);
1392
+
1393
+ };