@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,458 @@
|
|
|
1
|
+
module.exports = function (RED) {
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const { runLicensedNode } = require("../lib/licensing/node-guard");
|
|
5
|
+
|
|
6
|
+
const ModbusRTU = require("modbus-serial");
|
|
7
|
+
const RUNTIME_VERSION = require("../package.json").version;
|
|
8
|
+
const { atLeast } = require("../lib/numbers");
|
|
9
|
+
const {
|
|
10
|
+
findBase,
|
|
11
|
+
scanModelDescriptors,
|
|
12
|
+
readModelsByDescriptors
|
|
13
|
+
} = require("./lib/sunspec-core");
|
|
14
|
+
const { buildSummary } = require("./lib/sunspec-model-parsers");
|
|
15
|
+
|
|
16
|
+
const DEFAULT_FIELDS = [
|
|
17
|
+
"summary.ac.power_w",
|
|
18
|
+
"summary.dc.power_w",
|
|
19
|
+
"summary.ac.voltage_l1_v",
|
|
20
|
+
"summary.ac.voltage_l2_v",
|
|
21
|
+
"summary.ac.voltage_l3_v",
|
|
22
|
+
"summary.ac.frequency_hz"
|
|
23
|
+
];
|
|
24
|
+
const DEFAULT_MULTI_GRID_FIELDS = [
|
|
25
|
+
"summary.meter.power_w",
|
|
26
|
+
"summary.meter.power_l1_w",
|
|
27
|
+
"summary.meter.power_l2_w",
|
|
28
|
+
"summary.meter.power_l3_w"
|
|
29
|
+
];
|
|
30
|
+
const DEFAULT_METER_FIELDS = DEFAULT_MULTI_GRID_FIELDS.concat([
|
|
31
|
+
"summary.meter.current_total_a",
|
|
32
|
+
"summary.meter.current_l1_a",
|
|
33
|
+
"summary.meter.current_l2_a",
|
|
34
|
+
"summary.meter.current_l3_a",
|
|
35
|
+
"summary.meter.voltage_ln_avg_v",
|
|
36
|
+
"summary.meter.voltage_l1_v",
|
|
37
|
+
"summary.meter.voltage_l2_v",
|
|
38
|
+
"summary.meter.voltage_l3_v",
|
|
39
|
+
"summary.meter.frequency_hz",
|
|
40
|
+
"summary.meter.reactive_power_var",
|
|
41
|
+
"summary.meter.apparent_power_va",
|
|
42
|
+
"summary.meter.power_factor"
|
|
43
|
+
]);
|
|
44
|
+
const SUPPORTED_MODEL_IDS = new Set([
|
|
45
|
+
1,
|
|
46
|
+
101, 102, 103, 111, 112, 113,
|
|
47
|
+
120, 121, 122, 123, 160,
|
|
48
|
+
201, 202, 203, 204, 211, 212, 213, 214
|
|
49
|
+
]);
|
|
50
|
+
const nodeRuntimeState = new Map();
|
|
51
|
+
|
|
52
|
+
function supportedDescriptors(descriptors) {
|
|
53
|
+
return descriptors.filter(function (descriptor) {
|
|
54
|
+
return SUPPORTED_MODEL_IDS.has(Number(descriptor.model_id));
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function pollInterval(value) {
|
|
59
|
+
const text = String(value === undefined ? "5000" : value).trim().toLowerCase();
|
|
60
|
+
if (text === "off" || text === "0") return 0;
|
|
61
|
+
const number = Math.round(Number(text));
|
|
62
|
+
return Number.isFinite(number) && number > 0 ? Math.max(1000, number) : 5000;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeFields(value) {
|
|
66
|
+
let fields = value;
|
|
67
|
+
if (typeof fields === "string") {
|
|
68
|
+
try { fields = JSON.parse(fields); } catch (error) { fields = [fields]; }
|
|
69
|
+
}
|
|
70
|
+
if (!Array.isArray(fields) || !fields.length) return ["full"];
|
|
71
|
+
const unique = Array.from(new Set(fields.map(function (item) { return String(item || "").trim(); }).filter(Boolean)));
|
|
72
|
+
return unique.includes("full") ? ["full"] : (unique.length ? unique : ["full"]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function getByPath(source, path) {
|
|
76
|
+
return String(path || "").split(".").filter(Boolean).reduce(function (current, key) {
|
|
77
|
+
return current === null || current === undefined ? undefined : current[key];
|
|
78
|
+
}, source);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function setByPath(target, path, value) {
|
|
82
|
+
const parts = String(path || "").split(".").filter(Boolean);
|
|
83
|
+
let current = target;
|
|
84
|
+
parts.forEach(function (part, index) {
|
|
85
|
+
if (index === parts.length - 1) current[part] = value;
|
|
86
|
+
else {
|
|
87
|
+
if (!current[part] || typeof current[part] !== "object") current[part] = {};
|
|
88
|
+
current = current[part];
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function compactKey(path) {
|
|
94
|
+
const mappings = {
|
|
95
|
+
"summary.ac.power_w": "power_w",
|
|
96
|
+
"summary.meter.power_w": "power_w",
|
|
97
|
+
"summary.meter.power_l1_w": "power_l1_w",
|
|
98
|
+
"summary.meter.power_l2_w": "power_l2_w",
|
|
99
|
+
"summary.meter.power_l3_w": "power_l3_w"
|
|
100
|
+
};
|
|
101
|
+
return mappings[path] || String(path || "value").replace(/^summary\./, "").replace(/[^a-zA-Z0-9_]+/g, "_");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function flatten(source, prefix, target) {
|
|
105
|
+
if (!source || typeof source !== "object") return;
|
|
106
|
+
Object.keys(source).forEach(function (key) {
|
|
107
|
+
const value = source[key];
|
|
108
|
+
const path = prefix ? prefix + "_" + key : key;
|
|
109
|
+
if (typeof value === "number" && Number.isFinite(value)) target[path] = value;
|
|
110
|
+
else if (typeof value === "boolean" || (typeof value === "string" && value)) target[path] = value;
|
|
111
|
+
else if (value && typeof value === "object" && !Array.isArray(value)) flatten(value, path, target);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function fieldLabel(path, definitions) {
|
|
116
|
+
const definition = definitions.find(function (item) { return item.path === path; });
|
|
117
|
+
const labels = {
|
|
118
|
+
"summary.ac.power_w": "Putere activa AC",
|
|
119
|
+
"summary.ac.frequency_hz": "Frecventa",
|
|
120
|
+
"summary.ac.reactive_power_var": "Putere reactiva",
|
|
121
|
+
"summary.ac.apparent_power_va": "Putere aparenta",
|
|
122
|
+
"summary.dc.dc_total_power_w": "Putere DC totala",
|
|
123
|
+
"summary.common.manufacturer": "Producator",
|
|
124
|
+
"summary.common.model": "Model",
|
|
125
|
+
"summary.common.serial": "Numar serial"
|
|
126
|
+
};
|
|
127
|
+
return definition ? definition.label : (labels[path] || path);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function connect(client, host, port, unitId, timeout) {
|
|
131
|
+
if (!client.isOpen) await client.connectTCP(host, { port: port });
|
|
132
|
+
client.setID(unitId);
|
|
133
|
+
client.setTimeout(timeout);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function close(client) {
|
|
137
|
+
try { if (client && client.isOpen) await client.close(); } catch (error) { /* ignore */ }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function detectDeviceType(summary) {
|
|
141
|
+
if (summary && summary.meter) return "smart_meter";
|
|
142
|
+
if (summary && (summary.ac || summary.dc)) return "inverter";
|
|
143
|
+
return "unknown";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function phaseModeFromDescriptors(descriptors) {
|
|
147
|
+
const inverterModel = descriptors.find(function (item) {
|
|
148
|
+
return [101, 102, 103, 111, 112, 113].includes(item.model_id);
|
|
149
|
+
});
|
|
150
|
+
if (!inverterModel) return "unknown";
|
|
151
|
+
if ([101, 111].includes(inverterModel.model_id)) return "single_phase";
|
|
152
|
+
if ([103, 113].includes(inverterModel.model_id)) return "three_phase";
|
|
153
|
+
return "split_phase";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
RED.httpAdmin.get("/sma-read/probe", async function (req, res) {
|
|
157
|
+
const host = String(req.query.host || "").trim();
|
|
158
|
+
const port = atLeast(req.query.port, 1, 502);
|
|
159
|
+
const configuredUnitId = Math.min(247, atLeast(req.query.unitId, 1, 1));
|
|
160
|
+
const unitIds = Array.from(new Set([configuredUnitId, 126, 3, 1]));
|
|
161
|
+
const client = new ModbusRTU();
|
|
162
|
+
if (!host) {
|
|
163
|
+
res.json({ ok: false, error: "IP lipsa" });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
await connect(client, host, port, configuredUnitId, 3000);
|
|
168
|
+
const failures = [];
|
|
169
|
+
for (const unitId of unitIds) {
|
|
170
|
+
try {
|
|
171
|
+
client.setID(unitId);
|
|
172
|
+
const base = await findBase(client);
|
|
173
|
+
const descriptors = await scanModelDescriptors(client, base);
|
|
174
|
+
const readableDescriptors = supportedDescriptors(descriptors);
|
|
175
|
+
const models = await readModelsByDescriptors(client, readableDescriptors);
|
|
176
|
+
const summary = buildSummary(models);
|
|
177
|
+
const deviceType = detectDeviceType(summary);
|
|
178
|
+
if (deviceType === "unknown") throw new Error("modele SunSpec neacceptate");
|
|
179
|
+
res.json({
|
|
180
|
+
ok: true,
|
|
181
|
+
host: host,
|
|
182
|
+
unit_id: unitId,
|
|
183
|
+
configured_unit_id: configuredUnitId,
|
|
184
|
+
unit_id_changed: unitId !== configuredUnitId,
|
|
185
|
+
device_type: deviceType,
|
|
186
|
+
sunspec_base: base,
|
|
187
|
+
model_ids: descriptors.map(function (item) { return item.model_id; }),
|
|
188
|
+
mppt_count: summary && summary.dc && Array.isArray(summary.dc.mppts) ? summary.dc.mppts.length : 0,
|
|
189
|
+
phase_mode: phaseModeFromDescriptors(descriptors),
|
|
190
|
+
common: summary.common || null,
|
|
191
|
+
meter_model: summary.meter && summary.meter.meter_model || null
|
|
192
|
+
});
|
|
193
|
+
return;
|
|
194
|
+
} catch (error) {
|
|
195
|
+
failures.push("ID " + unitId + ": " + (error.message || String(error)));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
res.json({ ok: false, error: failures.join("; "), tested_unit_ids: unitIds });
|
|
199
|
+
} catch (error) {
|
|
200
|
+
res.json({ ok: false, error: error.message || String(error) });
|
|
201
|
+
} finally {
|
|
202
|
+
await close(client);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
RED.httpAdmin.get("/sma-read/state/:id", function (req, res) {
|
|
207
|
+
const state = nodeRuntimeState.get(req.params.id);
|
|
208
|
+
res.json(state ? { ok: true, state: state } : { ok: false, error: "No cached state" });
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
function SmaReadNode(config) {
|
|
212
|
+
RED.nodes.createNode(this, config);
|
|
213
|
+
const node = this;
|
|
214
|
+
|
|
215
|
+
runLicensedNode(node, RED, config, function startLicensedRuntime() {
|
|
216
|
+
node.host = String(config.host || "").trim();
|
|
217
|
+
node.port = atLeast(config.port, 1, 502);
|
|
218
|
+
node.unitId = atLeast(config.unitId, 1, 126);
|
|
219
|
+
node.pollInterval = pollInterval(config.pollInterval);
|
|
220
|
+
node.timeout = Math.max(500, Number(config.timeout) || 3000);
|
|
221
|
+
node.payloadFields = normalizeFields(config.payloadFields);
|
|
222
|
+
node.debugMode = !!config.debugMode;
|
|
223
|
+
node.multiInverter = !!config.multiInverter;
|
|
224
|
+
node.controlExport = !!config.controlExport;
|
|
225
|
+
node.multiGrid = !!config.multiGrid;
|
|
226
|
+
node.multiInverterExpertMode = !!config.multiInverterExpertMode;
|
|
227
|
+
node.multiInverterUseSelectedFields = config.multiInverterUseSelectedFields !== undefined
|
|
228
|
+
? !!config.multiInverterUseSelectedFields
|
|
229
|
+
: node.multiInverterExpertMode;
|
|
230
|
+
node.multiGridUseSelectedFields = !!config.multiGridUseSelectedFields;
|
|
231
|
+
node.influxDb = !!config.influxDb;
|
|
232
|
+
if (node.multiGrid) {
|
|
233
|
+
node.multiInverter = false;
|
|
234
|
+
node.controlExport = false;
|
|
235
|
+
} else if (node.controlExport) {
|
|
236
|
+
node.multiInverter = false;
|
|
237
|
+
}
|
|
238
|
+
node.inverterId = String(config.inverterId || "").trim();
|
|
239
|
+
node.influxMeasurement = String(config.influxMeasurement || "").trim() || "sma_inverter";
|
|
240
|
+
node.influxNode = String(config.influxNode || "").trim();
|
|
241
|
+
|
|
242
|
+
const selectedFields = node.payloadFields;
|
|
243
|
+
const compactMode = node.multiInverter || node.controlExport || node.multiGrid || node.influxDb;
|
|
244
|
+
const primaryOutputs = compactMode || selectedFields.includes("full") ? 1 : Math.max(1, selectedFields.length);
|
|
245
|
+
node.outputs = primaryOutputs + (node.debugMode ? 1 : 0);
|
|
246
|
+
|
|
247
|
+
let client = new ModbusRTU();
|
|
248
|
+
let timer = null;
|
|
249
|
+
let busy = false;
|
|
250
|
+
let closing = false;
|
|
251
|
+
let sunspecBase = null;
|
|
252
|
+
let sunspecDescriptors = null;
|
|
253
|
+
|
|
254
|
+
function makeInfluxFields(envelope, fieldsToEmit) {
|
|
255
|
+
const fields = {};
|
|
256
|
+
fieldsToEmit.forEach(function (path) {
|
|
257
|
+
const value = getByPath(envelope, path);
|
|
258
|
+
if (value !== null && value !== undefined && ["number", "string", "boolean"].includes(typeof value)) fields[compactKey(path)] = value;
|
|
259
|
+
});
|
|
260
|
+
return fields;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function resetClient() {
|
|
264
|
+
await close(client);
|
|
265
|
+
client = new ModbusRTU();
|
|
266
|
+
sunspecBase = null;
|
|
267
|
+
sunspecDescriptors = null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function pollOnce() {
|
|
271
|
+
if (busy || closing) return;
|
|
272
|
+
busy = true;
|
|
273
|
+
const readStartedAt = Date.now();
|
|
274
|
+
try {
|
|
275
|
+
if (compactMode && !node.inverterId) throw new Error("Inverter ID este obligatoriu pentru modul de integrare selectat.");
|
|
276
|
+
await connect(client, node.host, node.port, node.unitId, node.timeout);
|
|
277
|
+
if (sunspecBase === null) sunspecBase = await findBase(client);
|
|
278
|
+
if (!sunspecDescriptors) sunspecDescriptors = await scanModelDescriptors(client, sunspecBase);
|
|
279
|
+
const models = await readModelsByDescriptors(client, supportedDescriptors(sunspecDescriptors));
|
|
280
|
+
const summary = buildSummary(models);
|
|
281
|
+
const rawData = models;
|
|
282
|
+
const modelList = sunspecDescriptors.map(function (item) { return { model_id: item.model_id, addr: item.addr, len: item.len }; });
|
|
283
|
+
const deviceType = detectDeviceType(summary);
|
|
284
|
+
const customFields = selectedFields.filter(function (field) { return field !== "full"; });
|
|
285
|
+
const effectiveFields = node.multiGrid
|
|
286
|
+
? (node.multiGridUseSelectedFields && customFields.length ? customFields.slice() : DEFAULT_MULTI_GRID_FIELDS.slice())
|
|
287
|
+
: (node.multiInverter || node.controlExport)
|
|
288
|
+
? (node.multiInverterUseSelectedFields && customFields.length
|
|
289
|
+
? customFields.slice()
|
|
290
|
+
: [deviceType === "smart_meter" ? "summary.meter.power_w" : "summary.ac.power_w"])
|
|
291
|
+
: node.influxDb && selectedFields.includes("full")
|
|
292
|
+
? (deviceType === "smart_meter" ? DEFAULT_METER_FIELDS.slice() : DEFAULT_FIELDS.slice())
|
|
293
|
+
: node.influxDb && !node.multiInverterUseSelectedFields
|
|
294
|
+
? (deviceType === "smart_meter" ? DEFAULT_METER_FIELDS.slice() : DEFAULT_FIELDS.slice())
|
|
295
|
+
: selectedFields.slice();
|
|
296
|
+
const now = new Date().toISOString();
|
|
297
|
+
const readDurationMs = Date.now() - readStartedAt;
|
|
298
|
+
nodeRuntimeState.set(node.id, {
|
|
299
|
+
status: "ok",
|
|
300
|
+
host: node.host,
|
|
301
|
+
port: node.port,
|
|
302
|
+
unit_id: node.unitId,
|
|
303
|
+
device_type: deviceType,
|
|
304
|
+
sunspec_base: sunspecBase,
|
|
305
|
+
model_ids: sunspecDescriptors.map(function (item) { return item.model_id; }),
|
|
306
|
+
mppt_count: summary && summary.dc && Array.isArray(summary.dc.mppts) ? summary.dc.mppts.length : 0,
|
|
307
|
+
phase_mode: phaseModeFromDescriptors(sunspecDescriptors),
|
|
308
|
+
common: summary.common || null,
|
|
309
|
+
meter_model: summary.meter && summary.meter.meter_model || null,
|
|
310
|
+
payload_fields: selectedFields.slice(),
|
|
311
|
+
updated_at: now,
|
|
312
|
+
runtime_version: RUNTIME_VERSION,
|
|
313
|
+
read_duration_ms: readDurationMs
|
|
314
|
+
});
|
|
315
|
+
const envelope = {
|
|
316
|
+
status: "ok",
|
|
317
|
+
ip: node.host,
|
|
318
|
+
unit_id: node.unitId,
|
|
319
|
+
profile: "sunspec",
|
|
320
|
+
device_type: deviceType,
|
|
321
|
+
sunspec_base: sunspecBase,
|
|
322
|
+
models: modelList,
|
|
323
|
+
summary: summary,
|
|
324
|
+
ts: now,
|
|
325
|
+
runtime_version: RUNTIME_VERSION,
|
|
326
|
+
read_duration_ms: readDurationMs
|
|
327
|
+
};
|
|
328
|
+
const power = getByPath(envelope, "summary.ac.power_w");
|
|
329
|
+
node.status({ fill: "green", shape: "dot", text: power === null || power === undefined ? "online" : String(power) + " W" });
|
|
330
|
+
|
|
331
|
+
const debugMessage = node.debugMode ? {
|
|
332
|
+
topic: "sma/raw",
|
|
333
|
+
payload: { ...envelope, raw_models: rawData },
|
|
334
|
+
payload_field: "raw_models"
|
|
335
|
+
} : null;
|
|
336
|
+
|
|
337
|
+
let primary;
|
|
338
|
+
const secondaryInflux = node.influxDb && !!(node.multiGrid || node.multiInverter || node.controlExport);
|
|
339
|
+
let influxData = null;
|
|
340
|
+
if (node.influxDb) {
|
|
341
|
+
const fields = makeInfluxFields(envelope, effectiveFields);
|
|
342
|
+
influxData = {
|
|
343
|
+
measurement: node.influxMeasurement,
|
|
344
|
+
tags: { device_type: deviceType === "smart_meter" ? "sma_smart_meter" : "sma_inverter", inverter_id: node.inverterId, host: node.host, unit_id: String(node.unitId) },
|
|
345
|
+
fields: fields,
|
|
346
|
+
timestamp: now
|
|
347
|
+
};
|
|
348
|
+
if (!secondaryInflux) primary = {
|
|
349
|
+
topic: node.inverterId,
|
|
350
|
+
payload: fields,
|
|
351
|
+
payload_fields: effectiveFields.slice(),
|
|
352
|
+
inverter_id: node.inverterId,
|
|
353
|
+
device_type: deviceType,
|
|
354
|
+
summary_fragment: envelope.summary,
|
|
355
|
+
influx: influxData
|
|
356
|
+
};
|
|
357
|
+
if (!secondaryInflux) node.send(debugMessage ? [primary, debugMessage] : primary);
|
|
358
|
+
}
|
|
359
|
+
if (node.multiGrid) {
|
|
360
|
+
const compact = {};
|
|
361
|
+
effectiveFields.forEach(function (path) { compact[compactKey(path)] = getByPath(envelope, path); });
|
|
362
|
+
primary = {
|
|
363
|
+
topic: node.inverterId,
|
|
364
|
+
meter_id: node.inverterId,
|
|
365
|
+
device_type: deviceType,
|
|
366
|
+
ip: node.host,
|
|
367
|
+
host: node.host,
|
|
368
|
+
port: node.port,
|
|
369
|
+
unit_id: node.unitId,
|
|
370
|
+
model: envelope.meter_model || "",
|
|
371
|
+
payload: compact,
|
|
372
|
+
payload_field: "multi",
|
|
373
|
+
payload_fields: effectiveFields,
|
|
374
|
+
summary_fragment: envelope.summary
|
|
375
|
+
};
|
|
376
|
+
if (influxData) {
|
|
377
|
+
const target = RED.nodes.getNode(node.influxNode);
|
|
378
|
+
if (target && typeof target.receive === "function") target.receive({ topic: node.inverterId, payload: influxData.fields, influx: influxData });
|
|
379
|
+
else node.warn("SMA Read: selecteaza un nod adt-influxdb pentru integrarea secundara.");
|
|
380
|
+
}
|
|
381
|
+
node.send(debugMessage ? [primary, debugMessage] : primary);
|
|
382
|
+
} else if (node.multiInverter || node.controlExport) {
|
|
383
|
+
const controlField = deviceType === "smart_meter" ? "summary.meter.power_w" : "summary.ac.power_w";
|
|
384
|
+
const compact = {};
|
|
385
|
+
effectiveFields.forEach(function (path) { compact[compactKey(path)] = getByPath(envelope, path); });
|
|
386
|
+
primary = {
|
|
387
|
+
topic: node.inverterId,
|
|
388
|
+
inverter_id: node.inverterId,
|
|
389
|
+
device_type: deviceType,
|
|
390
|
+
payload: node.multiInverterUseSelectedFields ? compact : { power_w: getByPath(envelope, controlField) },
|
|
391
|
+
payload_field: node.multiInverterUseSelectedFields ? (effectiveFields[0] || controlField) : controlField,
|
|
392
|
+
payload_fields: effectiveFields,
|
|
393
|
+
summary_fragment: envelope.summary
|
|
394
|
+
};
|
|
395
|
+
if (influxData) {
|
|
396
|
+
const target = RED.nodes.getNode(node.influxNode);
|
|
397
|
+
if (target && typeof target.receive === "function") target.receive({ topic: node.inverterId, payload: influxData.fields, influx: influxData });
|
|
398
|
+
else node.warn("SMA Read: selecteaza un nod adt-influxdb pentru integrarea secundara.");
|
|
399
|
+
}
|
|
400
|
+
node.send(debugMessage ? [primary, debugMessage] : primary);
|
|
401
|
+
} else if (selectedFields.includes("full")) {
|
|
402
|
+
primary = { topic: "full", payload: envelope, payload_field: "full", payload_fields: ["full"], full_payload: envelope };
|
|
403
|
+
node.send(debugMessage ? [primary, debugMessage] : primary);
|
|
404
|
+
} else if (selectedFields.length === 1) {
|
|
405
|
+
const path = selectedFields[0];
|
|
406
|
+
primary = { topic: fieldLabel(path, []), payload: getByPath(envelope, path), payload_field: path, payload_fields: selectedFields, summary_fragment: envelope.summary };
|
|
407
|
+
node.send(debugMessage ? [primary, debugMessage] : primary);
|
|
408
|
+
} else {
|
|
409
|
+
const output = new Array(selectedFields.length + (debugMessage ? 1 : 0)).fill(null);
|
|
410
|
+
selectedFields.forEach(function (path, index) {
|
|
411
|
+
output[index] = { topic: fieldLabel(path, []), payload: getByPath(envelope, path), payload_field: path, payload_fields: selectedFields, output_index: index, summary_fragment: envelope.summary };
|
|
412
|
+
});
|
|
413
|
+
if (debugMessage) output[selectedFields.length] = debugMessage;
|
|
414
|
+
node.send(output);
|
|
415
|
+
}
|
|
416
|
+
} catch (error) {
|
|
417
|
+
nodeRuntimeState.set(node.id, {
|
|
418
|
+
status: "error",
|
|
419
|
+
host: node.host,
|
|
420
|
+
port: node.port,
|
|
421
|
+
unit_id: node.unitId,
|
|
422
|
+
error: error.message || String(error),
|
|
423
|
+
updated_at: new Date().toISOString()
|
|
424
|
+
});
|
|
425
|
+
node.status({ fill: "red", shape: "ring", text: "error" });
|
|
426
|
+
const message = { topic: "sma/error", payload: { status: "error", ip: node.host, unit_id: node.unitId, runtime_version: RUNTIME_VERSION, read_duration_ms: Date.now() - readStartedAt, error: error.message || String(error), ts: new Date().toISOString() } };
|
|
427
|
+
if (node.outputs > 1) {
|
|
428
|
+
const output = new Array(node.outputs).fill(null);
|
|
429
|
+
output[0] = message;
|
|
430
|
+
node.send(output);
|
|
431
|
+
} else node.send(message);
|
|
432
|
+
await resetClient();
|
|
433
|
+
} finally {
|
|
434
|
+
busy = false;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function loop() {
|
|
439
|
+
if (closing) return;
|
|
440
|
+
await pollOnce();
|
|
441
|
+
if (!closing && node.pollInterval > 0) timer = setTimeout(loop, node.pollInterval);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (node.pollInterval > 0) loop();
|
|
445
|
+
node.on("input", function () { pollOnce(); });
|
|
446
|
+
node.on("close", async function (done) {
|
|
447
|
+
closing = true;
|
|
448
|
+
nodeRuntimeState.delete(node.id);
|
|
449
|
+
if (timer) clearTimeout(timer);
|
|
450
|
+
await close(client);
|
|
451
|
+
done();
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
RED.nodes.registerType("sma-read", SmaReadNode);
|
|
458
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType("sma-write", {
|
|
3
|
+
category: "Atelierul de Tehnologii",
|
|
4
|
+
color: "#b5a2c8",
|
|
5
|
+
defaults: {
|
|
6
|
+
license: { value: "", type: "atelier-license", required: true },
|
|
7
|
+
name: { value: "" },
|
|
8
|
+
host: { value: "", required: true },
|
|
9
|
+
port: { value: 502, required: true, validate: RED.validators.number() },
|
|
10
|
+
unitId: { value: 126, required: true, validate: RED.validators.number() },
|
|
11
|
+
maxPower: { value: 125000, required: true, validate: RED.validators.number() },
|
|
12
|
+
nominalPower: { value: 125000, required: true, validate: RED.validators.number() },
|
|
13
|
+
multiInverter: { value: false },
|
|
14
|
+
inverterId: { value: "" }
|
|
15
|
+
},
|
|
16
|
+
inputs: 1,
|
|
17
|
+
outputs: 1,
|
|
18
|
+
icon: "font-awesome/fa-pencil-square-o",
|
|
19
|
+
paletteLabel: "sma-write",
|
|
20
|
+
label: function () { return this.name || this.host || "sma-write"; },
|
|
21
|
+
oneditprepare: function () {
|
|
22
|
+
const node = this;
|
|
23
|
+
const savedAutoName = node.host && node.inverterId ? node.host + " [" + node.inverterId + "]" : "";
|
|
24
|
+
let autoNameEnabled = !node.name || node.name === node.host || node.name === savedAutoName || node.name === "sma-write";
|
|
25
|
+
function autoName() {
|
|
26
|
+
const host = String($("#node-input-host").val() || "").trim();
|
|
27
|
+
const id = String($("#node-input-inverterId").val() || "").trim();
|
|
28
|
+
return $("#node-input-multiInverter").is(":checked") && id ? host + " [" + id + "]" : host;
|
|
29
|
+
}
|
|
30
|
+
function refreshAutoName() {
|
|
31
|
+
if (autoNameEnabled) $("#node-input-name").val(autoName());
|
|
32
|
+
}
|
|
33
|
+
function toggleMulti() {
|
|
34
|
+
$("#row-inverter-id").toggle($("#node-input-multiInverter").is(":checked"));
|
|
35
|
+
refreshAutoName();
|
|
36
|
+
}
|
|
37
|
+
$("#node-input-multiInverter").on("change.smaWrite", toggleMulti);
|
|
38
|
+
$("#node-input-host").on("input.smaWrite change.smaWrite", function () {
|
|
39
|
+
autoNameEnabled = true;
|
|
40
|
+
refreshAutoName();
|
|
41
|
+
});
|
|
42
|
+
$("#node-input-inverterId").on("input.smaWrite change.smaWrite", refreshAutoName);
|
|
43
|
+
$("#node-input-name").on("input.smaWrite", function () {
|
|
44
|
+
const value = String($(this).val() || "").trim();
|
|
45
|
+
autoNameEnabled = !value || value === autoName();
|
|
46
|
+
});
|
|
47
|
+
toggleMulti();
|
|
48
|
+
},
|
|
49
|
+
oneditsave: function () {
|
|
50
|
+
const host = String($("#node-input-host").val() || "").trim();
|
|
51
|
+
const id = String($("#node-input-inverterId").val() || "").trim();
|
|
52
|
+
if (!String($("#node-input-name").val() || "").trim()) {
|
|
53
|
+
$("#node-input-name").val($("#node-input-multiInverter").is(":checked") && id ? host + " [" + id + "]" : host);
|
|
54
|
+
}
|
|
55
|
+
this.nominalPower = Number($("#node-input-maxPower").val() || 125000);
|
|
56
|
+
$("#node-input-host, #node-input-inverterId, #node-input-name, #node-input-multiInverter").off(".smaWrite");
|
|
57
|
+
},
|
|
58
|
+
oneditcancel: function () {
|
|
59
|
+
$("#node-input-host, #node-input-inverterId, #node-input-name, #node-input-multiInverter").off(".smaWrite");
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
<script type="text/html" data-template-name="sma-write">
|
|
65
|
+
<div class="form-row"><label for="node-input-license"><i class="fa fa-key"></i> License</label><input type="text" id="node-input-license"></div>
|
|
66
|
+
<div class="sma-write-editor">
|
|
67
|
+
<input type="hidden" id="node-input-nominalPower">
|
|
68
|
+
<div class="pr-section">
|
|
69
|
+
<div class="pr-section-title"><i class="fa fa-plug"></i> Connection</div>
|
|
70
|
+
<p class="pr-section-note">Configuratia de scriere SunSpec (Modbus TCP) pentru invertor SMA.</p>
|
|
71
|
+
<div class="pr-grid pr-grid-2">
|
|
72
|
+
<div class="form-row"><label for="node-input-name"><i class="fa fa-tag"></i> Name</label><input type="text" id="node-input-name"></div>
|
|
73
|
+
<div class="form-row"><label for="node-input-host"><i class="fa fa-globe"></i> IP</label><input type="text" id="node-input-host" placeholder="192.168.0.100"></div>
|
|
74
|
+
<div class="form-row"><label for="node-input-port"><i class="fa fa-plug"></i> Port</label><input type="text" id="node-input-port" placeholder="502"></div>
|
|
75
|
+
<div class="form-row"><label for="node-input-unitId"><i class="fa fa-hashtag"></i> Unit ID</label><input type="text" id="node-input-unitId" placeholder="126"></div>
|
|
76
|
+
<div class="form-row pr-checkbox-row"><label for="node-input-multiInverter"><i class="fa fa-sitemap"></i> Multi-inverter</label><input type="checkbox" id="node-input-multiInverter"></div>
|
|
77
|
+
<div class="form-row" id="row-inverter-id"><label for="node-input-inverterId"><i class="fa fa-id-badge"></i> Inverter ID</label><input type="text" id="node-input-inverterId"></div>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
<div class="pr-section">
|
|
81
|
+
<div class="pr-section-title"><i class="fa fa-bolt"></i> Power Control</div>
|
|
82
|
+
<p class="pr-section-note">Limitare activa prin SunSpec Model 123. Sunt scrise numai WMaxLimPct si WMaxLimPct_Ena.</p>
|
|
83
|
+
<div class="form-row"><label for="node-input-maxPower"><i class="fa fa-line-chart"></i> Max Power (W)</label><input type="text" id="node-input-maxPower" placeholder="125000"></div>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
<style>
|
|
87
|
+
.sma-write-editor{--pr-border:#d7dde5;--pr-muted:#667085;--pr-surface:#f8fafc}
|
|
88
|
+
.sma-write-editor .pr-section{margin:0 0 18px;padding:14px 16px 16px;border:1px solid var(--pr-border);border-radius:10px;background:var(--pr-surface)}
|
|
89
|
+
.sma-write-editor .pr-section-title{display:flex;align-items:center;gap:8px;margin:0 0 4px;font-size:15px;font-weight:600;color:#243447}
|
|
90
|
+
.sma-write-editor .pr-section-note{margin:0 0 14px;color:var(--pr-muted);font-size:12px;line-height:1.45}
|
|
91
|
+
.sma-write-editor .pr-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px 16px}.sma-write-editor .pr-grid-2{grid-template-columns:repeat(2,minmax(320px,1fr))}
|
|
92
|
+
.sma-write-editor .form-row{margin-bottom:12px}.sma-write-editor .pr-grid .form-row,.sma-write-editor .form-row:last-child{margin-bottom:0}
|
|
93
|
+
.sma-write-editor .form-row>label{width:150px;font-weight:600}.sma-write-editor .form-row>input[type=text],.sma-write-editor .form-row>input[type=number],.sma-write-editor .form-row>select{width:calc(100% - 160px)}
|
|
94
|
+
.sma-write-editor .pr-grid-2 .form-row>label{width:130px}.sma-write-editor .pr-grid-2 .form-row>input[type=text],.sma-write-editor .pr-grid-2 .form-row>input[type=number],.sma-write-editor .pr-grid-2 .form-row>select{width:calc(100% - 140px)}
|
|
95
|
+
.sma-write-editor .pr-checkbox-row>input[type=checkbox]{width:auto;display:inline-block}
|
|
96
|
+
@media(max-width:1100px){.sma-write-editor .pr-grid-2{grid-template-columns:1fr}}
|
|
97
|
+
</style>
|
|
98
|
+
</script>
|
|
99
|
+
|
|
100
|
+
<script type="text/html" data-help-name="sma-write">
|
|
101
|
+
<p><b>sma-write</b> limiteaza puterea activa a invertorului prin SunSpec Model 123.</p>
|
|
102
|
+
<p>Nodul detecteaza baza SunSpec si scrie exclusiv <code>WMaxLimPct</code> si <code>WMaxLimPct_Ena</code> folosind Modbus Function 16. Nu modifica registre SMA native, putere reactiva, rampa, window sau revert.</p>
|
|
103
|
+
<p>Intrarea poate fi un numar in W sau un obiect cu <code>value</code>, <code>limit_w</code>, <code>power_w</code>, <code>site_limit_w</code> ori <code>percent</code>. Comenzile identice consecutive nu sunt rescrise.</p>
|
|
104
|
+
<p>Rezultatul are topic <code>sma/write</code> si include procentul, adresele, durata, versiunea runtime si <code>accepted_unverified=true</code>. Erorile folosesc topic <code>sma/error</code>.</p>
|
|
105
|
+
</script>
|