@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.
- package/NOTICE +56 -0
- package/PROPRIETARY-LICENSE.txt +157 -0
- package/README.md +105 -0
- package/TERMS-AND-CONDITIONS.md +311 -0
- package/advantech-read/advantech-read.html +775 -0
- package/advantech-read/advantech-read.js +449 -0
- package/advantech-write/advantech-write.html +413 -0
- package/advantech-write/advantech-write.js +338 -0
- package/bess-control/bess-control.html +569 -0
- package/bess-control/bess-control.js +541 -0
- package/bess-control/lib/allocation.js +149 -0
- package/bess-control/lib/batteries.js +86 -0
- package/bess-control/lib/helpers.js +157 -0
- package/bess-control/lib/messages.js +131 -0
- package/bess-control/lib/runtime.js +156 -0
- package/carlo-gavazzi/carlo-gavazzi.html +4327 -0
- package/carlo-gavazzi/carlo-gavazzi.js +3081 -0
- package/control-export/control-export.html +3644 -0
- package/control-export/control-export.js +2192 -0
- package/control-reactive/control-reactive.html +3136 -0
- package/control-reactive/control-reactive.js +2124 -0
- package/fronius-emulator/README.md +258 -0
- package/fronius-emulator/fronius-emulator.html +170 -0
- package/fronius-emulator/fronius-emulator.js +520 -0
- package/fronius-emulator/lib/binary-codec.js +65 -0
- package/fronius-emulator/lib/fronius-register-map.js +185 -0
- package/fronius-emulator/lib/modbus-parser.js +61 -0
- package/fronius-emulator/lib/modbus-tcp-server.js +224 -0
- package/fronius-emulator/lib/validation.js +206 -0
- package/fronius-emulator/lib/virtual-meter.js +132 -0
- package/fronius-read/fronius-read.html +3944 -0
- package/fronius-read/fronius-read.js +1466 -0
- package/fronius-read/lib/inverter-profiles.js +711 -0
- package/fronius-read/lib/sunspec-core.js +303 -0
- package/fronius-read/lib/sunspec-model-parsers.js +845 -0
- package/fronius-write/fronius-write.html +1789 -0
- package/fronius-write/fronius-write.js +2531 -0
- package/influxdb/influxdb.html +1077 -0
- package/influxdb/influxdb.js +1487 -0
- package/janitza/janitza.html +2562 -0
- package/janitza/janitza.js +1500 -0
- package/janitza/lib/janitza-profiles.js +371 -0
- package/lib/a3-test-server.js +199 -0
- package/lib/adam-6000-profiles.js +173 -0
- package/lib/licensing/constants.js +103 -0
- package/lib/licensing/device-id.js +251 -0
- package/lib/licensing/errors.js +21 -0
- package/lib/licensing/license-client.js +377 -0
- package/lib/licensing/license-state.js +50 -0
- package/lib/licensing/node-guard.js +150 -0
- package/lib/licensing/package-integrity.js +167 -0
- package/lib/licensing/package-integrity.json +513 -0
- package/lib/licensing/public-key.pem +3 -0
- package/lib/licensing/public-key.pem.example +2 -0
- package/lib/licensing/public-keys.js +18 -0
- package/lib/licensing/storage.js +63 -0
- package/lib/licensing/token-verifier.js +84 -0
- package/lib/numbers.js +26 -0
- package/license/atelier-license.html +88 -0
- package/license/atelier-license.js +170 -0
- package/modbus-tcp-discover/modbus-tcp-discover.html +190 -0
- package/modbus-tcp-discover/modbus-tcp-discover.js +706 -0
- package/multi-battery/multi-battery.html +649 -0
- package/multi-battery/multi-battery.js +668 -0
- package/multi-grid/multi-grid.html +745 -0
- package/multi-grid/multi-grid.js +1392 -0
- package/multi-inverter/lib/engine-core.js +237 -0
- package/multi-inverter/multi-inverter.html +2674 -0
- package/multi-inverter/multi-inverter.js +1393 -0
- package/package.json +109 -0
- package/phoenix-contact/lib/eem-ma370-advanced.js +175 -0
- package/phoenix-contact/lib/eem-ma370.js +138 -0
- package/phoenix-contact/lib/integrations.js +149 -0
- package/phoenix-contact/lib/phoenix-contact-profile.js +35030 -0
- package/phoenix-contact/phoenix-contact.html +840 -0
- package/phoenix-contact/phoenix-contact.js +679 -0
- package/pv-generator/pv-generator.html +284 -0
- package/pv-generator/pv-generator.js +387 -0
- package/pylontech-read/pylontech-read.html +763 -0
- package/pylontech-read/pylontech-read.js +518 -0
- package/pylontech-write/pylontech-write.html +405 -0
- package/pylontech-write/pylontech-write.js +546 -0
- package/scada-aggregator/lib/aggregate.js +72 -0
- package/scada-aggregator/lib/groups.js +39 -0
- package/scada-aggregator/scada-aggregator.html +108 -0
- package/scada-aggregator/scada-aggregator.js +117 -0
- package/scada-server/lib/aggregator-map.js +58 -0
- package/scada-server/lib/encoding.js +235 -0
- package/scada-server/lib/modbus-server.js +492 -0
- package/scada-server/lib/object-path.js +18 -0
- package/scada-server/lib/register-map.js +288 -0
- package/scada-server/scada-server.html +1419 -0
- package/scada-server/scada-server.js +704 -0
- package/scripts/check-syntax.js +66 -0
- package/scripts/package-integrity.js +46 -0
- package/scripts/package-release.js +116 -0
- package/scripts/run-tests.js +2178 -0
- package/scripts/tgz-integrity.js +74 -0
- package/sma-read/lib/sunspec-core.js +83 -0
- package/sma-read/lib/sunspec-model-parsers.js +853 -0
- package/sma-read/sma-read.html +659 -0
- package/sma-read/sma-read.js +458 -0
- package/sma-write/sma-write.html +105 -0
- package/sma-write/sma-write.js +270 -0
- package/victron-read/lib/mqtt-state.js +176 -0
- package/victron-read/victron-read.html +1074 -0
- package/victron-read/victron-read.js +1222 -0
|
@@ -0,0 +1,1222 @@
|
|
|
1
|
+
module.exports = function (RED) {
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const { runLicensedNode } = require("../lib/licensing/node-guard");
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
atLeast,
|
|
8
|
+
clampNumber,
|
|
9
|
+
roundedAtLeast
|
|
10
|
+
} = require("../lib/numbers");
|
|
11
|
+
const mqttState = require("./lib/mqtt-state");
|
|
12
|
+
|
|
13
|
+
let mqttLib = null;
|
|
14
|
+
try {
|
|
15
|
+
mqttLib = require("mqtt");
|
|
16
|
+
} catch (error) {
|
|
17
|
+
mqttLib = null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isPlainObject(value) {
|
|
21
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function normalizePayloadMode(mode) {
|
|
25
|
+
if (mode === "selected" || mode === "full") {
|
|
26
|
+
return mode;
|
|
27
|
+
}
|
|
28
|
+
return "default";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function normalizeSourceType(sourceType) {
|
|
32
|
+
return sourceType === "mqtt" ? "mqtt" : "global";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const BASIC_CATEGORY_SEGMENTS = new Set([
|
|
36
|
+
"vebus",
|
|
37
|
+
"battery",
|
|
38
|
+
"batteries",
|
|
39
|
+
"pvinverter",
|
|
40
|
+
"grid",
|
|
41
|
+
"solarcharger",
|
|
42
|
+
"inverter",
|
|
43
|
+
"invertercharger"
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
function isBasicMetricSegment(segment) {
|
|
47
|
+
const s = String(segment || "").toLowerCase();
|
|
48
|
+
if (!s) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (["v", "i", "p", "temp", "soc", "soh", "state", "mode"].includes(s)) {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return s.includes("voltage") || s.includes("current") || s.includes("power") ||
|
|
57
|
+
s.includes("temperature") || s.includes("frequency") || s.includes("energy") ||
|
|
58
|
+
s.includes("yield") || s.includes("consumption");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isBasicOperationalFieldPath(path) {
|
|
62
|
+
const segments = String(path || "").toLowerCase().split(".").filter(Boolean);
|
|
63
|
+
if (!segments.length) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const hasCategory = segments.some(function (segment) {
|
|
68
|
+
return BASIC_CATEGORY_SEGMENTS.has(segment);
|
|
69
|
+
});
|
|
70
|
+
if (!hasCategory) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return segments.some(isBasicMetricSegment);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function normalizeCsvTokens(value) {
|
|
78
|
+
if (Array.isArray(value)) {
|
|
79
|
+
return value
|
|
80
|
+
.map(function (item) { return String(item || "").trim().toLowerCase(); })
|
|
81
|
+
.filter(Boolean);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (typeof value === "string") {
|
|
85
|
+
return value
|
|
86
|
+
.split(",")
|
|
87
|
+
.map(function (item) { return String(item || "").trim().toLowerCase(); })
|
|
88
|
+
.filter(Boolean);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function pathMatchesAnyToken(path, tokens) {
|
|
95
|
+
if (!tokens || !tokens.length) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const normalized = String(path || "").trim().toLowerCase();
|
|
100
|
+
if (!normalized) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
105
|
+
const token = tokens[index];
|
|
106
|
+
if (token && normalized.includes(token)) {
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function normalizePayloadFields(payloadFields) {
|
|
115
|
+
if (Array.isArray(payloadFields)) {
|
|
116
|
+
const unique = Array.from(new Set(payloadFields.map(function (value) {
|
|
117
|
+
return String(value || "").trim();
|
|
118
|
+
}).filter(Boolean)));
|
|
119
|
+
|
|
120
|
+
if (unique.includes("full")) {
|
|
121
|
+
return ["full"];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return unique;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (typeof payloadFields === "string") {
|
|
128
|
+
const trimmed = payloadFields.trim();
|
|
129
|
+
if (!trimmed) {
|
|
130
|
+
return ["full"];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (trimmed[0] === "[") {
|
|
134
|
+
try {
|
|
135
|
+
const parsed = JSON.parse(trimmed);
|
|
136
|
+
if (Array.isArray(parsed)) {
|
|
137
|
+
return normalizePayloadFields(parsed);
|
|
138
|
+
}
|
|
139
|
+
} catch (error) {
|
|
140
|
+
// ignore invalid JSON
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return [trimmed];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return ["full"];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function getByPath(source, path) {
|
|
151
|
+
if (!path) {
|
|
152
|
+
return source;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (source !== null && source !== undefined && Object.prototype.hasOwnProperty.call(source, path)) {
|
|
156
|
+
return source[path];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!path.includes(".")) {
|
|
160
|
+
return source ? source[path] : undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const parts = path.split(".").map(function (part) {
|
|
164
|
+
return part.trim();
|
|
165
|
+
}).filter(Boolean);
|
|
166
|
+
|
|
167
|
+
let current = source;
|
|
168
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
169
|
+
if (current === null || current === undefined) {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Support flattened keys that already contain dots, e.g. "a.b.c"
|
|
174
|
+
// stored directly on the current object.
|
|
175
|
+
if (isPlainObject(current)) {
|
|
176
|
+
const remainingPath = parts.slice(index).join(".");
|
|
177
|
+
if (Object.prototype.hasOwnProperty.call(current, remainingPath)) {
|
|
178
|
+
return current[remainingPath];
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
current = current[parts[index]];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return current;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function setByPath(target, path, value) {
|
|
189
|
+
if (!isPlainObject(target) || !path) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const parts = String(path).split(".").map(function (part) {
|
|
194
|
+
return part.trim();
|
|
195
|
+
}).filter(Boolean);
|
|
196
|
+
|
|
197
|
+
if (!parts.length) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let current = target;
|
|
202
|
+
for (let index = 0; index < parts.length - 1; index += 1) {
|
|
203
|
+
const key = parts[index];
|
|
204
|
+
if (!isPlainObject(current[key])) {
|
|
205
|
+
current[key] = {};
|
|
206
|
+
}
|
|
207
|
+
current = current[key];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
current[parts[parts.length - 1]] = value;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function isSupportedInfluxValue(value) {
|
|
214
|
+
if (typeof value === "number") {
|
|
215
|
+
return Number.isFinite(value);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (typeof value === "boolean") {
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (typeof value === "string") {
|
|
223
|
+
return value.trim() !== "";
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function fieldPathToCompactKey(path) {
|
|
230
|
+
const compact = String(path || "")
|
|
231
|
+
.trim()
|
|
232
|
+
.replace(/[^a-zA-Z0-9_]+/g, "_")
|
|
233
|
+
.replace(/^_+|_+$/g, "")
|
|
234
|
+
.toLowerCase();
|
|
235
|
+
|
|
236
|
+
return compact || "value";
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function flattenSupportedFields(source, prefix, target, meta, seen, depth) {
|
|
240
|
+
const nextDepth = Number.isFinite(depth) ? depth : 0;
|
|
241
|
+
if (nextDepth > 20) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (source === null || source === undefined) {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (isSupportedInfluxValue(source) && prefix) {
|
|
250
|
+
target[prefix] = source;
|
|
251
|
+
if (meta) {
|
|
252
|
+
meta[prefix] = typeof source;
|
|
253
|
+
}
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (Array.isArray(source)) {
|
|
258
|
+
source.forEach(function (item, index) {
|
|
259
|
+
const key = prefix ? (prefix + "." + index) : String(index);
|
|
260
|
+
flattenSupportedFields(item, key, target, meta, seen, nextDepth + 1);
|
|
261
|
+
});
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (!isPlainObject(source)) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (seen.has(source)) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
seen.add(source);
|
|
273
|
+
|
|
274
|
+
Object.keys(source).forEach(function (key) {
|
|
275
|
+
const cleanKey = String(key || "").trim();
|
|
276
|
+
if (!cleanKey) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const value = source[key];
|
|
281
|
+
const path = prefix ? (prefix + "." + cleanKey) : cleanKey;
|
|
282
|
+
flattenSupportedFields(value, path, target, meta, seen, nextDepth + 1);
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function decodeMqttPayload(payloadBuffer) {
|
|
287
|
+
return mqttState.decodePayload(payloadBuffer);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function scopeFlatMapByPrefix(source, prefixPath) {
|
|
291
|
+
if (!isPlainObject(source) || !prefixPath) {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const prefix = String(prefixPath || "").trim();
|
|
296
|
+
if (!prefix) {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const subset = {};
|
|
301
|
+
const fullPrefix = prefix + ".";
|
|
302
|
+
Object.keys(source).forEach(function (key) {
|
|
303
|
+
const stringKey = String(key || "");
|
|
304
|
+
if (stringKey === prefix) {
|
|
305
|
+
subset["value"] = source[key];
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (stringKey.indexOf(fullPrefix) === 0) {
|
|
309
|
+
// Keep full path keys to preserve field categorization in basic mode
|
|
310
|
+
// and keep selection stable between scoped/unscoped views.
|
|
311
|
+
subset[stringKey] = source[key];
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
return Object.keys(subset).length ? subset : null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function getScopedSource(source, path) {
|
|
319
|
+
if (!path) {
|
|
320
|
+
return source;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const byPath = getByPath(source, path);
|
|
324
|
+
if (byPath !== undefined) {
|
|
325
|
+
return byPath;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const byPrefix = scopeFlatMapByPrefix(source, path);
|
|
329
|
+
if (byPrefix !== null) {
|
|
330
|
+
return byPrefix;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return undefined;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function rootFromFieldPath(path) {
|
|
337
|
+
const segments = String(path || "").split(".").filter(Boolean);
|
|
338
|
+
if (segments.length >= 2) {
|
|
339
|
+
return segments.slice(0, 2).join(".");
|
|
340
|
+
}
|
|
341
|
+
return segments[0] || "";
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function extractFieldInventory(source) {
|
|
345
|
+
const flat = {};
|
|
346
|
+
const types = {};
|
|
347
|
+
|
|
348
|
+
flattenSupportedFields(source, "", flat, types, new WeakSet(), 0);
|
|
349
|
+
|
|
350
|
+
return Object.keys(flat)
|
|
351
|
+
.sort(function (a, b) { return a.localeCompare(b); })
|
|
352
|
+
.map(function (path) {
|
|
353
|
+
return {
|
|
354
|
+
path: path,
|
|
355
|
+
type: types[path] || typeof flat[path],
|
|
356
|
+
sample: flat[path]
|
|
357
|
+
};
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function extractRootInventory(source) {
|
|
362
|
+
const fields = extractFieldInventory(source);
|
|
363
|
+
const roots = new Set();
|
|
364
|
+
|
|
365
|
+
fields.forEach(function (item) {
|
|
366
|
+
const root = rootFromFieldPath(item.path);
|
|
367
|
+
if (root) {
|
|
368
|
+
roots.add(root);
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
return Array.from(roots).sort(function (a, b) { return a.localeCompare(b); });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function mergeDecodedMqttMessage(snapshot, topic, decodedValue, topicPathMode, stripPortalId) {
|
|
376
|
+
mqttState.mergeDecodedMessage(snapshot, new Map(), topic, decodedValue, {
|
|
377
|
+
topicPathMode: topicPathMode,
|
|
378
|
+
stripPortalId: stripPortalId
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function discoverMqttSnapshot(options, timeoutMs) {
|
|
383
|
+
return new Promise(function (resolve, reject) {
|
|
384
|
+
if (!mqttLib) {
|
|
385
|
+
reject(new Error("Lipseste pachetul 'mqtt'."));
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const mqttUrl = String(options.mqttUrl || "mqtt://127.0.0.1:1883").trim();
|
|
390
|
+
const topicFilter = String(options.mqttTopicFilter || "N/#").trim() || "N/#";
|
|
391
|
+
const qos = clampNumber(options.mqttQos, 0, 2, 0);
|
|
392
|
+
const topicPathMode = options.mqttTopicPathMode === "raw" ? "raw" : "victron";
|
|
393
|
+
const stripPortalId = options.mqttStripPortalId !== false;
|
|
394
|
+
const username = String(options.mqttUsername || "").trim();
|
|
395
|
+
const password = String(options.mqttPassword || "");
|
|
396
|
+
const configuredClientId = String(options.mqttClientId || "").trim();
|
|
397
|
+
const clientId = configuredClientId
|
|
398
|
+
? ("victron-discover-" + Math.random().toString(16).slice(2, 8))
|
|
399
|
+
: "";
|
|
400
|
+
let portalId = String(options.mqttPortalId || "").trim();
|
|
401
|
+
let keepaliveSent = false;
|
|
402
|
+
|
|
403
|
+
const connectOptions = {
|
|
404
|
+
keepalive: 30,
|
|
405
|
+
reconnectPeriod: 0,
|
|
406
|
+
connectTimeout: clampNumber(timeoutMs, 2000, 30000, 5000),
|
|
407
|
+
clean: true
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
if (clientId) connectOptions.clientId = clientId;
|
|
411
|
+
if (username) connectOptions.username = username;
|
|
412
|
+
if (password) connectOptions.password = password;
|
|
413
|
+
|
|
414
|
+
const snapshot = {};
|
|
415
|
+
let messages = 0;
|
|
416
|
+
let done = false;
|
|
417
|
+
let timeoutHandle = null;
|
|
418
|
+
let client = null;
|
|
419
|
+
|
|
420
|
+
function finish(err) {
|
|
421
|
+
if (done) return;
|
|
422
|
+
done = true;
|
|
423
|
+
if (timeoutHandle) {
|
|
424
|
+
clearTimeout(timeoutHandle);
|
|
425
|
+
timeoutHandle = null;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (client) {
|
|
429
|
+
try {
|
|
430
|
+
client.removeAllListeners("connect");
|
|
431
|
+
client.removeAllListeners("message");
|
|
432
|
+
client.removeAllListeners("error");
|
|
433
|
+
client.end(true);
|
|
434
|
+
} catch (closeError) {
|
|
435
|
+
// ignore close errors
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (err) {
|
|
440
|
+
reject(err);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
resolve({
|
|
444
|
+
snapshot: snapshot,
|
|
445
|
+
messages: messages,
|
|
446
|
+
portalId: portalId
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
try {
|
|
451
|
+
client = mqttLib.connect(mqttUrl, connectOptions);
|
|
452
|
+
} catch (connectError) {
|
|
453
|
+
finish(connectError);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
timeoutHandle = setTimeout(function () {
|
|
458
|
+
finish();
|
|
459
|
+
}, clampNumber(timeoutMs, 2000, 20000, 5000));
|
|
460
|
+
|
|
461
|
+
client.on("connect", function () {
|
|
462
|
+
client.subscribe(topicFilter, { qos: qos }, function (subErr) {
|
|
463
|
+
if (subErr) {
|
|
464
|
+
finish(subErr);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (portalId) {
|
|
468
|
+
client.publish("R/" + portalId + "/keepalive", "");
|
|
469
|
+
keepaliveSent = true;
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
client.on("message", function (topic, payloadBuffer) {
|
|
475
|
+
const value = decodeMqttPayload(payloadBuffer);
|
|
476
|
+
if (value === undefined) {
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
mergeDecodedMqttMessage(snapshot, topic, value, topicPathMode, stripPortalId);
|
|
480
|
+
messages += 1;
|
|
481
|
+
if (!portalId) {
|
|
482
|
+
portalId = mqttState.detectPortalId(topic);
|
|
483
|
+
}
|
|
484
|
+
if (portalId && !keepaliveSent) {
|
|
485
|
+
client.publish("R/" + portalId + "/keepalive", "");
|
|
486
|
+
keepaliveSent = true;
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
client.on("error", function (err) {
|
|
491
|
+
finish(err || new Error("MQTT connection error"));
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
RED.httpAdmin.get("/victron-read/fields/:id?", function (req, res) {
|
|
497
|
+
const nodeId = String((req.params && req.params.id) || "").trim();
|
|
498
|
+
const sourceType = normalizeSourceType(req.query && req.query.sourceType);
|
|
499
|
+
const queryGlobalVar = String((req.query && req.query.globalVar) || "").trim();
|
|
500
|
+
const queryGlobalPath = String((req.query && req.query.globalPath) || "").trim();
|
|
501
|
+
|
|
502
|
+
const runtimeNode = nodeId ? RED.nodes.getNode(nodeId) : null;
|
|
503
|
+
|
|
504
|
+
if (sourceType === "mqtt") {
|
|
505
|
+
if (!runtimeNode || typeof runtimeNode.__getMqttSnapshot !== "function") {
|
|
506
|
+
res.json({
|
|
507
|
+
ok: false,
|
|
508
|
+
error: "Nu exista inca date MQTT in cache. Pune nodul in flow, deploy si asteapta mesaje.",
|
|
509
|
+
fields: []
|
|
510
|
+
});
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const mqttSnapshot = runtimeNode.__getMqttSnapshot();
|
|
515
|
+
const queryMqttRootPath = String((req.query && req.query.mqttRootPath) || "").trim();
|
|
516
|
+
const scopedMqtt = getScopedSource(mqttSnapshot, queryMqttRootPath);
|
|
517
|
+
const fields = extractFieldInventory(scopedMqtt);
|
|
518
|
+
const roots = extractRootInventory(mqttSnapshot);
|
|
519
|
+
|
|
520
|
+
res.json({
|
|
521
|
+
ok: true,
|
|
522
|
+
sourceType: "mqtt",
|
|
523
|
+
mqttRootPath: queryMqttRootPath,
|
|
524
|
+
portalId: String(runtimeNode.mqttPortalId || ""),
|
|
525
|
+
roots: roots,
|
|
526
|
+
count: fields.length,
|
|
527
|
+
fields: fields
|
|
528
|
+
});
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (!runtimeNode) {
|
|
533
|
+
res.json({
|
|
534
|
+
ok: false,
|
|
535
|
+
error: "Nodul editat nu este disponibil in runtime. Salveaza si fa Deploy pentru a citi contextul global.",
|
|
536
|
+
fields: []
|
|
537
|
+
});
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const sourceGlobalVar = queryGlobalVar || "victron";
|
|
542
|
+
const globalCtx = runtimeNode.context().global;
|
|
543
|
+
const rootValue = globalCtx.get(sourceGlobalVar);
|
|
544
|
+
const scopedValue = queryGlobalPath ? getByPath(rootValue, queryGlobalPath) : rootValue;
|
|
545
|
+
const fields = extractFieldInventory(scopedValue);
|
|
546
|
+
|
|
547
|
+
res.json({
|
|
548
|
+
ok: true,
|
|
549
|
+
sourceType: "global",
|
|
550
|
+
globalVar: sourceGlobalVar,
|
|
551
|
+
globalPath: queryGlobalPath,
|
|
552
|
+
count: fields.length,
|
|
553
|
+
fields: fields
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
function sanitizePasswordInput(value) {
|
|
558
|
+
if (value === undefined || value === null) {
|
|
559
|
+
return "";
|
|
560
|
+
}
|
|
561
|
+
const text = String(value);
|
|
562
|
+
const trimmed = text.trim();
|
|
563
|
+
if (!trimmed) return "";
|
|
564
|
+
if (trimmed === "__PWRD__") return "";
|
|
565
|
+
if (/^[*\u2022]+$/.test(trimmed)) return "";
|
|
566
|
+
if (/^[•*]+$/.test(trimmed)) return "";
|
|
567
|
+
return text;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function handleDiscoverMqttRequest(source, res) {
|
|
571
|
+
const input = (source && typeof source === "object") ? source : {};
|
|
572
|
+
const timeoutMs = clampNumber(input.timeoutMs, 2000, 20000, 5000);
|
|
573
|
+
const mqttRootPath = String(input.mqttRootPath || "").trim();
|
|
574
|
+
const nodeId = String(input.nodeId || "").trim();
|
|
575
|
+
const runtimeNode = nodeId ? RED.nodes.getNode(nodeId) : null;
|
|
576
|
+
|
|
577
|
+
const requestOptions = {
|
|
578
|
+
mqttUrl: String(input.mqttUrl || "").trim() || (runtimeNode ? String(runtimeNode.mqttUrl || "").trim() : ""),
|
|
579
|
+
mqttTopicFilter: String(input.mqttTopicFilter || "").trim() || (runtimeNode ? String(runtimeNode.mqttTopicFilter || "").trim() : ""),
|
|
580
|
+
mqttQos: Number.isFinite(Number(input.mqttQos)) ? Number(input.mqttQos) : (runtimeNode ? Number(runtimeNode.mqttQos || 0) : 0),
|
|
581
|
+
mqttClientId: String(input.mqttClientId || "").trim() || (runtimeNode ? String(runtimeNode.mqttClientId || "").trim() : ""),
|
|
582
|
+
mqttUsername: String(input.mqttUsername || "").trim() || (runtimeNode ? String(runtimeNode.mqttUsername || "").trim() : ""),
|
|
583
|
+
mqttPassword: sanitizePasswordInput(input.mqttPassword) || (runtimeNode ? String(runtimeNode.mqttPassword || "") : ""),
|
|
584
|
+
mqttPortalId: String(input.mqttPortalId || "").trim() || (runtimeNode ? String(runtimeNode.mqttPortalId || "").trim() : ""),
|
|
585
|
+
mqttTopicPathMode: (input.mqttTopicPathMode === "raw" || input.mqttTopicPathMode === "victron")
|
|
586
|
+
? input.mqttTopicPathMode
|
|
587
|
+
: (runtimeNode ? String(runtimeNode.mqttTopicPathMode || "victron") : "victron"),
|
|
588
|
+
mqttStripPortalId: (input.mqttStripPortalId !== undefined && input.mqttStripPortalId !== null)
|
|
589
|
+
? !(String(input.mqttStripPortalId).toLowerCase() === "false" || String(input.mqttStripPortalId) === "0")
|
|
590
|
+
: (runtimeNode ? !!runtimeNode.mqttStripPortalId : true)
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
discoverMqttSnapshot(requestOptions, timeoutMs)
|
|
594
|
+
.then(function (result) {
|
|
595
|
+
const snapshot = result.snapshot || {};
|
|
596
|
+
const roots = extractRootInventory(snapshot);
|
|
597
|
+
const scoped = getScopedSource(snapshot, mqttRootPath);
|
|
598
|
+
const fields = extractFieldInventory(scoped);
|
|
599
|
+
|
|
600
|
+
res.json({
|
|
601
|
+
ok: true,
|
|
602
|
+
messages: Number(result.messages || 0),
|
|
603
|
+
portalId: String(result.portalId || ""),
|
|
604
|
+
roots: roots,
|
|
605
|
+
mqttRootPath: mqttRootPath,
|
|
606
|
+
count: fields.length,
|
|
607
|
+
fields: fields
|
|
608
|
+
});
|
|
609
|
+
})
|
|
610
|
+
.catch(function (error) {
|
|
611
|
+
res.json({
|
|
612
|
+
ok: false,
|
|
613
|
+
error: error && error.message ? error.message : String(error || "Eroare necunoscuta"),
|
|
614
|
+
roots: [],
|
|
615
|
+
fields: []
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
RED.httpAdmin.post("/victron-read/discover-mqtt", function (req, res) {
|
|
621
|
+
const body = (req && req.body && typeof req.body === "object") ? req.body : {};
|
|
622
|
+
handleDiscoverMqttRequest(body, res);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
function VictronReadNode(config) {
|
|
626
|
+
RED.nodes.createNode(this, config);
|
|
627
|
+
const node = this;
|
|
628
|
+
|
|
629
|
+
runLicensedNode(node, RED, config, function startLicensedRuntime() {
|
|
630
|
+
|
|
631
|
+
node.name = String(config.name || "").trim();
|
|
632
|
+
node.sourceType = normalizeSourceType(config.sourceType);
|
|
633
|
+
|
|
634
|
+
node.globalVariable = String(config.globalVariable || "victron").trim() || "victron";
|
|
635
|
+
node.globalPath = String(config.globalPath || "").trim();
|
|
636
|
+
|
|
637
|
+
node.pollInterval = (function () {
|
|
638
|
+
const text = String(config.pollInterval || "").trim().toLowerCase();
|
|
639
|
+
if (text === "off" || text === "0") return 0;
|
|
640
|
+
const n = Math.round(Number(config.pollInterval));
|
|
641
|
+
return Number.isFinite(n) && n > 0 ? n : 5000;
|
|
642
|
+
})();
|
|
643
|
+
node.payloadMode = normalizePayloadMode(config.payloadMode);
|
|
644
|
+
node.payloadFields = normalizePayloadFields(config.payloadFields);
|
|
645
|
+
|
|
646
|
+
node.deviceId = String(config.deviceId || "victron").trim() || "victron";
|
|
647
|
+
node.topic = String(config.topic || "").trim();
|
|
648
|
+
node.influxMeasurement = String(config.influxMeasurement || "").trim();
|
|
649
|
+
|
|
650
|
+
node.mqttUrl = String(config.mqttUrl || "mqtt://127.0.0.1:1883").trim() || "mqtt://127.0.0.1:1883";
|
|
651
|
+
node.mqttTopicFilter = String(config.mqttTopicFilter || "N/#").trim() || "N/#";
|
|
652
|
+
node.mqttQos = clampNumber(config.mqttQos, 0, 2, 0);
|
|
653
|
+
node.mqttClientId = String(config.mqttClientId || "").trim();
|
|
654
|
+
node.mqttUsername = String(config.mqttUsername || "root").trim();
|
|
655
|
+
node.mqttPassword = node.credentials && node.credentials.mqttPassword ? String(node.credentials.mqttPassword) : "";
|
|
656
|
+
node.mqttTopicPathMode = config.mqttTopicPathMode === "raw" ? "raw" : "victron";
|
|
657
|
+
node.mqttStripPortalId = config.mqttStripPortalId !== false;
|
|
658
|
+
node.mqttRootPath = String(config.mqttRootPath || "").trim();
|
|
659
|
+
node.mqttPortalId = String(config.mqttPortalId || "").trim();
|
|
660
|
+
node.mqttKeepaliveEnabled = config.mqttKeepaliveEnabled !== false;
|
|
661
|
+
node.mqttKeepaliveInterval = atLeast(config.mqttKeepaliveInterval, 10000, 30000);
|
|
662
|
+
node.mqttStaleAfter = roundedAtLeast(config.mqttStaleAfter, 0, 120000);
|
|
663
|
+
node.emitOnlyChanges = config.emitOnlyChanges === true;
|
|
664
|
+
// Expert mode is UI-session only (for selecting hidden fields),
|
|
665
|
+
// never a persisted runtime mode after Done/Deploy.
|
|
666
|
+
node.expertMode = false;
|
|
667
|
+
node.includeFieldTokens = normalizeCsvTokens(config.includeFieldTokens);
|
|
668
|
+
node.excludeFieldTokens = normalizeCsvTokens(config.excludeFieldTokens);
|
|
669
|
+
node.onlyNumericFields = config.onlyNumericFields !== false;
|
|
670
|
+
node.maxFields = roundedAtLeast(config.maxFields, 0, 150);
|
|
671
|
+
|
|
672
|
+
let timer = null;
|
|
673
|
+
let mqttKeepaliveTimer = null;
|
|
674
|
+
let mqttEmitTimer = null;
|
|
675
|
+
let busy = false;
|
|
676
|
+
let mqttClient = null;
|
|
677
|
+
let mqttConnected = false;
|
|
678
|
+
let mqttMessageCount = 0;
|
|
679
|
+
let mqttLastUpdateMs = null;
|
|
680
|
+
let mqttLastFullPublishMs = null;
|
|
681
|
+
let mqttRevision = 0;
|
|
682
|
+
let lastEmittedRevision = -1;
|
|
683
|
+
let lastGlobalSignature = "";
|
|
684
|
+
let missingMqttLibWarned = false;
|
|
685
|
+
|
|
686
|
+
const mqttSnapshot = {};
|
|
687
|
+
const mqttFieldUpdatedAt = new Map();
|
|
688
|
+
|
|
689
|
+
node.__getMqttSnapshot = function () {
|
|
690
|
+
return mqttSnapshot;
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
function getMeasurement() {
|
|
694
|
+
return node.influxMeasurement || node.deviceId;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function readScopedGlobalValue() {
|
|
698
|
+
const sourceGlobalVar = node.globalVariable;
|
|
699
|
+
const sourcePath = node.globalPath;
|
|
700
|
+
const globalCtx = node.context().global;
|
|
701
|
+
|
|
702
|
+
const rootValue = globalCtx.get(sourceGlobalVar);
|
|
703
|
+
const scopedValue = sourcePath ? getByPath(rootValue, sourcePath) : rootValue;
|
|
704
|
+
|
|
705
|
+
return {
|
|
706
|
+
sourceType: "global",
|
|
707
|
+
sourceGlobalVar: sourceGlobalVar,
|
|
708
|
+
sourcePath: sourcePath,
|
|
709
|
+
scopedValue: scopedValue
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function readMqttValue() {
|
|
714
|
+
const removed = mqttState.pruneStale(mqttSnapshot, mqttFieldUpdatedAt, node.mqttStaleAfter);
|
|
715
|
+
if (removed.length) {
|
|
716
|
+
mqttRevision += 1;
|
|
717
|
+
}
|
|
718
|
+
const sourcePath = node.mqttRootPath;
|
|
719
|
+
const scopedValue = sourcePath ? getScopedSource(mqttSnapshot, sourcePath) : mqttSnapshot;
|
|
720
|
+
|
|
721
|
+
return {
|
|
722
|
+
sourceType: "mqtt",
|
|
723
|
+
sourcePath: sourcePath,
|
|
724
|
+
sourceGlobalVar: "",
|
|
725
|
+
scopedValue: scopedValue,
|
|
726
|
+
staleFieldsRemoved: removed.length
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function buildInfluxFields(snapshot, selectedFields, mode) {
|
|
731
|
+
const scopedValue = snapshot.scopedValue;
|
|
732
|
+
const fields = {};
|
|
733
|
+
|
|
734
|
+
if (mode === "full") {
|
|
735
|
+
flattenSupportedFields(scopedValue, "", fields, null, new WeakSet(), 0);
|
|
736
|
+
return {
|
|
737
|
+
fields: fields,
|
|
738
|
+
effectivePaths: ["full"]
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const normalized = normalizePayloadFields(selectedFields).filter(function (field) {
|
|
743
|
+
return field !== "full";
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
normalized.forEach(function (fieldPath) {
|
|
747
|
+
const value = getByPath(scopedValue, fieldPath);
|
|
748
|
+
|
|
749
|
+
if (isSupportedInfluxValue(value)) {
|
|
750
|
+
fields[fieldPathToCompactKey(fieldPath)] = value;
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if (Array.isArray(value) || isPlainObject(value)) {
|
|
755
|
+
flattenSupportedFields(value, fieldPathToCompactKey(fieldPath), fields, null, new WeakSet(), 0);
|
|
756
|
+
}
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
return {
|
|
760
|
+
fields: fields,
|
|
761
|
+
effectivePaths: normalized
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function applyFieldTokensAndTypeFilters(fields) {
|
|
766
|
+
if (normalizePayloadMode(node.payloadMode) !== "default") {
|
|
767
|
+
return Object.assign({}, fields || {});
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const filtered = {};
|
|
771
|
+
const keys = Object.keys(fields || {});
|
|
772
|
+
|
|
773
|
+
keys.forEach(function (key) {
|
|
774
|
+
const value = fields[key];
|
|
775
|
+
const isNumeric = typeof value === "number" && Number.isFinite(value);
|
|
776
|
+
if (node.onlyNumericFields && !isNumeric) {
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
if (node.includeFieldTokens.length && !pathMatchesAnyToken(key, node.includeFieldTokens)) {
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
if (node.excludeFieldTokens.length && pathMatchesAnyToken(key, node.excludeFieldTokens)) {
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
filtered[key] = value;
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
return filtered;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function applyBasicOperationalFilter(fields, sourcePath) {
|
|
795
|
+
if (normalizePayloadMode(node.payloadMode) !== "default") {
|
|
796
|
+
return fields;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const filtered = {};
|
|
800
|
+
Object.keys(fields || {}).forEach(function (key) {
|
|
801
|
+
const categorizedPath = sourcePath ? (sourcePath + "." + key) : key;
|
|
802
|
+
if (!isBasicOperationalFieldPath(categorizedPath)) {
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
filtered[key] = fields[key];
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
return filtered;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function applyMaxFieldsLimit(fields) {
|
|
813
|
+
if (normalizePayloadMode(node.payloadMode) !== "default") {
|
|
814
|
+
return fields;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
if (!node.maxFields || node.maxFields <= 0) {
|
|
818
|
+
return fields;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const keys = Object.keys(fields || {});
|
|
822
|
+
if (keys.length <= node.maxFields) {
|
|
823
|
+
return fields;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const limited = {};
|
|
827
|
+
keys.sort(function (a, b) { return a.localeCompare(b); })
|
|
828
|
+
.slice(0, node.maxFields)
|
|
829
|
+
.forEach(function (key) {
|
|
830
|
+
limited[key] = fields[key];
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
return limited;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function buildMessage(snapshot) {
|
|
837
|
+
const mode = normalizePayloadMode(node.payloadMode);
|
|
838
|
+
const selected = normalizePayloadFields(node.payloadFields);
|
|
839
|
+
const useFull = mode === "full" || selected.includes("full");
|
|
840
|
+
|
|
841
|
+
const built = buildInfluxFields(snapshot, selected, useFull ? "full" : "selected");
|
|
842
|
+
const nowIso = new Date().toISOString();
|
|
843
|
+
const measurement = getMeasurement();
|
|
844
|
+
const topic = node.topic || node.deviceId || node.name || "victron-read";
|
|
845
|
+
|
|
846
|
+
return {
|
|
847
|
+
topic: topic,
|
|
848
|
+
payload: built.fields,
|
|
849
|
+
payload_field: built.effectivePaths[0] || (mode === "selected" ? "none" : "full"),
|
|
850
|
+
payload_fields: built.effectivePaths,
|
|
851
|
+
source_type: snapshot.sourceType,
|
|
852
|
+
source_global_var: snapshot.sourceGlobalVar || "",
|
|
853
|
+
source_path: snapshot.sourcePath || "",
|
|
854
|
+
device_id: node.deviceId,
|
|
855
|
+
mqtt_portal_id: snapshot.sourceType === "mqtt" ? node.mqttPortalId : "",
|
|
856
|
+
mqtt_last_update: mqttLastUpdateMs ? new Date(mqttLastUpdateMs).toISOString() : "",
|
|
857
|
+
mqtt_last_full_publish: mqttLastFullPublishMs ? new Date(mqttLastFullPublishMs).toISOString() : "",
|
|
858
|
+
influx: {
|
|
859
|
+
measurement: measurement,
|
|
860
|
+
tags: {
|
|
861
|
+
device_type: "victron",
|
|
862
|
+
device_id: node.deviceId,
|
|
863
|
+
source_type: snapshot.sourceType,
|
|
864
|
+
source_global_var: snapshot.sourceGlobalVar || "",
|
|
865
|
+
source_path: snapshot.sourcePath || "",
|
|
866
|
+
portal_id: snapshot.sourceType === "mqtt" ? node.mqttPortalId : ""
|
|
867
|
+
},
|
|
868
|
+
fields: built.fields,
|
|
869
|
+
timestamp: nowIso
|
|
870
|
+
}
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function updateStatusFromMessage(msg) {
|
|
875
|
+
const fields = (msg && msg.influx && msg.influx.fields) || {};
|
|
876
|
+
const count = Object.keys(fields).length;
|
|
877
|
+
|
|
878
|
+
if (node.sourceType === "mqtt") {
|
|
879
|
+
if (!mqttConnected) {
|
|
880
|
+
node.status({
|
|
881
|
+
fill: "yellow",
|
|
882
|
+
shape: "ring",
|
|
883
|
+
text: "mqtt disconnected"
|
|
884
|
+
});
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
if (!count) {
|
|
889
|
+
node.status({
|
|
890
|
+
fill: "yellow",
|
|
891
|
+
shape: "ring",
|
|
892
|
+
text: "mqtt on, fara campuri"
|
|
893
|
+
});
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
node.status({
|
|
898
|
+
fill: "green",
|
|
899
|
+
shape: "dot",
|
|
900
|
+
text: String(count) + " campuri | mqtt " + String(mqttMessageCount)
|
|
901
|
+
});
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
if (!count) {
|
|
906
|
+
node.status({
|
|
907
|
+
fill: "yellow",
|
|
908
|
+
shape: "ring",
|
|
909
|
+
text: "fara campuri valide"
|
|
910
|
+
});
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
node.status({
|
|
915
|
+
fill: "green",
|
|
916
|
+
shape: "dot",
|
|
917
|
+
text: String(count) + " campuri -> influx"
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function emitSnapshot(reason) {
|
|
922
|
+
if (busy) {
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
busy = true;
|
|
927
|
+
|
|
928
|
+
try {
|
|
929
|
+
const snapshot = node.sourceType === "mqtt" ? readMqttValue() : readScopedGlobalValue();
|
|
930
|
+
const scopedValue = snapshot.scopedValue;
|
|
931
|
+
|
|
932
|
+
if (scopedValue === undefined || scopedValue === null) {
|
|
933
|
+
if (node.sourceType === "mqtt") {
|
|
934
|
+
node.status({
|
|
935
|
+
fill: "yellow",
|
|
936
|
+
shape: "ring",
|
|
937
|
+
text: mqttConnected ? "mqtt fara date" : "mqtt disconnected"
|
|
938
|
+
});
|
|
939
|
+
} else {
|
|
940
|
+
node.status({
|
|
941
|
+
fill: "yellow",
|
|
942
|
+
shape: "ring",
|
|
943
|
+
text: "lipseste variabila globala"
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
if (!isPlainObject(scopedValue) && !Array.isArray(scopedValue)) {
|
|
950
|
+
node.status({
|
|
951
|
+
fill: "yellow",
|
|
952
|
+
shape: "ring",
|
|
953
|
+
text: "sursa invalida"
|
|
954
|
+
});
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const msg = buildMessage(snapshot);
|
|
959
|
+
let filteredFields = applyFieldTokensAndTypeFilters(msg.influx.fields || {});
|
|
960
|
+
filteredFields = applyBasicOperationalFilter(filteredFields, snapshot.sourcePath);
|
|
961
|
+
filteredFields = applyMaxFieldsLimit(filteredFields);
|
|
962
|
+
|
|
963
|
+
if (!Object.keys(filteredFields).length) {
|
|
964
|
+
node.status({
|
|
965
|
+
fill: node.sourceType === "mqtt" ? "yellow" : "blue",
|
|
966
|
+
shape: "ring",
|
|
967
|
+
text: node.sourceType === "mqtt" ? "mqtt: 0 campuri proaspete" : "filtrat: 0 campuri"
|
|
968
|
+
});
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
msg.payload = filteredFields;
|
|
973
|
+
msg.influx.fields = filteredFields;
|
|
974
|
+
|
|
975
|
+
if (node.emitOnlyChanges) {
|
|
976
|
+
if (snapshot.sourceType === "mqtt") {
|
|
977
|
+
if (lastEmittedRevision === mqttRevision) {
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
lastEmittedRevision = mqttRevision;
|
|
981
|
+
} else {
|
|
982
|
+
const signature = JSON.stringify(filteredFields);
|
|
983
|
+
if (signature === lastGlobalSignature) {
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
lastGlobalSignature = signature;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
updateStatusFromMessage(msg);
|
|
991
|
+
node.send(msg);
|
|
992
|
+
} catch (error) {
|
|
993
|
+
node.status({
|
|
994
|
+
fill: "red",
|
|
995
|
+
shape: "ring",
|
|
996
|
+
text: "error"
|
|
997
|
+
});
|
|
998
|
+
node.error(error && error.message ? error.message : error);
|
|
999
|
+
} finally {
|
|
1000
|
+
busy = false;
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function applyMqttPayload(topic, decodedValue) {
|
|
1005
|
+
const result = mqttState.mergeDecodedMessage(mqttSnapshot, mqttFieldUpdatedAt, topic, decodedValue, {
|
|
1006
|
+
topicPathMode: node.mqttTopicPathMode,
|
|
1007
|
+
stripPortalId: node.mqttStripPortalId
|
|
1008
|
+
});
|
|
1009
|
+
if (!node.mqttPortalId && result.portalId) {
|
|
1010
|
+
node.mqttPortalId = result.portalId;
|
|
1011
|
+
startMqttKeepalive();
|
|
1012
|
+
}
|
|
1013
|
+
if (result.changed) {
|
|
1014
|
+
mqttRevision += 1;
|
|
1015
|
+
}
|
|
1016
|
+
if (String(topic || "").endsWith("/full_publish_completed")) {
|
|
1017
|
+
mqttLastFullPublishMs = Date.now();
|
|
1018
|
+
}
|
|
1019
|
+
mqttMessageCount += 1;
|
|
1020
|
+
mqttLastUpdateMs = Date.now();
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function publishMqttKeepalive(suppressRepublish) {
|
|
1024
|
+
if (!mqttConnected || !mqttClient || !node.mqttKeepaliveEnabled || !node.mqttPortalId) {
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
const payload = suppressRepublish
|
|
1029
|
+
? JSON.stringify({ "keepalive-options": ["suppress-republish"] })
|
|
1030
|
+
: "";
|
|
1031
|
+
mqttClient.publish("R/" + node.mqttPortalId + "/keepalive", payload, { qos: 0 }, function (error) {
|
|
1032
|
+
if (error) {
|
|
1033
|
+
node.warn("Eroare keepalive MQTT Victron: " + (error.message || error));
|
|
1034
|
+
}
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function startMqttKeepalive() {
|
|
1039
|
+
if (mqttKeepaliveTimer) {
|
|
1040
|
+
clearInterval(mqttKeepaliveTimer);
|
|
1041
|
+
mqttKeepaliveTimer = null;
|
|
1042
|
+
}
|
|
1043
|
+
if (!node.mqttKeepaliveEnabled || !node.mqttPortalId) {
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
publishMqttKeepalive(false);
|
|
1047
|
+
mqttKeepaliveTimer = setInterval(function () {
|
|
1048
|
+
publishMqttKeepalive(true);
|
|
1049
|
+
}, node.mqttKeepaliveInterval);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function connectMqtt() {
|
|
1053
|
+
if (node.sourceType !== "mqtt") {
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
if (!mqttLib) {
|
|
1058
|
+
if (!missingMqttLibWarned) {
|
|
1059
|
+
missingMqttLibWarned = true;
|
|
1060
|
+
node.error("Lipseste pachetul 'mqtt'. Ruleaza npm install in proiect pentru a activa sursa MQTT.");
|
|
1061
|
+
}
|
|
1062
|
+
node.status({
|
|
1063
|
+
fill: "red",
|
|
1064
|
+
shape: "ring",
|
|
1065
|
+
text: "mqtt lib missing"
|
|
1066
|
+
});
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
const options = {
|
|
1071
|
+
keepalive: 60,
|
|
1072
|
+
reconnectPeriod: 5000,
|
|
1073
|
+
connectTimeout: 30000,
|
|
1074
|
+
clean: true
|
|
1075
|
+
};
|
|
1076
|
+
|
|
1077
|
+
if (node.mqttClientId) {
|
|
1078
|
+
options.clientId = node.mqttClientId;
|
|
1079
|
+
}
|
|
1080
|
+
if (node.mqttUsername) {
|
|
1081
|
+
options.username = node.mqttUsername;
|
|
1082
|
+
}
|
|
1083
|
+
if (node.mqttPassword) {
|
|
1084
|
+
options.password = node.mqttPassword;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
mqttClient = mqttLib.connect(node.mqttUrl, options);
|
|
1088
|
+
|
|
1089
|
+
mqttClient.on("connect", function () {
|
|
1090
|
+
mqttConnected = true;
|
|
1091
|
+
node.status({
|
|
1092
|
+
fill: "green",
|
|
1093
|
+
shape: "dot",
|
|
1094
|
+
text: "mqtt connected"
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
mqttClient.subscribe(node.mqttTopicFilter, { qos: node.mqttQos }, function (err) {
|
|
1098
|
+
if (err) {
|
|
1099
|
+
node.error("Eroare subscribe MQTT: " + (err.message || err));
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
node.status({
|
|
1104
|
+
fill: node.mqttKeepaliveEnabled && !node.mqttPortalId ? "yellow" : "green",
|
|
1105
|
+
shape: node.mqttKeepaliveEnabled && !node.mqttPortalId ? "ring" : "dot",
|
|
1106
|
+
text: node.mqttKeepaliveEnabled && !node.mqttPortalId
|
|
1107
|
+
? "mqtt: lipseste Portal ID"
|
|
1108
|
+
: "mqtt subscribed"
|
|
1109
|
+
});
|
|
1110
|
+
startMqttKeepalive();
|
|
1111
|
+
});
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
mqttClient.on("reconnect", function () {
|
|
1115
|
+
mqttConnected = false;
|
|
1116
|
+
node.status({
|
|
1117
|
+
fill: "yellow",
|
|
1118
|
+
shape: "ring",
|
|
1119
|
+
text: "mqtt reconnect"
|
|
1120
|
+
});
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
mqttClient.on("close", function () {
|
|
1124
|
+
mqttConnected = false;
|
|
1125
|
+
if (mqttKeepaliveTimer) {
|
|
1126
|
+
clearInterval(mqttKeepaliveTimer);
|
|
1127
|
+
mqttKeepaliveTimer = null;
|
|
1128
|
+
}
|
|
1129
|
+
node.status({
|
|
1130
|
+
fill: "yellow",
|
|
1131
|
+
shape: "ring",
|
|
1132
|
+
text: "mqtt closed"
|
|
1133
|
+
});
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
mqttClient.on("error", function (error) {
|
|
1137
|
+
mqttConnected = false;
|
|
1138
|
+
node.status({
|
|
1139
|
+
fill: "red",
|
|
1140
|
+
shape: "ring",
|
|
1141
|
+
text: "mqtt error"
|
|
1142
|
+
});
|
|
1143
|
+
node.error(error && error.message ? error.message : error);
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
mqttClient.on("message", function (topic, payloadBuffer) {
|
|
1147
|
+
const value = decodeMqttPayload(payloadBuffer);
|
|
1148
|
+
if (value === undefined) {
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
applyMqttPayload(topic, value);
|
|
1153
|
+
if (node.emitOnlyChanges) {
|
|
1154
|
+
if (mqttEmitTimer) {
|
|
1155
|
+
clearTimeout(mqttEmitTimer);
|
|
1156
|
+
}
|
|
1157
|
+
mqttEmitTimer = setTimeout(function () {
|
|
1158
|
+
mqttEmitTimer = null;
|
|
1159
|
+
emitSnapshot("mqtt-change");
|
|
1160
|
+
}, 250);
|
|
1161
|
+
}
|
|
1162
|
+
const ageMs = mqttLastUpdateMs ? (Date.now() - mqttLastUpdateMs) : 0;
|
|
1163
|
+
node.status({
|
|
1164
|
+
fill: mqttConnected ? "green" : "yellow",
|
|
1165
|
+
shape: mqttConnected ? "dot" : "ring",
|
|
1166
|
+
text: "mqtt " + String(mqttMessageCount) + " | " + String(ageMs) + "ms"
|
|
1167
|
+
});
|
|
1168
|
+
});
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
if (node.pollInterval > 0) {
|
|
1172
|
+
timer = setInterval(function () {
|
|
1173
|
+
emitSnapshot("interval");
|
|
1174
|
+
}, node.pollInterval);
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
connectMqtt();
|
|
1178
|
+
if (node.pollInterval > 0) {
|
|
1179
|
+
emitSnapshot("startup");
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
node.on("input", function () {
|
|
1183
|
+
emitSnapshot("input");
|
|
1184
|
+
});
|
|
1185
|
+
|
|
1186
|
+
node.on("close", function (done) {
|
|
1187
|
+
if (timer) {
|
|
1188
|
+
clearInterval(timer);
|
|
1189
|
+
timer = null;
|
|
1190
|
+
}
|
|
1191
|
+
if (mqttKeepaliveTimer) {
|
|
1192
|
+
clearInterval(mqttKeepaliveTimer);
|
|
1193
|
+
mqttKeepaliveTimer = null;
|
|
1194
|
+
}
|
|
1195
|
+
if (mqttEmitTimer) {
|
|
1196
|
+
clearTimeout(mqttEmitTimer);
|
|
1197
|
+
mqttEmitTimer = null;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
if (mqttClient) {
|
|
1201
|
+
try {
|
|
1202
|
+
mqttClient.end(true, done);
|
|
1203
|
+
mqttClient = null;
|
|
1204
|
+
return;
|
|
1205
|
+
} catch (error) {
|
|
1206
|
+
// ignore close errors
|
|
1207
|
+
}
|
|
1208
|
+
mqttClient = null;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
done();
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
RED.nodes.registerType("victron-read", VictronReadNode, {
|
|
1218
|
+
credentials: {
|
|
1219
|
+
mqttPassword: { type: "password" }
|
|
1220
|
+
}
|
|
1221
|
+
});
|
|
1222
|
+
};
|