@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,270 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { runLicensedNode } = require("../lib/licensing/node-guard");
|
|
4
|
+
|
|
5
|
+
module.exports = function (RED) {
|
|
6
|
+
const ModbusRTU = require("modbus-serial");
|
|
7
|
+
const { atLeast, clampNumber } = require("../lib/numbers");
|
|
8
|
+
const RUNTIME_VERSION = require("../package.json").version;
|
|
9
|
+
const MODEL_123 = 123;
|
|
10
|
+
const BASES = [39999, 40000, 40001, 40002, 40199, 40200, 40201, 40202];
|
|
11
|
+
const MAX_MODELS = 256;
|
|
12
|
+
const states = new Map();
|
|
13
|
+
|
|
14
|
+
function state(id, value) {
|
|
15
|
+
states.set(id, Object.assign({ runtime_version: RUNTIME_VERSION, updated_at: new Date().toISOString() }, value));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function readHolding(client, address, count) {
|
|
19
|
+
const data = [];
|
|
20
|
+
for (let offset = 0; offset < count;) {
|
|
21
|
+
const size = Math.min(120, count - offset);
|
|
22
|
+
const response = await client.readHoldingRegisters(address + offset, size);
|
|
23
|
+
if (!response || !Array.isArray(response.data) || response.data.length !== size) {
|
|
24
|
+
throw new Error("Raspuns Modbus incomplet la registrul " + (address + offset));
|
|
25
|
+
}
|
|
26
|
+
data.push.apply(data, response.data);
|
|
27
|
+
offset += size;
|
|
28
|
+
}
|
|
29
|
+
return data;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function findBase(client) {
|
|
33
|
+
for (const base of BASES) {
|
|
34
|
+
try {
|
|
35
|
+
const words = await readHolding(client, base, 2);
|
|
36
|
+
const signature = words.map(function (word) {
|
|
37
|
+
return String.fromCharCode((word >> 8) & 0xff) + String.fromCharCode(word & 0xff);
|
|
38
|
+
}).join("");
|
|
39
|
+
if (signature === "SunS") return base;
|
|
40
|
+
} catch (error) { /* try next standard base */ }
|
|
41
|
+
}
|
|
42
|
+
throw new Error("SunSpec not found");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function scanModels(client, base) {
|
|
46
|
+
const models = [];
|
|
47
|
+
let address = base + 2;
|
|
48
|
+
while (true) {
|
|
49
|
+
if (models.length >= MAX_MODELS || address > 0xfffd) throw new Error("Tabel SunSpec invalid sau neterminat");
|
|
50
|
+
const header = await readHolding(client, address, 2);
|
|
51
|
+
const id = header[0];
|
|
52
|
+
const length = header[1];
|
|
53
|
+
if (id === 0xffff) break;
|
|
54
|
+
if (!Number.isInteger(length) || length < 1 || address + 2 + length > 0x10000) {
|
|
55
|
+
throw new Error("Lungime SunSpec invalida pentru modelul " + id);
|
|
56
|
+
}
|
|
57
|
+
const model = { modelId: id, addr: address, baseAddr: address + 2, len: length, raw: null };
|
|
58
|
+
if (id === MODEL_123) model.raw = await readHolding(client, model.baseAddr, length);
|
|
59
|
+
models.push(model);
|
|
60
|
+
address += 2 + length;
|
|
61
|
+
}
|
|
62
|
+
return models;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function controlModel(models) {
|
|
66
|
+
const model = models.find(function (item) { return item.modelId === MODEL_123; });
|
|
67
|
+
if (!model) throw new Error("Modelul SunSpec 123 nu este publicat. Modele: " + models.map(function (m) { return m.modelId; }).join(", "));
|
|
68
|
+
if (model.len < 24 || !Array.isArray(model.raw) || model.raw.length < 24) throw new Error("Modelul SunSpec 123 este incomplet");
|
|
69
|
+
if (model.raw[21] === 0x8000) throw new Error("Limitarea puterii active nu este implementata in modelul SunSpec 123");
|
|
70
|
+
return model;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
RED.httpAdmin.get("/sma-write/state/:id", function (req, res) {
|
|
74
|
+
const value = states.get(req.params.id);
|
|
75
|
+
res.json(value ? { ok: true, state: value } : { ok: false, error: "No cached state" });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
RED.httpAdmin.get("/sma-write/probe", async function (req, res) {
|
|
79
|
+
const host = String(req.query.host || "").trim();
|
|
80
|
+
const port = atLeast(req.query.port, 1, 502);
|
|
81
|
+
const unitId = Math.min(247, atLeast(req.query.unitId, 1, 126));
|
|
82
|
+
const client = new ModbusRTU();
|
|
83
|
+
if (!host) return res.json({ ok: false, error: "IP lipsa" });
|
|
84
|
+
try {
|
|
85
|
+
await client.connectTCP(host, { port: port });
|
|
86
|
+
client.setID(unitId);
|
|
87
|
+
client.setTimeout(3000);
|
|
88
|
+
const base = await findBase(client);
|
|
89
|
+
const models = await scanModels(client, base);
|
|
90
|
+
const model = controlModel(models);
|
|
91
|
+
res.json({ ok: true, runtime_version: RUNTIME_VERSION, unit_id: unitId, sunspec_base: base, control_model_id: MODEL_123, control_model_length: model.len });
|
|
92
|
+
} catch (error) {
|
|
93
|
+
res.json({ ok: false, runtime_version: RUNTIME_VERSION, error: error.message || String(error) });
|
|
94
|
+
} finally {
|
|
95
|
+
try { if (client.isOpen) await client.close(); } catch (error) { /* ignore */ }
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
function SmaWriteNode(config) {
|
|
100
|
+
RED.nodes.createNode(this, config);
|
|
101
|
+
const node = this;
|
|
102
|
+
|
|
103
|
+
runLicensedNode(node, RED, config, function startLicensedRuntime() {
|
|
104
|
+
node.host = String(config.host || "").trim();
|
|
105
|
+
node.port = atLeast(config.port, 1, 502);
|
|
106
|
+
node.unitId = Math.min(247, atLeast(config.unitId, 1, 126));
|
|
107
|
+
node.maxPower = clampNumber(config.maxPower || config.nominalPower, 1, 125000, 125000);
|
|
108
|
+
node.nominalPower = clampNumber(config.nominalPower || node.maxPower, node.maxPower, 125000, node.maxPower);
|
|
109
|
+
node.multiInverter = !!config.multiInverter;
|
|
110
|
+
node.inverterId = String(config.inverterId || "").trim();
|
|
111
|
+
node.timeout = 3000;
|
|
112
|
+
|
|
113
|
+
let client = new ModbusRTU();
|
|
114
|
+
let base = null;
|
|
115
|
+
let model = null;
|
|
116
|
+
let pending = null;
|
|
117
|
+
let worker = false;
|
|
118
|
+
let closing = false;
|
|
119
|
+
let lastRaw = null;
|
|
120
|
+
|
|
121
|
+
async function closeClient() {
|
|
122
|
+
try { if (client.isOpen) await client.close(); } catch (error) { /* ignore */ }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function reset() {
|
|
126
|
+
await closeClient();
|
|
127
|
+
client = new ModbusRTU();
|
|
128
|
+
base = null;
|
|
129
|
+
model = null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function connect() {
|
|
133
|
+
if (!client.isOpen) await client.connectTCP(node.host, { port: node.port });
|
|
134
|
+
client.setID(node.unitId);
|
|
135
|
+
client.setTimeout(node.timeout);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function valueFromMessage(msg) {
|
|
139
|
+
let payload = msg ? msg.payload : undefined;
|
|
140
|
+
if (node.multiInverter && payload && typeof payload === "object") {
|
|
141
|
+
if (Array.isArray(payload.inverters)) {
|
|
142
|
+
payload = payload.inverters.find(function (item) {
|
|
143
|
+
return String(item.inverter_id || item.id || item.topic || "") === node.inverterId;
|
|
144
|
+
}) || payload;
|
|
145
|
+
} else if (node.inverterId && payload[node.inverterId] !== undefined) {
|
|
146
|
+
payload = payload[node.inverterId];
|
|
147
|
+
}
|
|
148
|
+
if (payload && payload.payload !== undefined) payload = payload.payload;
|
|
149
|
+
}
|
|
150
|
+
if (typeof payload === "number" || typeof payload === "string") return { value: Number(payload), mode: "watts" };
|
|
151
|
+
if (!payload || typeof payload !== "object") throw new Error("Payload SMA invalid");
|
|
152
|
+
if (payload.powerType === "reactive" || payload.power_type === "reactive" || payload.mode === "vars" || payload.vars !== undefined || payload.var !== undefined) {
|
|
153
|
+
throw new Error("Controlul reactiv nu este disponibil");
|
|
154
|
+
}
|
|
155
|
+
const percentValue = payload.percent !== undefined ? payload.percent : payload.limit_pct;
|
|
156
|
+
if (percentValue !== undefined || payload.mode === "percent") {
|
|
157
|
+
const value = percentValue !== undefined ? percentValue : payload.value;
|
|
158
|
+
return { value: Number(value), mode: "percent" };
|
|
159
|
+
}
|
|
160
|
+
const candidates = [payload.value, payload.limit_w, payload.watts, payload.power_w, payload.site_limit_w, payload.allocated_limit_w];
|
|
161
|
+
const value = candidates.find(function (item) { return item !== undefined && item !== null; });
|
|
162
|
+
return { value: Number(value), mode: "watts" };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function command(msg) {
|
|
166
|
+
const input = valueFromMessage(msg);
|
|
167
|
+
if (!Number.isFinite(input.value)) throw new Error("Valoare SMA invalida");
|
|
168
|
+
let percent;
|
|
169
|
+
if (input.mode === "percent") percent = input.value;
|
|
170
|
+
else {
|
|
171
|
+
if (input.value < 0 || input.value > node.maxPower) throw new Error("Puterea trebuie sa fie intre 0 si " + node.maxPower + " W");
|
|
172
|
+
percent = input.value / node.nominalPower * 100;
|
|
173
|
+
}
|
|
174
|
+
percent = Math.max(0, Math.min(100, percent));
|
|
175
|
+
const sf = model.raw[21] >= 0x8000 ? model.raw[21] - 0x10000 : model.raw[21];
|
|
176
|
+
const raw = Math.round(percent / Math.pow(10, sf));
|
|
177
|
+
if (raw < 0 || raw > 0xffff) throw new Error("Setpoint SunSpec in afara domeniului");
|
|
178
|
+
return { input: input, percent: percent, sf: sf, raw: raw };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function prepare() {
|
|
182
|
+
await connect();
|
|
183
|
+
if (base === null) base = await findBase(client);
|
|
184
|
+
if (!model) model = controlModel(await scanModels(client, base));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function run(msg) {
|
|
188
|
+
const started = Date.now();
|
|
189
|
+
let lastError;
|
|
190
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
191
|
+
try {
|
|
192
|
+
await prepare();
|
|
193
|
+
const item = command(msg);
|
|
194
|
+
const setpointAddr = model.baseAddr + 3;
|
|
195
|
+
const enableAddr = model.baseAddr + 7;
|
|
196
|
+
const duplicate = item.raw === lastRaw;
|
|
197
|
+
if (!duplicate) {
|
|
198
|
+
await client.writeRegisters(setpointAddr, [item.raw]);
|
|
199
|
+
await client.writeRegisters(enableAddr, [1]);
|
|
200
|
+
lastRaw = item.raw;
|
|
201
|
+
}
|
|
202
|
+
const finished = Date.now();
|
|
203
|
+
const output = {
|
|
204
|
+
status: "ok",
|
|
205
|
+
accepted_unverified: true,
|
|
206
|
+
write_skipped_duplicate: duplicate,
|
|
207
|
+
runtime_version: RUNTIME_VERSION,
|
|
208
|
+
power_type: "active",
|
|
209
|
+
mode: item.input.mode,
|
|
210
|
+
input_value: item.input.value,
|
|
211
|
+
max_power_w: node.maxPower,
|
|
212
|
+
nominal_power_w: node.nominalPower,
|
|
213
|
+
applied_percent: item.percent,
|
|
214
|
+
raw_written: item.raw,
|
|
215
|
+
scale_factor: item.sf,
|
|
216
|
+
setpoint_addr: setpointAddr,
|
|
217
|
+
enable_addr: enableAddr,
|
|
218
|
+
sunspec_base: base,
|
|
219
|
+
control_model_id: MODEL_123,
|
|
220
|
+
write_duration_ms: finished - started,
|
|
221
|
+
ts: new Date(finished).toISOString()
|
|
222
|
+
};
|
|
223
|
+
node.status({ fill: duplicate ? "blue" : "green", shape: "dot", text: duplicate ? "neschimbat " + item.percent.toFixed(1) + "%" : item.percent.toFixed(1) + "%" });
|
|
224
|
+
node.send({ topic: "sma/write", payload: output });
|
|
225
|
+
state(node.id, Object.assign({ status: "ok", host: node.host, port: node.port, unit_id: node.unitId }, output));
|
|
226
|
+
return;
|
|
227
|
+
} catch (error) {
|
|
228
|
+
lastError = error;
|
|
229
|
+
await reset();
|
|
230
|
+
if (attempt < 3) await new Promise(function (resolve) { setTimeout(resolve, attempt * 250); });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const message = lastError && lastError.message ? lastError.message : String(lastError);
|
|
234
|
+
node.status({ fill: "red", shape: "ring", text: "error" });
|
|
235
|
+
node.send({ topic: "sma/error", payload: { status: "error", runtime_version: RUNTIME_VERSION, error: message, ts: new Date().toISOString() } });
|
|
236
|
+
state(node.id, { status: "error", host: node.host, port: node.port, unit_id: node.unitId, error: message });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
node.on("input", function (msg, send, done) {
|
|
240
|
+
if (pending && pending.done) pending.done();
|
|
241
|
+
pending = { msg: msg, done: done };
|
|
242
|
+
if (worker) return;
|
|
243
|
+
worker = true;
|
|
244
|
+
(async function () {
|
|
245
|
+
while (pending && !closing) {
|
|
246
|
+
const current = pending;
|
|
247
|
+
pending = null;
|
|
248
|
+
await run(current.msg);
|
|
249
|
+
if (current.done) current.done();
|
|
250
|
+
}
|
|
251
|
+
worker = false;
|
|
252
|
+
})().catch(function (error) {
|
|
253
|
+
worker = false;
|
|
254
|
+
node.error(error);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
node.on("close", function (done) {
|
|
259
|
+
closing = true;
|
|
260
|
+
states.delete(node.id);
|
|
261
|
+
closeClient().then(done).catch(done);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
node.status({});
|
|
265
|
+
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
RED.nodes.registerType("sma-write", SmaWriteNode);
|
|
270
|
+
};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
function isPlainObject(value) {
|
|
4
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function sanitizePathSegment(segment) {
|
|
8
|
+
return String(segment || "").trim()
|
|
9
|
+
.replace(/[^a-zA-Z0-9_\-]+/g, "_")
|
|
10
|
+
.replace(/^_+|_+$/g, "");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function topicSegments(topic) {
|
|
14
|
+
return String(topic || "").split("/").map(sanitizePathSegment).filter(Boolean);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function detectPortalId(topic) {
|
|
18
|
+
const segments = topicSegments(topic);
|
|
19
|
+
return segments.length > 1 && ["N", "R", "W"].includes(segments[0]) ? segments[1] : "";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizeSegments(segments, topicPathMode, stripPortalId) {
|
|
23
|
+
if (topicPathMode === "victron") {
|
|
24
|
+
if (["N", "R", "W"].includes(segments[0])) segments.shift();
|
|
25
|
+
if (stripPortalId && segments.length > 1) segments.shift();
|
|
26
|
+
}
|
|
27
|
+
return segments.join(".");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function normalizeTopicPath(topic, topicPathMode, stripPortalId) {
|
|
31
|
+
return normalizeSegments(topicSegments(topic), topicPathMode, stripPortalId);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeFlatTelemetryKey(key, topicPathMode, stripPortalId) {
|
|
35
|
+
const segments = String(key || "").split(".").map(sanitizePathSegment).filter(Boolean);
|
|
36
|
+
return normalizeSegments(segments, topicPathMode, stripPortalId);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function looksLikeFlatTelemetryMap(value) {
|
|
40
|
+
if (!isPlainObject(value)) return false;
|
|
41
|
+
const keys = Object.keys(value);
|
|
42
|
+
if (!keys.length) return false;
|
|
43
|
+
const inspected = keys.slice(0, 500);
|
|
44
|
+
const dotted = inspected.filter(function (key) { return String(key).includes("."); }).length;
|
|
45
|
+
return dotted >= Math.max(3, Math.floor(inspected.length * 0.5));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getByPath(source, path) {
|
|
49
|
+
if (isPlainObject(source) && Object.prototype.hasOwnProperty.call(source, path)) return source[path];
|
|
50
|
+
const parts = String(path || "").split(".").filter(Boolean);
|
|
51
|
+
let current = source;
|
|
52
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
53
|
+
if (current === null || current === undefined) return undefined;
|
|
54
|
+
current = current[parts[index]];
|
|
55
|
+
}
|
|
56
|
+
return current;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function setByPath(target, path, value) {
|
|
60
|
+
const parts = String(path || "").split(".").filter(Boolean);
|
|
61
|
+
if (!isPlainObject(target) || !parts.length) return;
|
|
62
|
+
let current = target;
|
|
63
|
+
for (let index = 0; index < parts.length - 1; index += 1) {
|
|
64
|
+
const key = parts[index];
|
|
65
|
+
if (!isPlainObject(current[key])) current[key] = {};
|
|
66
|
+
current = current[key];
|
|
67
|
+
}
|
|
68
|
+
current[parts[parts.length - 1]] = value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function deleteByPath(target, path) {
|
|
72
|
+
if (!isPlainObject(target) || !path) return false;
|
|
73
|
+
if (Object.prototype.hasOwnProperty.call(target, path)) {
|
|
74
|
+
delete target[path];
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const parts = String(path).split(".").filter(Boolean);
|
|
79
|
+
const parents = [];
|
|
80
|
+
let current = target;
|
|
81
|
+
for (let index = 0; index < parts.length - 1; index += 1) {
|
|
82
|
+
if (!isPlainObject(current[parts[index]])) return false;
|
|
83
|
+
parents.push({ object: current, key: parts[index] });
|
|
84
|
+
current = current[parts[index]];
|
|
85
|
+
}
|
|
86
|
+
const leaf = parts[parts.length - 1];
|
|
87
|
+
if (!Object.prototype.hasOwnProperty.call(current, leaf)) return false;
|
|
88
|
+
delete current[leaf];
|
|
89
|
+
for (let index = parents.length - 1; index >= 0; index -= 1) {
|
|
90
|
+
const parent = parents[index];
|
|
91
|
+
if (Object.keys(parent.object[parent.key]).length) break;
|
|
92
|
+
delete parent.object[parent.key];
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function valuesEqual(left, right) {
|
|
98
|
+
if (Object.is(left, right)) return true;
|
|
99
|
+
if ((isPlainObject(left) || Array.isArray(left)) && (isPlainObject(right) || Array.isArray(right))) {
|
|
100
|
+
try {
|
|
101
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function mergeDecodedMessage(snapshot, updatedAt, topic, decodedValue, options) {
|
|
110
|
+
const opts = options || {};
|
|
111
|
+
const now = Number.isFinite(opts.now) ? opts.now : Date.now();
|
|
112
|
+
const mode = opts.topicPathMode === "raw" ? "raw" : "victron";
|
|
113
|
+
const stripPortalId = opts.stripPortalId !== false;
|
|
114
|
+
const changedPaths = [];
|
|
115
|
+
|
|
116
|
+
function assign(path, value, direct) {
|
|
117
|
+
if (!path) return;
|
|
118
|
+
const previous = direct ? snapshot[path] : getByPath(snapshot, path);
|
|
119
|
+
if (!valuesEqual(previous, value)) changedPaths.push(path);
|
|
120
|
+
if (direct) snapshot[path] = value;
|
|
121
|
+
else setByPath(snapshot, path, value);
|
|
122
|
+
updatedAt.set(path, now);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (looksLikeFlatTelemetryMap(decodedValue)) {
|
|
126
|
+
Object.keys(decodedValue).forEach(function (key) {
|
|
127
|
+
assign(normalizeFlatTelemetryKey(key, mode, stripPortalId), decodedValue[key], true);
|
|
128
|
+
});
|
|
129
|
+
} else {
|
|
130
|
+
assign(normalizeTopicPath(topic, mode, stripPortalId), decodedValue, false);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
changed: changedPaths.length > 0,
|
|
135
|
+
changedPaths: changedPaths,
|
|
136
|
+
portalId: detectPortalId(topic)
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function pruneStale(snapshot, updatedAt, staleAfterMs, now) {
|
|
141
|
+
const maxAge = Number(staleAfterMs);
|
|
142
|
+
if (!Number.isFinite(maxAge) || maxAge <= 0) return [];
|
|
143
|
+
const currentTime = Number.isFinite(now) ? now : Date.now();
|
|
144
|
+
const removed = [];
|
|
145
|
+
updatedAt.forEach(function (timestamp, path) {
|
|
146
|
+
if ((currentTime - timestamp) <= maxAge) return;
|
|
147
|
+
if (deleteByPath(snapshot, path)) removed.push(path);
|
|
148
|
+
updatedAt.delete(path);
|
|
149
|
+
});
|
|
150
|
+
return removed;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function decodePayload(payloadBuffer) {
|
|
154
|
+
const text = (Buffer.isBuffer(payloadBuffer) ? payloadBuffer.toString("utf8") : String(payloadBuffer || "")).trim();
|
|
155
|
+
if (!text) return undefined;
|
|
156
|
+
try {
|
|
157
|
+
const parsed = JSON.parse(text);
|
|
158
|
+
if (isPlainObject(parsed) && Object.prototype.hasOwnProperty.call(parsed, "value")) return parsed.value;
|
|
159
|
+
return parsed;
|
|
160
|
+
} catch (error) {
|
|
161
|
+
const lower = text.toLowerCase();
|
|
162
|
+
if (lower === "true") return true;
|
|
163
|
+
if (lower === "false") return false;
|
|
164
|
+
const numberValue = Number(text);
|
|
165
|
+
return Number.isFinite(numberValue) ? numberValue : text;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = {
|
|
170
|
+
decodePayload,
|
|
171
|
+
detectPortalId,
|
|
172
|
+
mergeDecodedMessage,
|
|
173
|
+
normalizeFlatTelemetryKey,
|
|
174
|
+
normalizeTopicPath,
|
|
175
|
+
pruneStale
|
|
176
|
+
};
|