@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,108 @@
|
|
|
1
|
+
<style>
|
|
2
|
+
.scada-aggregator-editor { --sa-border:#d7dde5; --sa-surface:#f8fafc; --sa-muted:#667085; }
|
|
3
|
+
.scada-aggregator-editor .sa-section { margin:0 0 16px; padding:14px 16px; border:1px solid var(--sa-border); border-radius:10px; background:var(--sa-surface); }
|
|
4
|
+
.scada-aggregator-editor .sa-title { margin:0 0 5px; color:#243447; font-size:15px; font-weight:600; }
|
|
5
|
+
.scada-aggregator-editor .sa-note { margin:0 0 12px; color:var(--sa-muted); font-size:12px; line-height:1.45; }
|
|
6
|
+
.scada-aggregator-editor .scada-aggregator-group { display:grid; grid-template-columns:190px minmax(240px,1fr) 34px; gap:8px; align-items:center; margin:0 0 8px; }
|
|
7
|
+
.scada-aggregator-editor .scada-aggregator-group input { width:100% !important; min-width:0; box-sizing:border-box; }
|
|
8
|
+
.scada-aggregator-editor .sa-fields { display:grid; grid-template-columns:repeat(3,minmax(150px,1fr)); gap:8px; padding:10px 12px; border:1px solid var(--sa-border); border-radius:8px; background:#fff; }
|
|
9
|
+
.scada-aggregator-editor .sa-fields label { margin:0; font-weight:600; }
|
|
10
|
+
.scada-aggregator-editor .form-tips { margin:10px 0 0; padding:10px 12px; border:1px solid #eadfb3; border-radius:7px; background:#fffdf0; color:#5e5427; line-height:1.45; }
|
|
11
|
+
.scada-aggregator-editor .form-row > label { width:130px; font-weight:600; }
|
|
12
|
+
.scada-aggregator-editor .form-row > input, .scada-aggregator-editor .form-row > select { width:calc(100% - 140px); }
|
|
13
|
+
</style>
|
|
14
|
+
|
|
15
|
+
<script type="text/javascript">
|
|
16
|
+
(function () {
|
|
17
|
+
const DEFAULT_GROUPS = [{ id: "grid_meter", label: "Grid meter", count: 3 }, { id: "pv_inverter", label: "PV inverter", count: 13 }, { id: "bess", label: "BESS", count: 1 }];
|
|
18
|
+
function groupsFromEditor() {
|
|
19
|
+
const groups = [];
|
|
20
|
+
$("#node-scada-aggregator-groups .scada-aggregator-group").each(function (index) {
|
|
21
|
+
const row = $(this);
|
|
22
|
+
const id = String(row.find(".scada-aggregator-group-id").val() || "").trim().toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "") || ("group_" + (index + 1));
|
|
23
|
+
groups.push({ id: id, label: String(row.find(".scada-aggregator-group-label").val() || id).trim() || id, count: 1 });
|
|
24
|
+
});
|
|
25
|
+
return groups.length ? groups : DEFAULT_GROUPS;
|
|
26
|
+
}
|
|
27
|
+
function addGroup(group) {
|
|
28
|
+
const value = group || { id: "custom_group", label: "Custom group", count: 1 };
|
|
29
|
+
const row = $("<div class='scada-aggregator-group form-row'></div>");
|
|
30
|
+
row.append("<input type='text' class='scada-aggregator-group-id' placeholder='ID'>").find("input:last").val(value.id);
|
|
31
|
+
row.append("<input type='text' class='scada-aggregator-group-label' placeholder='Denumire'>").find("input:last").val(value.label);
|
|
32
|
+
row.append("<a href='#' class='button scada-remove-aggregator-group'><i class='fa fa-trash'></i></a>");
|
|
33
|
+
row.find(".scada-remove-aggregator-group").on("click", function (event) { event.preventDefault(); row.remove(); });
|
|
34
|
+
$("#node-scada-aggregator-groups").append(row);
|
|
35
|
+
}
|
|
36
|
+
RED.nodes.registerType("scada-aggregator", {
|
|
37
|
+
category: "Atelierul de Tehnologii",
|
|
38
|
+
color: "#d9ead3",
|
|
39
|
+
defaults: {
|
|
40
|
+
license: { value: "", type: "atelier-license", required: true },
|
|
41
|
+
name: { value: "" },
|
|
42
|
+
aggregatorGroups: { value: '[{"id":"grid_meter","label":"Grid meter","count":3},{"id":"pv_inverter","label":"PV inverter","count":13},{"id":"bess","label":"BESS","count":1}]' },
|
|
43
|
+
sourceIdPath: { value: "scadaSource", validate: function (value) { return !!String(value || "").trim(); } },
|
|
44
|
+
groupIdPath: { value: "scadaGroup", validate: function (value) { return !!String(value || "").trim(); } },
|
|
45
|
+
staleAfterMs: { value: 5000, validate: function (value) { return Number(value) >= 1000; } },
|
|
46
|
+
emitIntervalMs: { value: 1000, validate: function (value) { return Number(value) >= 0; } },
|
|
47
|
+
debugEnabled: { value: false },
|
|
48
|
+
gridVoltageProfile: { value: "complete" },
|
|
49
|
+
},
|
|
50
|
+
inputs: 1,
|
|
51
|
+
outputs: 1,
|
|
52
|
+
icon: "font-awesome/fa-object-group",
|
|
53
|
+
outputLabels: function (index) { return index === 0 ? "snapshot pentru scada-server" : "diagnostics"; },
|
|
54
|
+
label: function () { return this.name || "scada-aggregator"; },
|
|
55
|
+
oneditprepare: function () {
|
|
56
|
+
let groups;
|
|
57
|
+
try { groups = JSON.parse(this.aggregatorGroups); } catch (_) { groups = null; }
|
|
58
|
+
(Array.isArray(groups) && groups.length ? groups : DEFAULT_GROUPS).forEach(addGroup);
|
|
59
|
+
$("#node-scada-add-aggregator-group").on("click", function (event) { event.preventDefault(); addGroup(); });
|
|
60
|
+
$("input[name='node-input-gridVoltageProfile'][value='" + (this.gridVoltageProfile || "complete") + "']").prop("checked", true);
|
|
61
|
+
},
|
|
62
|
+
oneditsave: function () {
|
|
63
|
+
this.aggregatorGroups = JSON.stringify(groupsFromEditor());
|
|
64
|
+
this.gridVoltageProfile = $("input[name='node-input-gridVoltageProfile']:checked").val() || "complete";
|
|
65
|
+
this.outputs = $("#node-input-debugEnabled").prop("checked") ? 2 : 1;
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
})();
|
|
69
|
+
</script>
|
|
70
|
+
|
|
71
|
+
<script type="text/html" data-template-name="scada-aggregator">
|
|
72
|
+
<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>
|
|
73
|
+
<div class="scada-aggregator-editor">
|
|
74
|
+
<div class="sa-section">
|
|
75
|
+
<div class="sa-title"><i class="fa fa-object-group"></i> SCADA Aggregator</div>
|
|
76
|
+
<p class="sa-note">Identifică automat sursele din <code>msg.topic</code>, meter/inverter ID, IP sau din câmpurile configurate.</p>
|
|
77
|
+
<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>
|
|
78
|
+
<div class="form-row"><label><i class="fa fa-object-group"></i> Grupe</label><span>ID și denumire</span></div>
|
|
79
|
+
<div id="node-scada-aggregator-groups"></div>
|
|
80
|
+
<div class="form-row"><label></label><a href="#" class="button" id="node-scada-add-aggregator-group"><i class="fa fa-plus"></i> Adaugă grupă</a></div>
|
|
81
|
+
<input type="hidden" id="node-input-sourceIdPath"><input type="hidden" id="node-input-groupIdPath">
|
|
82
|
+
<div class="form-tips">Sursele sunt recunoscute automat din <code>msg.topic</code>, meter/inverter ID, IP sau host. Pentru două aparate identice fără niciun ID în mesaj, setează doar <code>msg.topic</code> diferit înainte de agregator.</div>
|
|
83
|
+
<div class="sa-title" style="margin-top:16px;"><i class="fa fa-bolt"></i> Grid meter · date exportate</div>
|
|
84
|
+
<p class="sa-note">P, Q și frecvența sunt trimise mereu. Alege tensiunile publicate pentru fiecare grid meter.</p>
|
|
85
|
+
<div class="sa-fields">
|
|
86
|
+
<label><input type="radio" name="node-input-gridVoltageProfile" value="compact"> Compact: U L-N medie, U L-L medie</label>
|
|
87
|
+
<label><input type="radio" name="node-input-gridVoltageProfile" value="three_phase"> Trifazat: L1-N…L3-N și L1-L2…L3-L1</label>
|
|
88
|
+
<label><input type="radio" name="node-input-gridVoltageProfile" value="complete"> Complet: faze + ambele medii</label>
|
|
89
|
+
</div>
|
|
90
|
+
<div class="form-row"><label for="node-input-staleAfterMs"><i class="fa fa-clock-o"></i> Stale după (ms)</label><input type="number" id="node-input-staleAfterMs" min="1000"></div>
|
|
91
|
+
<div class="form-row"><label for="node-input-emitIntervalMs"><i class="fa fa-refresh"></i> Snapshot (ms)</label><input type="number" id="node-input-emitIntervalMs" min="0"></div>
|
|
92
|
+
<div class="form-row"><label for="node-input-debugEnabled"><i class="fa fa-bug"></i> Debug output</label><input type="checkbox" id="node-input-debugEnabled" style="width:auto;"></div>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
</script>
|
|
96
|
+
|
|
97
|
+
<script type="text/html" data-help-name="scada-aggregator">
|
|
98
|
+
<p>Agregă P, Q, tensiunile trifazate și frecvența de la mai multe surse într-un singur snapshot pentru <b>scada-server</b>.</p>
|
|
99
|
+
<h3>Conectare</h3>
|
|
100
|
+
<p>Etichetează fiecare mesaj cu <code>msg.scadaGroup</code> și <code>msg.scadaSource</code>, apoi conectează ieșirea 1 la <b>scada-server</b> configurat cu <b>Mod hartă: Auto-discovery</b>. Grupele implicite sunt <code>grid_meter</code>, <code>pv_inverter</code> și <code>bess</code>, dar orice grupă sau sursă nouă este acceptată automat. Un ID de sursă este unic în întregul agregator: nu poate fi reutilizat într-o altă grupă.</p>
|
|
101
|
+
<h3>Date acceptate</h3>
|
|
102
|
+
<p>Nodul acceptă forme normalizate: <code>p</code> (W), <code>q</code> (VAr), <code>f</code> (Hz), <code>u_l1n</code>, <code>u_l2n</code>, <code>u_l3n</code>, <code>u_ln_avg</code>, <code>u_l12</code>, <code>u_l23</code>, <code>u_l31</code> și <code>u_ll_avg</code> (V). Sunt acceptate și denumirile echivalente, de exemplu <code>active_power_w</code>, <code>reactive_power_var</code>, <code>frequency_hz</code> și <code>voltage_l1_l2_v</code>, inclusiv structurile produse de Fronius, Janitza și Carlo Gavazzi.</p>
|
|
103
|
+
<p>Valorile zero de tensiune și frecvență rămân vizibile la sursa individuală, dar sunt excluse automat din mediile pe faze și din mediile generale. Astfel, un invertor sau meter aflat în eroare, care raportează 0 V / 0 Hz, nu alterează valorile medii ale instalației. Puterile P și Q continuă să fie însumate.</p>
|
|
104
|
+
<h3>Totaluri</h3>
|
|
105
|
+
<ul><li>P și Q sunt însumate în fiecare grup.</li><li>Fiecare tensiune și frecvența sunt mediate numai pentru sursele online.</li><li><code>payload.scada.totals.site.grid</code> este totalul grupului <code>grid_meter</code>, iar <code>payload.scada.totals.site.pv</code> este totalul grupului <code>pv_inverter</code>.</li></ul>
|
|
106
|
+
<h3>Ieșiri</h3>
|
|
107
|
+
<p>Ieșirea 1 publică snapshot-ul <code>msg.topic = "scada-aggregator/snapshot"</code> și <code>msg.payload.scada</code>. Ieșirea 2 există numai după activarea <b>Debug output</b> și deploy; ea publică diagnosticul surselor și al totalurilor.</p>
|
|
108
|
+
</script>
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
module.exports = function (RED) {
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const { runLicensedNode } = require("../lib/licensing/node-guard");
|
|
5
|
+
|
|
6
|
+
const { normalizeMessage, summarize } = require("./lib/aggregate");
|
|
7
|
+
const { groupList } = require("./lib/groups");
|
|
8
|
+
|
|
9
|
+
function getByPath(object, path) {
|
|
10
|
+
return String(path || "scadaSource").split(".").reduce(function (value, key) {
|
|
11
|
+
return value == null ? undefined : value[key];
|
|
12
|
+
}, object);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function ScadaAggregatorNode(config) {
|
|
16
|
+
RED.nodes.createNode(this, config);
|
|
17
|
+
const node = this;
|
|
18
|
+
|
|
19
|
+
runLicensedNode(node, RED, config, function startLicensedRuntime() {
|
|
20
|
+
node.sourceIdPath = String(config.sourceIdPath || "scadaSource").trim() || "scadaSource";
|
|
21
|
+
node.staleAfterMs = Math.max(1000, Number(config.staleAfterMs) || 5000);
|
|
22
|
+
node.debugEnabled = !!config.debugEnabled;
|
|
23
|
+
node.gridVoltageProfile = ["compact", "three_phase", "complete"].includes(config.gridVoltageProfile) ? config.gridVoltageProfile : "complete";
|
|
24
|
+
const configuredGroups = groupList(config);
|
|
25
|
+
const groups = new Map(configuredGroups.map(function (group) { return [group.id, { id: group.id, label: group.label }]; }));
|
|
26
|
+
node.groupIdPath = String(config.groupIdPath || "scadaGroup").trim() || "scadaGroup";
|
|
27
|
+
node.siteTotalSource = String(config.siteTotalSource || configuredGroups[0].id).trim() || configuredGroups[0].id;
|
|
28
|
+
const sources = new Map();
|
|
29
|
+
const sourceGroups = new Map();
|
|
30
|
+
const state = new Map();
|
|
31
|
+
let timer = null;
|
|
32
|
+
|
|
33
|
+
function buildPayload() {
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
const payload = { totals: {} };
|
|
36
|
+
const active = {};
|
|
37
|
+
groups.forEach(function (group) { payload[group.id] = {}; active[group.id] = []; });
|
|
38
|
+
sources.forEach(function (source) {
|
|
39
|
+
const entry = state.get(source.key);
|
|
40
|
+
const age = entry ? Math.max(0, Math.round((now - entry.updatedAt) / 1000)) : Math.ceil(node.staleAfterMs / 1000);
|
|
41
|
+
const online = !!entry && (now - entry.updatedAt) <= node.staleAfterMs;
|
|
42
|
+
const raw = entry ? entry.values : {};
|
|
43
|
+
const target = Object.assign({}, raw, { online: online ? 1 : 0, age_s: age });
|
|
44
|
+
if (source.group === "grid_meter") {
|
|
45
|
+
const allowed = node.gridVoltageProfile === "compact" ? ["u_ln_avg", "u_ll_avg"] : (node.gridVoltageProfile === "three_phase" ? ["u_l1n", "u_l2n", "u_l3n", "u_l12", "u_l23", "u_l31"] : ["u_l1n", "u_l2n", "u_l3n", "u_ln_avg", "u_l12", "u_l23", "u_l31", "u_ll_avg"]);
|
|
46
|
+
["u_l1n", "u_l2n", "u_l3n", "u_ln_avg", "u_l12", "u_l23", "u_l31", "u_ll_avg"].forEach(function (key) { if (!allowed.includes(key)) delete target[key]; });
|
|
47
|
+
}
|
|
48
|
+
payload[source.group][source.id] = target;
|
|
49
|
+
if (online) active[source.group].push({ values: entry.values });
|
|
50
|
+
});
|
|
51
|
+
groups.forEach(function (group) { payload.totals[group.id] = summarize(active[group.id]); });
|
|
52
|
+
if (payload.totals.grid_meter) {
|
|
53
|
+
const allowed = node.gridVoltageProfile === "compact" ? ["u_ln_avg", "u_ll_avg"] : (node.gridVoltageProfile === "three_phase" ? ["u_l1n", "u_l2n", "u_l3n", "u_l12", "u_l23", "u_l31"] : ["u_l1n", "u_l2n", "u_l3n", "u_ln_avg", "u_l12", "u_l23", "u_l31", "u_ll_avg"]);
|
|
54
|
+
["u_l1n", "u_l2n", "u_l3n", "u_ln_avg", "u_l12", "u_l23", "u_l31", "u_ll_avg"].forEach(function (key) { if (!allowed.includes(key)) delete payload.totals.grid_meter[key]; });
|
|
55
|
+
}
|
|
56
|
+
payload.totals.site = {
|
|
57
|
+
grid: Object.assign({ active: 0 }, payload.totals.grid_meter || {}),
|
|
58
|
+
pv: Object.assign({ active: 0 }, payload.totals.pv_inverter || {})
|
|
59
|
+
};
|
|
60
|
+
return payload;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function emit(reason, send) {
|
|
64
|
+
const payload = buildPayload();
|
|
65
|
+
const online = Object.values(payload.totals).reduce(function (sum, total) { return Math.max(sum, total.active || 0); }, 0);
|
|
66
|
+
node.status({ fill: online ? "green" : "yellow", shape: "dot", text: online + " active | " + reason });
|
|
67
|
+
const snapshot = { topic: "scada-aggregator/snapshot", payload: { scada: payload } };
|
|
68
|
+
const diagnostics = node.debugEnabled ? { topic: "scada-aggregator/diagnostics", payload: { reason: reason, sources: sources.size, stale_after_ms: node.staleAfterMs, totals: payload.totals } } : null;
|
|
69
|
+
send(node.debugEnabled ? [snapshot, diagnostics] : [snapshot]);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
node.on("input", function (msg, send, done) {
|
|
73
|
+
try {
|
|
74
|
+
const sourceId = String(getByPath(msg, node.sourceIdPath) || msg.topic || msg.meter_id || msg.inverter_id || msg.device_id || msg.ip || msg.host || "").trim();
|
|
75
|
+
const groupId = String(getByPath(msg, node.groupIdPath) || configuredGroups[0].id).trim().toLowerCase().replace(/[^a-z0-9_]+/g, "_") || configuredGroups[0].id;
|
|
76
|
+
if (!sourceId) {
|
|
77
|
+
node.status({ fill: "yellow", shape: "ring", text: "lipseste ID sursa" });
|
|
78
|
+
node.warn("scada-aggregator: sursa nu poate fi identificata. Seteaza msg.topic sau msg.scadaSource.");
|
|
79
|
+
done();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const existingGroup = sourceGroups.get(sourceId);
|
|
83
|
+
if (existingGroup && existingGroup !== groupId) {
|
|
84
|
+
node.status({ fill: "red", shape: "ring", text: "ID duplicat: " + sourceId });
|
|
85
|
+
node.warn("scada-aggregator: ID-ul sursei '" + sourceId + "' este deja folosit de grupa '" + existingGroup + "' si nu poate fi reutilizat in grupa '" + groupId + "'.");
|
|
86
|
+
done();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (!groups.has(groupId)) groups.set(groupId, { id: groupId, label: groupId });
|
|
90
|
+
const sourceKey = groupId + "." + sourceId;
|
|
91
|
+
const source = sources.get(sourceKey) || { group: groupId, id: sourceId, key: sourceKey };
|
|
92
|
+
const values = normalizeMessage(msg);
|
|
93
|
+
if (!Object.values(values).some(function (value) { return value !== null; })) {
|
|
94
|
+
node.warn("scada-aggregator: nu exista P/Q/U/f numeric pentru " + sourceId);
|
|
95
|
+
done();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
sources.set(sourceKey, source);
|
|
99
|
+
sourceGroups.set(sourceId, groupId);
|
|
100
|
+
const previous = state.get(sourceKey);
|
|
101
|
+
const merged = Object.assign({}, previous ? previous.values : {});
|
|
102
|
+
Object.keys(values).forEach(function (key) { if (values[key] !== null) merged[key] = values[key]; });
|
|
103
|
+
state.set(sourceKey, { values: merged, updatedAt: Date.now() });
|
|
104
|
+
emit("input " + sourceId, send);
|
|
105
|
+
done();
|
|
106
|
+
} catch (error) { done(error); }
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const emitIntervalMs = Math.max(0, Number(config.emitIntervalMs) || 1000);
|
|
110
|
+
if (emitIntervalMs) timer = setInterval(function () { emit("interval", node.send.bind(node)); }, emitIntervalMs);
|
|
111
|
+
node.on("close", function (done) { if (timer) clearInterval(timer); done(); });
|
|
112
|
+
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
RED.nodes.registerType("scada-aggregator", ScadaAggregatorNode);
|
|
117
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const VOLTAGE_PROFILES = {
|
|
4
|
+
compact: ["u_ln_avg", "u_ll_avg"],
|
|
5
|
+
three_phase: ["u_l1n", "u_l2n", "u_l3n", "u_l12", "u_l23", "u_l31"],
|
|
6
|
+
complete: ["u_l1n", "u_l2n", "u_l3n", "u_ln_avg", "u_l12", "u_l23", "u_l31", "u_ll_avg"]
|
|
7
|
+
};
|
|
8
|
+
const SIGNALS = { p: { label: "P", unit: "W" }, q: { label: "Q", unit: "VAr" }, f: { label: "F", unit: "Hz" }, u_l1n: { label: "U L1-N", unit: "V" }, u_l2n: { label: "U L2-N", unit: "V" }, u_l3n: { label: "U L3-N", unit: "V" }, u_ln_avg: { label: "U L-N medie", unit: "V" }, u_l12: { label: "U L1-L2", unit: "V" }, u_l23: { label: "U L2-L3", unit: "V" }, u_l31: { label: "U L3-L1", unit: "V" }, u_ll_avg: { label: "U L-L medie", unit: "V" } };
|
|
9
|
+
const DEFAULT_GROUPS = [{ id: "grid_meter", label: "Grid meter", count: 3 }, { id: "pv_inverter", label: "PV inverter", count: 13 }, { id: "bess", label: "BESS", count: 1 }];
|
|
10
|
+
|
|
11
|
+
function slug(value, fallback) { return String(value || "").toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "") || fallback; }
|
|
12
|
+
function count(value, fallback) { const number = Number(value); return Number.isFinite(number) ? Math.max(0, Math.min(99, Math.round(number))) : fallback; }
|
|
13
|
+
function normalizeProfile(value) { return VOLTAGE_PROFILES[value] ? value : "complete"; }
|
|
14
|
+
function normalizePrefix(value) { return String(value || "payload.scada").trim().replace(/\.+$/, "") || "payload.scada"; }
|
|
15
|
+
|
|
16
|
+
function groupList(config) {
|
|
17
|
+
let parsed;
|
|
18
|
+
try { parsed = typeof config.aggregatorGroups === "string" ? JSON.parse(config.aggregatorGroups) : config.aggregatorGroups; } catch (_) { parsed = null; }
|
|
19
|
+
if (!Array.isArray(parsed) || !parsed.length) return DEFAULT_GROUPS.map(function (group) { return Object.assign({}, group); });
|
|
20
|
+
const used = new Set();
|
|
21
|
+
return parsed.map(function (group, index) {
|
|
22
|
+
let id = slug(group && group.id, "group_" + (index + 1));
|
|
23
|
+
while (used.has(id)) id += "_" + (index + 1);
|
|
24
|
+
used.add(id);
|
|
25
|
+
return { id: id, label: String(group && group.label || id).trim() || id, count: count(group && group.count, 1) };
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sourceList(config) {
|
|
30
|
+
const list = [];
|
|
31
|
+
groupList(config).forEach(function (group) {
|
|
32
|
+
for (let index = 1; index <= group.count; index += 1) {
|
|
33
|
+
const id = group.count === 1 ? group.id : group.id + "_" + String(index).padStart(2, "0");
|
|
34
|
+
list.push({ group: group.id, id: id, label: group.count === 1 ? group.label : group.label + " " + String(index).padStart(2, "0") });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
return list;
|
|
38
|
+
}
|
|
39
|
+
function metric(key, label, address, path, unit, type) { return { key: key, label: label, address: address, type: type || "float32", scale: 1, path: path, unit: unit || "", signMode: "as_is", byteOrder: "big_endian", wordOrder: "msw_first" }; }
|
|
40
|
+
|
|
41
|
+
function buildAggregatorMetrics(config) {
|
|
42
|
+
const prefix = normalizePrefix(config.aggregatorPrefix);
|
|
43
|
+
const signals = ["p", "q"].concat(VOLTAGE_PROFILES[normalizeProfile(config.aggregatorVoltageProfile)], ["f"]);
|
|
44
|
+
const metrics = [];
|
|
45
|
+
const addBlock = function (group, id, label, address, total) {
|
|
46
|
+
signals.forEach(function (signal, index) { const info = SIGNALS[signal]; metrics.push(metric((total ? "total_" + group : id) + "_" + signal, (total ? "Total " + label : label) + " " + info.label, address + (index * 2), prefix + "." + (total ? "totals." + group : group + "." + id) + "." + signal, info.unit)); });
|
|
47
|
+
metrics.push(metric((total ? "total_" + group : id) + "_" + (total ? "active" : "online"), (total ? "Total " + label : label) + " " + (total ? "active" : "online"), address + 28, prefix + "." + (total ? "totals." + group : group + "." + id) + "." + (total ? "active" : "online"), "", "uint16"));
|
|
48
|
+
if (!total) metrics.push(metric(id + "_age_s", label + " age", address + 29, prefix + "." + group + "." + id + ".age_s", "s", "uint16"));
|
|
49
|
+
};
|
|
50
|
+
const sources = sourceList(config);
|
|
51
|
+
sources.forEach(function (source, index) { addBlock(source.group, source.id, source.label, index * 32, false); });
|
|
52
|
+
const totalAddress = sources.length * 32;
|
|
53
|
+
groupList(config).forEach(function (group, index) { addBlock(group.id, "", group.label, totalAddress + (index * 32), true); });
|
|
54
|
+
addBlock("site", "", "Site", totalAddress + (groupList(config).length * 32), true);
|
|
55
|
+
return metrics;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { DEFAULT_GROUPS, SIGNALS, VOLTAGE_PROFILES, buildAggregatorMetrics, groupList, normalizeProfile, normalizePrefix, sourceList };
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
function clamp(value, min, max) {
|
|
5
|
+
|
|
6
|
+
return Math.min(Math.max(value, min), max);
|
|
7
|
+
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
function normalizeByteOrder(value) {
|
|
12
|
+
|
|
13
|
+
return value === "little_endian" ? "little_endian" : "big_endian";
|
|
14
|
+
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
function normalizeWordOrder(value) {
|
|
19
|
+
|
|
20
|
+
return value === "lsw_first" ? "lsw_first" : "msw_first";
|
|
21
|
+
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
function normalizeMetricValue(value) {
|
|
26
|
+
|
|
27
|
+
const numeric = Number(value);
|
|
28
|
+
|
|
29
|
+
return Number.isFinite(numeric) ? numeric : null;
|
|
30
|
+
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
function wordsFromInt32(value) {
|
|
35
|
+
|
|
36
|
+
const buffer = Buffer.allocUnsafe(4);
|
|
37
|
+
|
|
38
|
+
buffer.writeInt32BE(value, 0);
|
|
39
|
+
|
|
40
|
+
return [buffer.readUInt16BE(0), buffer.readUInt16BE(2)];
|
|
41
|
+
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
function wordsFromUInt32(value) {
|
|
46
|
+
|
|
47
|
+
const buffer = Buffer.allocUnsafe(4);
|
|
48
|
+
|
|
49
|
+
buffer.writeUInt32BE(value, 0);
|
|
50
|
+
|
|
51
|
+
return [buffer.readUInt16BE(0), buffer.readUInt16BE(2)];
|
|
52
|
+
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
function wordsFromFloat32(value) {
|
|
57
|
+
|
|
58
|
+
const buffer = Buffer.allocUnsafe(4);
|
|
59
|
+
|
|
60
|
+
buffer.writeFloatBE(Number(value) || 0, 0);
|
|
61
|
+
|
|
62
|
+
return [buffer.readUInt16BE(0), buffer.readUInt16BE(2)];
|
|
63
|
+
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
function wordsFromInt16(value) {
|
|
68
|
+
|
|
69
|
+
const buffer = Buffer.allocUnsafe(2);
|
|
70
|
+
|
|
71
|
+
buffer.writeInt16BE(value, 0);
|
|
72
|
+
|
|
73
|
+
return [buffer.readUInt16BE(0)];
|
|
74
|
+
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
function wordsFromUInt16(value) {
|
|
79
|
+
|
|
80
|
+
const buffer = Buffer.allocUnsafe(2);
|
|
81
|
+
|
|
82
|
+
buffer.writeUInt16BE(value, 0);
|
|
83
|
+
|
|
84
|
+
return [buffer.readUInt16BE(0)];
|
|
85
|
+
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
function integerRangeForType(type) {
|
|
90
|
+
|
|
91
|
+
switch (type) {
|
|
92
|
+
case "int16":
|
|
93
|
+
return { min: -32768, max: 32767 };
|
|
94
|
+
case "uint16":
|
|
95
|
+
return { min: 0, max: 65535 };
|
|
96
|
+
case "uint32":
|
|
97
|
+
return { min: 0, max: 0xFFFFFFFF };
|
|
98
|
+
case "int32":
|
|
99
|
+
return { min: -2147483648, max: 2147483647 };
|
|
100
|
+
default:
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
function applyWordOrder(words, wordOrder) {
|
|
108
|
+
|
|
109
|
+
if (words.length === 2 && normalizeWordOrder(wordOrder) === "lsw_first") {
|
|
110
|
+
return [words[1], words[0]];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return words.slice();
|
|
114
|
+
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
function applyByteOrder(words, byteOrder) {
|
|
119
|
+
|
|
120
|
+
if (normalizeByteOrder(byteOrder) !== "little_endian") {
|
|
121
|
+
return words.slice();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return words.map(function (word) {
|
|
125
|
+
return ((word & 0x00FF) << 8) | ((word & 0xFF00) >> 8);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
function applyRegisterOrder(words, options) {
|
|
132
|
+
|
|
133
|
+
return applyByteOrder(applyWordOrder(words, options && options.wordOrder), options && options.byteOrder);
|
|
134
|
+
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
function encodeMetricDetailed(value, type, scale, options) {
|
|
139
|
+
|
|
140
|
+
const normalized = normalizeMetricValue(value);
|
|
141
|
+
|
|
142
|
+
if (!Number.isFinite(normalized)) {
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
words: [],
|
|
146
|
+
issues: []
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
const scaled = normalized * Number(scale || 1);
|
|
153
|
+
const issues = [];
|
|
154
|
+
let words;
|
|
155
|
+
|
|
156
|
+
switch (type) {
|
|
157
|
+
|
|
158
|
+
case "float32":
|
|
159
|
+
|
|
160
|
+
words = wordsFromFloat32(scaled);
|
|
161
|
+
break;
|
|
162
|
+
|
|
163
|
+
case "int16":
|
|
164
|
+
|
|
165
|
+
words = wordsFromInt16(clamp(Math.round(scaled), -32768, 32767));
|
|
166
|
+
break;
|
|
167
|
+
|
|
168
|
+
case "uint16":
|
|
169
|
+
|
|
170
|
+
words = wordsFromUInt16(clamp(Math.round(scaled), 0, 65535));
|
|
171
|
+
break;
|
|
172
|
+
|
|
173
|
+
case "uint32":
|
|
174
|
+
|
|
175
|
+
words = wordsFromUInt32(clamp(Math.round(scaled), 0, 0xFFFFFFFF));
|
|
176
|
+
break;
|
|
177
|
+
|
|
178
|
+
case "int32":
|
|
179
|
+
|
|
180
|
+
default:
|
|
181
|
+
|
|
182
|
+
words = wordsFromInt32(clamp(Math.round(scaled), -2147483648, 2147483647));
|
|
183
|
+
break;
|
|
184
|
+
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const range = integerRangeForType(type);
|
|
188
|
+
if (range && (Math.round(scaled) < range.min || Math.round(scaled) > range.max)) {
|
|
189
|
+
issues.push({
|
|
190
|
+
code: "value_saturated",
|
|
191
|
+
type: type,
|
|
192
|
+
raw_value: normalized,
|
|
193
|
+
scaled_value: scaled,
|
|
194
|
+
min: range.min,
|
|
195
|
+
max: range.max
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
words: applyRegisterOrder(words, options || {}),
|
|
201
|
+
issues: issues
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
function encodeMetric(value, type, scale, options) {
|
|
208
|
+
|
|
209
|
+
return encodeMetricDetailed(value, type, scale, options).words;
|
|
210
|
+
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
function typeWordLength(type) {
|
|
215
|
+
|
|
216
|
+
return (type === "int16" || type === "uint16") ? 1 : 2;
|
|
217
|
+
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
module.exports = {
|
|
222
|
+
|
|
223
|
+
encodeMetric,
|
|
224
|
+
|
|
225
|
+
encodeMetricDetailed,
|
|
226
|
+
|
|
227
|
+
normalizeMetricValue,
|
|
228
|
+
|
|
229
|
+
normalizeByteOrder,
|
|
230
|
+
|
|
231
|
+
normalizeWordOrder,
|
|
232
|
+
|
|
233
|
+
typeWordLength
|
|
234
|
+
|
|
235
|
+
};
|