node-red-contrib-uos-nats 0.2.76 → 0.2.94
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/README.md +8 -8
- package/lib/package.json +3 -0
- package/lib/payloads.js +9 -0
- package/lib/subjects.js +3 -0
- package/nodes/datahub-input.html +201 -50
- package/nodes/datahub-input.js +23 -13
- package/nodes/datahub-output.js +49 -8
- package/nodes/datahub-write.html +154 -103
- package/nodes/datahub-write.js +0 -64
- package/nodes/uos-config.js +138 -33
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,12 +10,12 @@ Maintained by [IoTUeli](https://iotueli.ch). Source: [GitHub](https://github.com
|
|
|
10
10
|
|
|
11
11
|
## Nodes Overview
|
|
12
12
|
|
|
13
|
-
| Node |
|
|
14
|
-
|
|
15
|
-
| **u-OS Config** |
|
|
16
|
-
| **DataHub - Read** |
|
|
17
|
-
| **DataHub - Write** |
|
|
18
|
-
| **DataHub - Provider** |
|
|
13
|
+
| Node | Purpose |
|
|
14
|
+
|------|---------|
|
|
15
|
+
| **u-OS Config** | Central configuration for NATS connection and OAuth credentials. |
|
|
16
|
+
| **DataHub - Read** | Subscribe to variable changes from system providers (e.g. `u_os_adm`). |
|
|
17
|
+
| **DataHub - Write** | Send commands to change variables in other providers. |
|
|
18
|
+
| **DataHub - Provider** | Create your own provider to publish variables to the Data Hub. |
|
|
19
19
|
|
|
20
20
|
---
|
|
21
21
|
|
|
@@ -66,13 +66,13 @@ Import this flow to test reading and writing immediately:
|
|
|
66
66
|
### DataHub - Read
|
|
67
67
|
Reads values from existing providers (like `u_os_adm`).
|
|
68
68
|
- **Provider ID:** Name of the source provider.
|
|
69
|
-
- **Variables:** Enter `Key:ID` manually.
|
|
69
|
+
- **Variables:** Enter `Key:ID` manually or use the **Variables Table** to search and add variables.
|
|
70
70
|
- **Trigger:** "Event" (instant update) or "Poll" (interval).
|
|
71
71
|
|
|
72
72
|
### DataHub - Write
|
|
73
73
|
Changes values in other providers.
|
|
74
74
|
- **Input:** Send `msg.payload` with the new value.
|
|
75
|
-
- **Config:**
|
|
75
|
+
- **Config:** Select the **Provider ID**, click **Load Variables**, and choose a variable from the list.
|
|
76
76
|
|
|
77
77
|
### DataHub - Provider
|
|
78
78
|
Publishes your own data to the Data Hub.
|
package/lib/package.json
ADDED
package/lib/payloads.js
CHANGED
|
@@ -20,6 +20,8 @@ import { ReadVariablesQueryRequestT } from './fbs/weidmueller/ucontrol/hub/read-
|
|
|
20
20
|
import { ReadVariablesQueryRequest } from './fbs/weidmueller/ucontrol/hub/read-variables-query-request.js';
|
|
21
21
|
import { ReadProviderDefinitionQueryRequest } from './fbs/weidmueller/ucontrol/hub/read-provider-definition-query-request.js';
|
|
22
22
|
import { WriteVariablesCommandT } from './fbs/weidmueller/ucontrol/hub/write-variables-command.js';
|
|
23
|
+
import { StateChangedEvent } from './fbs/weidmueller/ucontrol/hub/state-changed-event.js';
|
|
24
|
+
import { State } from './fbs/weidmueller/ucontrol/hub/state.js';
|
|
23
25
|
|
|
24
26
|
const DEFAULT_QUALITY = 'GOOD';
|
|
25
27
|
export function buildProviderDefinitionEvent(defs) {
|
|
@@ -100,6 +102,13 @@ export function buildReadProviderDefinitionQuery() {
|
|
|
100
102
|
return builder.asUint8Array();
|
|
101
103
|
}
|
|
102
104
|
|
|
105
|
+
export function parseRegistryStateEvent(buffer) {
|
|
106
|
+
const bb = new flatbuffers.ByteBuffer(buffer);
|
|
107
|
+
const event = StateChangedEvent.getRootAsStateChangedEvent(bb);
|
|
108
|
+
// State is an enum: 1 = RUNNING
|
|
109
|
+
return event.state();
|
|
110
|
+
}
|
|
111
|
+
|
|
103
112
|
// Encode write variables command
|
|
104
113
|
export function encodeWriteVariablesCommand(variables) {
|
|
105
114
|
// variables: [{id: number, value: any}, ...]
|
package/lib/subjects.js
CHANGED
|
@@ -15,3 +15,6 @@ export function registryProviderQuery(providerId) {
|
|
|
15
15
|
export function readProviderDefinitionQuery(providerId) {
|
|
16
16
|
return `${VERSION_PREFIX}.${LOCATION_PREFIX}.${providerId}.def.qry.read`;
|
|
17
17
|
}
|
|
18
|
+
export function registryStateEvent() {
|
|
19
|
+
return `${VERSION_PREFIX}.${LOCATION_PREFIX}.registry.state.evt.changed`;
|
|
20
|
+
}
|
package/nodes/datahub-input.html
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
providerId: { value: "", required: true },
|
|
9
9
|
manualVariables: { value: "" },
|
|
10
10
|
triggerMode: { value: "event" },
|
|
11
|
-
pollingInterval: { value: 0, validate: RED.validators.number() }
|
|
11
|
+
pollingInterval: { value: 0, validate: RED.validators.number() },
|
|
12
|
+
pollingUnit: { value: "ms" }
|
|
12
13
|
},
|
|
13
14
|
inputs: 1,
|
|
14
15
|
outputs: 1,
|
|
@@ -21,9 +22,24 @@
|
|
|
21
22
|
oneditprepare: function () {
|
|
22
23
|
const node = this;
|
|
23
24
|
|
|
24
|
-
// --- Trigger Mode
|
|
25
|
+
// --- Trigger Mode & Polling Logic ---
|
|
25
26
|
const $triggerMode = $('#node-input-triggerMode');
|
|
26
27
|
const $pollRow = $('#poll-interval-row');
|
|
28
|
+
const $pollIntInput = $('#node-input-pollingInterval');
|
|
29
|
+
const $pollUnitInput = $('#node-input-pollingUnit');
|
|
30
|
+
|
|
31
|
+
// Convert stored ms to unit for display
|
|
32
|
+
let currentMs = node.pollingInterval || 0;
|
|
33
|
+
let uiVal = currentMs;
|
|
34
|
+
let uiUnit = node.pollingUnit || 'ms';
|
|
35
|
+
|
|
36
|
+
// Heuristic helper if unit was missing (legacy support)
|
|
37
|
+
if (!node.pollingUnit && currentMs > 0) {
|
|
38
|
+
if (currentMs >= 60000 && currentMs % 60000 === 0) { uiVal = currentMs / 60000; uiUnit = 'min'; }
|
|
39
|
+
else if (currentMs >= 1000 && currentMs % 1000 === 0) { uiVal = currentMs / 1000; uiUnit = 's'; }
|
|
40
|
+
}
|
|
41
|
+
$pollUnitInput.val(uiUnit);
|
|
42
|
+
$pollIntInput.val(uiVal);
|
|
27
43
|
|
|
28
44
|
const updateTriggerVisibility = () => {
|
|
29
45
|
if ($triggerMode.val() === 'poll') {
|
|
@@ -36,52 +52,166 @@
|
|
|
36
52
|
$triggerMode.on('change', updateTriggerVisibility);
|
|
37
53
|
updateTriggerVisibility();
|
|
38
54
|
|
|
39
|
-
// ---
|
|
40
|
-
const $
|
|
41
|
-
const $
|
|
42
|
-
|
|
43
|
-
$
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
)
|
|
59
|
-
|
|
55
|
+
// --- Variable Selector Logic ---
|
|
56
|
+
const $fetchBtn = $('#btn-fetch-vars');
|
|
57
|
+
const $listContainer = $('#variable-list-container');
|
|
58
|
+
const $statusMsg = $('#fetch-status');
|
|
59
|
+
const $searchInput = $('#node-input-var-search');
|
|
60
|
+
const $selectAllBtn = $('#btn-select-all');
|
|
61
|
+
const $clearAllBtn = $('#btn-clear-all');
|
|
62
|
+
const $hiddenManual = $('#node-input-manualVariables');
|
|
63
|
+
|
|
64
|
+
// Styles
|
|
65
|
+
const rowStyle = "display:grid; grid-template-columns: 30px 60px 1fr; align-items:center; padding:4px 0; border-bottom:1px solid #eee; font-family:'Helvetica Neue', Arial, sans-serif; font-size:12px;";
|
|
66
|
+
const idStyle = "background:#eee; color:#555; padding:1px 4px; border-radius:3px; font-family:monospace; text-align:center; font-size:11px;";
|
|
67
|
+
const keyStyle = "overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding-left:10px;";
|
|
68
|
+
|
|
69
|
+
let currentVariables = []; // Stores {id, key, accessType, etc.}
|
|
70
|
+
|
|
71
|
+
// Parse existing selection
|
|
72
|
+
const getSelectedMap = () => {
|
|
73
|
+
const selected = new Map(); // Key -> ID
|
|
74
|
+
const raw = $hiddenManual.val();
|
|
75
|
+
if (raw) {
|
|
76
|
+
raw.split(',').forEach(entry => {
|
|
77
|
+
const parts = entry.split(':');
|
|
78
|
+
if (parts.length === 2) {
|
|
79
|
+
selected.set(parts[0].trim(), parts[1].trim());
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return selected;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const renderList = (vars) => {
|
|
87
|
+
$listContainer.empty();
|
|
88
|
+
if (!vars || vars.length === 0) {
|
|
89
|
+
$listContainer.append('<div style="padding:15px; color:#777; text-align:center;">No variables found.</div>');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const selectedMap = getSelectedMap();
|
|
94
|
+
|
|
95
|
+
vars.forEach(v => {
|
|
96
|
+
// Robust ID handling: verify v.id exists (try 'id' and 'Id')
|
|
97
|
+
let rawId = (v.id !== undefined && v.id !== null) ? v.id : v.Id;
|
|
98
|
+
const safeId = (rawId !== undefined && rawId !== null) ? rawId : 'ERR';
|
|
99
|
+
const isSelected = selectedMap.has(v.key);
|
|
100
|
+
|
|
101
|
+
const row = $('<div>', { class: 'var-row', style: rowStyle });
|
|
102
|
+
|
|
103
|
+
const cbContainer = $('<div>', { style: 'text-align:center;' });
|
|
104
|
+
const cb = $('<input type="checkbox" class="var-checkbox">')
|
|
105
|
+
.prop('checked', isSelected)
|
|
106
|
+
.data('key', v.key)
|
|
107
|
+
.data('id', safeId);
|
|
108
|
+
cbContainer.append(cb);
|
|
109
|
+
|
|
110
|
+
const idBadge = $('<div>').append($('<span>', { style: idStyle }).text(safeId));
|
|
111
|
+
const label = $('<div>', { style: keyStyle, title: v.key }).text(v.key);
|
|
112
|
+
|
|
113
|
+
row.append(cbContainer).append(idBadge).append(label);
|
|
114
|
+
$listContainer.append(row);
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const filterList = (term) => {
|
|
119
|
+
const rows = $listContainer.find('.var-row');
|
|
120
|
+
term = term.toLowerCase();
|
|
121
|
+
rows.each(function () {
|
|
122
|
+
const text = $(this).find('div:nth-child(3)').text().toLowerCase(); // 3rd child is name
|
|
123
|
+
$(this).toggle(text.indexOf(term) > -1);
|
|
124
|
+
});
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
$searchInput.on('keyup', function () {
|
|
128
|
+
filterList($(this).val());
|
|
60
129
|
});
|
|
61
130
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
131
|
+
$selectAllBtn.on('click', function () {
|
|
132
|
+
$listContainer.find('.var-checkbox:visible').prop('checked', true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
$clearAllBtn.on('click', function () {
|
|
136
|
+
$listContainer.find('.var-checkbox:visible').prop('checked', false);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
$fetchBtn.on('click', function () {
|
|
140
|
+
const configNodeId = $('#node-input-connection').val();
|
|
141
|
+
const providerId = $('#node-input-providerId').val();
|
|
142
|
+
|
|
143
|
+
if (!configNodeId || configNodeId === '_ADD_') {
|
|
144
|
+
$statusMsg.text('Select Config Node!').css('color', 'red');
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (!providerId) {
|
|
148
|
+
$statusMsg.text('Enter Provider ID!').css('color', 'red');
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
$fetchBtn.prop('disabled', true);
|
|
153
|
+
$statusMsg.text('Fetching...').css('color', 'blue');
|
|
154
|
+
$listContainer.html('<div style="padding:20px; text-align:center;"><i class="fa fa-spinner fa-spin"></i> Loading...</div>');
|
|
155
|
+
|
|
156
|
+
$.getJSON('uos/providers/' + configNodeId + '/' + providerId + '/variables', function (data) {
|
|
157
|
+
$fetchBtn.prop('disabled', false);
|
|
158
|
+
$statusMsg.text('');
|
|
159
|
+
// Sort by ID (handle 'id' or 'Id')
|
|
160
|
+
currentVariables = data.sort((a, b) => {
|
|
161
|
+
const idA = (a.id !== undefined) ? a.id : a.Id;
|
|
162
|
+
const idB = (b.id !== undefined) ? b.id : b.Id;
|
|
163
|
+
return parseInt(idA) - parseInt(idB);
|
|
164
|
+
});
|
|
165
|
+
renderList(currentVariables);
|
|
166
|
+
$statusMsg.text(`Loaded ${data.length} variables.`).css('color', 'green');
|
|
167
|
+
}).fail(function (jqxhr) {
|
|
168
|
+
$fetchBtn.prop('disabled', false);
|
|
169
|
+
$statusMsg.text('Error: ' + (jqxhr.responseJSON?.error || 'Unknown')).css('color', 'red');
|
|
170
|
+
$listContainer.empty();
|
|
71
171
|
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Initial Render
|
|
175
|
+
const initialMap = getSelectedMap();
|
|
176
|
+
if (initialMap.size > 0 && currentVariables.length === 0) {
|
|
177
|
+
const dummyVars = [];
|
|
178
|
+
initialMap.forEach((id, key) => dummyVars.push({ id, key }));
|
|
179
|
+
// Just in case stored ID is undefined, show it so user can see it's broken
|
|
180
|
+
renderList(dummyVars);
|
|
181
|
+
$statusMsg.text('Cached variables shown. Load again to refresh.').css('color', '#888');
|
|
182
|
+
} else {
|
|
183
|
+
$listContainer.html('<div style="padding:20px; text-align:center; color:#999;">Start by clicking <b>Load Variables</b>.<br><br>Empty Selection = <b>Read ALL</b></div>');
|
|
72
184
|
}
|
|
73
185
|
},
|
|
74
186
|
oneditsave: function () {
|
|
75
|
-
|
|
187
|
+
// 1. Save Variables
|
|
76
188
|
const items = [];
|
|
77
|
-
$
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
189
|
+
$('#variable-list-container').find('.var-checkbox').each(function () {
|
|
190
|
+
if ($(this).prop('checked')) {
|
|
191
|
+
const k = $(this).data('key');
|
|
192
|
+
const i = $(this).data('id');
|
|
193
|
+
// Important: Don't save if ID is 'ERR' or undefined
|
|
194
|
+
if (k && i !== undefined && i !== 'ERR') {
|
|
195
|
+
items.push(`${k}:${i}`);
|
|
196
|
+
}
|
|
82
197
|
}
|
|
83
198
|
});
|
|
84
199
|
$('#node-input-manualVariables').val(items.join(','));
|
|
200
|
+
|
|
201
|
+
// 2. Save Polling Interval (calculate ms)
|
|
202
|
+
const val = parseInt($('#node-input-pollingInterval').val(), 10) || 0;
|
|
203
|
+
const unit = $('#node-input-pollingUnit').val();
|
|
204
|
+
let multiplier = 1;
|
|
205
|
+
if (unit === 's') multiplier = 1000;
|
|
206
|
+
if (unit === 'min') multiplier = 60000;
|
|
207
|
+
|
|
208
|
+
// We overwrite the raw value with calculated MS.
|
|
209
|
+
// WAIT! The 'defaults' define pollingInterval. If we overwrite it here, the UI input needs to read it back correctly in oneditprepare.
|
|
210
|
+
// Actually, standard pattern is to store the MS value in 'pollingInterval' and recover the Unit in UI.
|
|
211
|
+
// But my oneditprepare logic for unit recovery was heuristic. Let's make it robust by trusting the calculation.
|
|
212
|
+
const totalMs = val * multiplier;
|
|
213
|
+
$('#node-input-pollingInterval').val(totalMs);
|
|
214
|
+
$('#node-input-pollingUnit').val(unit); // Also save the unit
|
|
85
215
|
}
|
|
86
216
|
});
|
|
87
217
|
</script>
|
|
@@ -99,34 +229,55 @@
|
|
|
99
229
|
|
|
100
230
|
<div class="form-row">
|
|
101
231
|
<label for="node-input-providerId"><i class="fa fa-server"></i> Provider ID</label>
|
|
102
|
-
<
|
|
232
|
+
<div style="display:flex; gap:5px;">
|
|
233
|
+
<input type="text" id="node-input-providerId" placeholder="e.g. u_os_adm" style="flex:1;">
|
|
234
|
+
<button id="btn-fetch-vars" class="red-ui-button"><i class="fa fa-refresh"></i> Load Variables</button>
|
|
235
|
+
</div>
|
|
236
|
+
<div id="fetch-status" style="margin-top:5px; font-size:0.9em; min-height:1.2em;"></div>
|
|
103
237
|
</div>
|
|
104
238
|
|
|
105
|
-
<div class="form-row">
|
|
106
|
-
<
|
|
107
|
-
|
|
108
|
-
<
|
|
239
|
+
<div class="form-row" style="border:1px solid #ccc; padding:0; border-radius:4px; background:#fff;">
|
|
240
|
+
<div style="background:#f7f7f7; padding:8px 10px; border-bottom:1px solid #ddd; display:flex; justify-content:space-between; align-items:center;">
|
|
241
|
+
<span style="font-weight:bold; font-size:12px;"><i class="fa fa-list-ul"></i> Selection</span>
|
|
242
|
+
<input type="text" id="node-input-var-search" placeholder="Filter..." style="width:120px; font-size:11px; padding:2px;">
|
|
243
|
+
</div>
|
|
244
|
+
|
|
245
|
+
<div style="display:grid; grid-template-columns: 30px 60px 1fr; background:#eee; font-size:11px; font-weight:bold; padding:4px 0; border-bottom:1px solid #ccc;">
|
|
246
|
+
<div style="text-align:center;"><i class="fa fa-check-square-o"></i></div>
|
|
247
|
+
<div style="text-align:center;">ID</div>
|
|
248
|
+
<div style="padding-left:10px;">Name</div>
|
|
249
|
+
</div>
|
|
250
|
+
|
|
251
|
+
<div id="variable-list-container" style="height:250px; overflow-y:auto; background:white;">
|
|
252
|
+
<!-- Variables -->
|
|
109
253
|
</div>
|
|
110
|
-
<input type="hidden" id="node-input-manualVariables">
|
|
111
|
-
</div>
|
|
112
254
|
|
|
113
|
-
|
|
114
|
-
|
|
255
|
+
<div style="background:#f7f7f7; padding:8px 10px; border-top:1px solid #ddd; display:flex; gap:10px;">
|
|
256
|
+
<button id="btn-select-all" class="red-ui-button red-ui-button-small">Select All</button>
|
|
257
|
+
<button id="btn-clear-all" class="red-ui-button red-ui-button-small">Clear All</button>
|
|
258
|
+
<div style="flex:1; text-align:right; color:#888; font-size:11px; padding-top:4px;">Empty = Read ALL</div>
|
|
259
|
+
</div>
|
|
260
|
+
<input type="hidden" id="node-input-manualVariables">
|
|
115
261
|
</div>
|
|
116
262
|
|
|
117
263
|
<hr style="margin:15px 0;">
|
|
118
264
|
|
|
119
265
|
<div class="form-row">
|
|
120
266
|
<label for="node-input-triggerMode"><i class="fa fa-bolt"></i> Trigger</label>
|
|
121
|
-
<select id="node-input-triggerMode">
|
|
267
|
+
<select id="node-input-triggerMode" style="width:70%;">
|
|
122
268
|
<option value="event">Event (on change)</option>
|
|
123
269
|
<option value="poll">Poll (interval)</option>
|
|
124
270
|
</select>
|
|
125
271
|
</div>
|
|
126
272
|
|
|
127
273
|
<div id="poll-interval-row" class="form-row" style="display:none;">
|
|
128
|
-
<label for="node-input-pollingInterval"><i class="fa fa-clock-o"></i> Interval
|
|
129
|
-
<input type="number" id="node-input-pollingInterval" placeholder="
|
|
274
|
+
<label for="node-input-pollingInterval"><i class="fa fa-clock-o"></i> Interval</label>
|
|
275
|
+
<input type="number" id="node-input-pollingInterval" style="width:80px;" placeholder="1000">
|
|
276
|
+
<select id="node-input-pollingUnit" style="width:80px;">
|
|
277
|
+
<option value="ms">ms</option>
|
|
278
|
+
<option value="s">sec</option>
|
|
279
|
+
<option value="min">min</option>
|
|
280
|
+
</select>
|
|
130
281
|
</div>
|
|
131
282
|
</script>
|
|
132
283
|
|
package/nodes/datahub-input.js
CHANGED
|
@@ -51,6 +51,11 @@ module.exports = function (RED) {
|
|
|
51
51
|
});
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
// Warn if manual config existed but resulted in no valid definitions (e.g. corruption or NaN IDs)
|
|
55
|
+
if (manualText.length > 0 && this.manualDefs.length === 0) {
|
|
56
|
+
this.warn("Configuration Warning: 'Selected Variables' contained data but no valid IDs could be parsed. Falling back to 'Read All'. Please re-select variables in the editor.");
|
|
57
|
+
}
|
|
58
|
+
|
|
54
59
|
let nc;
|
|
55
60
|
let sub;
|
|
56
61
|
let closed = false;
|
|
@@ -177,33 +182,38 @@ module.exports = function (RED) {
|
|
|
177
182
|
}
|
|
178
183
|
|
|
179
184
|
try {
|
|
185
|
+
// Resolve requested variable names to IDs
|
|
180
186
|
// Resolve requested variable names to IDs
|
|
181
187
|
let targetIds = [];
|
|
182
|
-
|
|
188
|
+
let isWildcard = (this.variables.length === 0);
|
|
189
|
+
|
|
190
|
+
if (!isWildcard) {
|
|
183
191
|
// Reverse lookup: Find Def by Key
|
|
184
192
|
const requestedKeys = new Set(this.variables);
|
|
185
193
|
|
|
186
|
-
// Debug Map Content before filtering
|
|
187
|
-
// if (defMap.size > 0 && providerRequestCount < 2) {
|
|
188
|
-
// this.warn(`DefMap Dump (Size ${defMap.size}): ${Array.from(defMap.values()).map(d=>d.key).slice(0,5).join(', ')} ...`);
|
|
189
|
-
// }
|
|
190
|
-
|
|
191
194
|
for (const def of defMap.values()) {
|
|
192
195
|
if (requestedKeys.has(def.key)) {
|
|
193
196
|
targetIds.push(Number(def.id));
|
|
194
197
|
}
|
|
195
198
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if (
|
|
201
|
-
this.warn(
|
|
199
|
+
|
|
200
|
+
// CRITICAL FIX: If user requested specific variables but we found NONE,
|
|
201
|
+
// we MUST NOT send an empty list, because that would interpret as "Read All".
|
|
202
|
+
if (targetIds.length === 0) {
|
|
203
|
+
if (defMap.size > 0) {
|
|
204
|
+
this.warn(`Snapshot Aborted: None of the ${this.variables.length} requested variables could be resolved to IDs. (Provider has ${defMap.size} vars).`);
|
|
205
|
+
} else {
|
|
206
|
+
// If defMap is empty (Discovery failed), we might want to try reading ALL to see if we get lucky?
|
|
207
|
+
// Or just rely on Manual Defs fallback which would have populated defMap.
|
|
208
|
+
// Safe default: Abort to avoid flooding if discovery failed.
|
|
209
|
+
this.warn('Snapshot Aborted: Provider Definition not ready (no IDs resolved).');
|
|
202
210
|
}
|
|
211
|
+
return;
|
|
203
212
|
}
|
|
204
213
|
}
|
|
205
214
|
|
|
206
|
-
// If
|
|
215
|
+
// If Wildcard (empty targetIds and isWildcard=true) -> Request ALL.
|
|
216
|
+
// If Specific (targetIds has items) -> Request specific.
|
|
207
217
|
const snapshotMsg = await nc.request(subjects.readVariablesQuery(this.providerId), payloads.buildReadVariablesQuery(targetIds), { timeout: 2000 });
|
|
208
218
|
|
|
209
219
|
const bb = new flatbuffers.ByteBuffer(snapshotMsg.data);
|
package/nodes/datahub-output.js
CHANGED
|
@@ -109,12 +109,22 @@ module.exports = function (RED) {
|
|
|
109
109
|
return { def, created: true };
|
|
110
110
|
};
|
|
111
111
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
112
|
+
// --- SEND DEFINITION HELPER ---
|
|
113
|
+
const sendDefinitionUpdate = async (modPayloads, modSubjects) => {
|
|
114
|
+
if (!nc) return;
|
|
115
|
+
try {
|
|
116
|
+
const { payload, fingerprint: fp } = modPayloads.buildProviderDefinitionEvent(definitions);
|
|
117
|
+
const subject = modSubjects.providerDefinitionChanged(this.providerId);
|
|
118
|
+
|
|
119
|
+
console.log(`[DataHub Output] Publishing definition for Provider '${this.providerId}' to '${subject}'`);
|
|
120
|
+
|
|
121
|
+
fingerprint = fp;
|
|
122
|
+
await nc.publish(subject, payload);
|
|
123
|
+
await nc.flush();
|
|
124
|
+
console.log(`[DataHub Output] Definition published. FP: ${fp}`);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
this.warn(`Definition update error: ${err.message}`);
|
|
127
|
+
}
|
|
118
128
|
};
|
|
119
129
|
|
|
120
130
|
const handleRead = async (payloads, msg) => {
|
|
@@ -137,7 +147,19 @@ module.exports = function (RED) {
|
|
|
137
147
|
const sendValuesUpdate = async () => {
|
|
138
148
|
if (!nc || nc.isClosed()) {
|
|
139
149
|
console.log('[DataHub Output] Heartbeat skipped: NATS closed or missing.');
|
|
140
|
-
|
|
150
|
+
// Add event listeners for connection status changes
|
|
151
|
+
// Note: `connection` is defined in the outer scope, not `this.connection`
|
|
152
|
+
connection.on('reconnected', () => {
|
|
153
|
+
console.log('[DataHub Output] Reconnected. Re-publishing definition...');
|
|
154
|
+
this.status({ fill: 'green', shape: 'ring', text: 'reconnected' });
|
|
155
|
+
start().catch((err) => {
|
|
156
|
+
this.warn(`Re-registration failed: ${err.message}`);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
connection.on('disconnected', () => {
|
|
160
|
+
this.status({ fill: 'red', shape: 'ring', text: 'disconnected' });
|
|
161
|
+
});
|
|
162
|
+
return; // Skip sending values if NATS is closed
|
|
141
163
|
}
|
|
142
164
|
if (!loadedPayloads || !loadedSubjects) return;
|
|
143
165
|
|
|
@@ -187,9 +209,28 @@ module.exports = function (RED) {
|
|
|
187
209
|
nc = await connection.acquire();
|
|
188
210
|
console.log('[DataHub Output] NATS acquired.');
|
|
189
211
|
|
|
190
|
-
// Only publish definition if we have one. Empty definitions might be rejected?
|
|
191
212
|
if (definitions.length > 0) {
|
|
213
|
+
// Initial publish
|
|
192
214
|
await sendDefinitionUpdate(payloads, subjects);
|
|
215
|
+
|
|
216
|
+
// Subscribe to Registry State changes
|
|
217
|
+
// The registry publishes its state (RUNNING=1) when it comes online.
|
|
218
|
+
// Providers MUST re-publish their definition when this happens.
|
|
219
|
+
nc.subscribe(subjects.registryStateEvent(), {
|
|
220
|
+
callback: (err, msg) => {
|
|
221
|
+
if (err) return;
|
|
222
|
+
try {
|
|
223
|
+
const state = payloads.parseRegistryStateEvent(msg.data);
|
|
224
|
+
if (state === 1) { // 1 = RUNNING
|
|
225
|
+
console.log('[DataHub Output] Registry is RUNNING. Re-publishing definition...');
|
|
226
|
+
sendDefinitionUpdate(payloads, subjects);
|
|
227
|
+
}
|
|
228
|
+
} catch (e) {
|
|
229
|
+
console.warn('[DataHub Output] Registry state parse error:', e);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
console.log('[DataHub Output] Subscribed to Registry State.');
|
|
193
234
|
}
|
|
194
235
|
|
|
195
236
|
// Listen for Variable READ requests
|
package/nodes/datahub-write.html
CHANGED
|
@@ -17,118 +17,154 @@
|
|
|
17
17
|
},
|
|
18
18
|
paletteLabel: "DataHub - Write",
|
|
19
19
|
oneditprepare: function () {
|
|
20
|
-
|
|
21
|
-
$('#
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
20
|
+
const node = this;
|
|
21
|
+
const $fetchBtn = $('#btn-fetch-vars');
|
|
22
|
+
const $listContainer = $('#variable-list-container');
|
|
23
|
+
const $statusMsg = $('#fetch-status');
|
|
24
|
+
const $searchInput = $('#node-input-var-search');
|
|
25
|
+
const $selectionDisplay = $('#selected-variable-display');
|
|
26
|
+
|
|
27
|
+
// Raw Inputs (Hidden)
|
|
28
|
+
const $inputId = $('#node-input-variableId');
|
|
29
|
+
const $inputKey = $('#node-input-variableKey');
|
|
30
|
+
|
|
31
|
+
// Styles matching Read Node
|
|
32
|
+
const rowStyle = "display:grid; grid-template-columns: 60px 1fr 80px 80px; align-items:center; padding:4px 0; border-bottom:1px solid #eee; font-family:'Helvetica Neue', Arial, sans-serif; font-size:12px; cursor:pointer;";
|
|
33
|
+
const idStyle = "background:#eee; color:#555; padding:1px 4px; border-radius:3px; font-family:monospace; text-align:center; font-size:11px;";
|
|
34
|
+
const keyStyle = "overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding-left:10px;";
|
|
35
|
+
|
|
36
|
+
let currentVariables = [];
|
|
37
|
+
|
|
38
|
+
// Helper to update display
|
|
39
|
+
const updateSelectionDisplay = (id, key) => {
|
|
40
|
+
if (id || key) {
|
|
41
|
+
$selectionDisplay.html(`<b>${key || '?'}</b> <span style="color:#666; font-family:monospace; margin-left:10px;">(ID: ${id || '?'})</span>`);
|
|
42
|
+
} else {
|
|
43
|
+
$selectionDisplay.html('<span style="color:#888; font-style:italic;">None selected</span>');
|
|
28
44
|
}
|
|
29
|
-
|
|
30
|
-
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Render List Function
|
|
48
|
+
const renderList = (vars) => {
|
|
49
|
+
$listContainer.empty();
|
|
50
|
+
if (!vars || vars.length === 0) {
|
|
51
|
+
$listContainer.html('<div style="padding:15px; color:#777; text-align:center;">No variables found.</div>');
|
|
31
52
|
return;
|
|
32
53
|
}
|
|
33
54
|
|
|
34
|
-
|
|
55
|
+
vars.forEach(v => {
|
|
56
|
+
const safeId = (v.id !== undefined) ? v.id : (v.Id !== undefined ? v.Id : 'ERR');
|
|
57
|
+
const safeType = v.dataType || v.type || '-';
|
|
58
|
+
const safeAccess = v.access || v.accessType || '-';
|
|
35
59
|
|
|
36
|
-
|
|
37
|
-
notification.close();
|
|
60
|
+
const row = $('<div>', { class: 'var-row', style: rowStyle });
|
|
38
61
|
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
62
|
+
// ID
|
|
63
|
+
const idCol = $('<div>', { style: 'text-align:center;' }).append($('<span>', { style: idStyle }).text(safeId));
|
|
64
|
+
// Key
|
|
65
|
+
const keyCol = $('<div>', { style: keyStyle, title: v.key }).text(v.key);
|
|
66
|
+
// Type
|
|
67
|
+
const typeCol = $('<div>', { style: 'font-size:10px; color:#666; padding-left:5px;' }).text(safeType);
|
|
68
|
+
// Access
|
|
69
|
+
const accessCol = $('<div>', { style: 'font-size:10px; color:#666; padding-left:5px;' }).text(safeAccess);
|
|
42
70
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
71
|
+
row.append(idCol).append(keyCol).append(typeCol).append(accessCol);
|
|
72
|
+
|
|
73
|
+
// Click Handler - Fill Inputs
|
|
74
|
+
row.on('click', function () {
|
|
75
|
+
$('.var-row').css('background', 'transparent');
|
|
76
|
+
$(this).css('background', '#dcefff'); // Highlight selection
|
|
77
|
+
|
|
78
|
+
$inputId.val(safeId !== 'ERR' ? safeId : '');
|
|
79
|
+
$inputKey.val(v.key);
|
|
51
80
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
<table class="table table-hover" style="width:100%">
|
|
57
|
-
<thead>
|
|
58
|
-
<tr>
|
|
59
|
-
<th>ID</th>
|
|
60
|
-
<th>Key</th>
|
|
61
|
-
<th>Type</th>
|
|
62
|
-
<th>Access</th>
|
|
63
|
-
</tr>
|
|
64
|
-
</thead>
|
|
65
|
-
<tbody id="node-lookup-var-list">
|
|
66
|
-
${rows}
|
|
67
|
-
</tbody>
|
|
68
|
-
</table>
|
|
69
|
-
</div>
|
|
70
|
-
</div>
|
|
71
|
-
`;
|
|
72
|
-
|
|
73
|
-
// Show Dialog (using jquery-ui which Node-RED has)
|
|
74
|
-
const $dialog = $(dialogHtml).appendTo("body");
|
|
75
|
-
|
|
76
|
-
// Filter Logic
|
|
77
|
-
$('#node-lookup-var-search').on('keyup', function () {
|
|
78
|
-
const val = $(this).val().toLowerCase();
|
|
79
|
-
$("#node-lookup-var-list tr").filter(function () {
|
|
80
|
-
$(this).toggle($(this).text().toLowerCase().indexOf(val) > -1)
|
|
81
|
-
});
|
|
81
|
+
updateSelectionDisplay(safeId, v.key);
|
|
82
|
+
|
|
83
|
+
// Trigger validation visual update if needed
|
|
84
|
+
$inputId.trigger('change');
|
|
82
85
|
});
|
|
83
86
|
|
|
84
|
-
//
|
|
85
|
-
$('
|
|
86
|
-
|
|
87
|
-
|
|
87
|
+
// Hover effect
|
|
88
|
+
row.on('mouseenter', function () { if ($(this).css('background-color') !== 'rgb(220, 239, 255)') $(this).css('background', '#f7f7f7'); });
|
|
89
|
+
row.on('mouseleave', function () { if ($(this).css('background-color') !== 'rgb(220, 239, 255)') $(this).css('background', 'transparent'); });
|
|
90
|
+
|
|
91
|
+
$listContainer.append(row);
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Filter Logic
|
|
96
|
+
$searchInput.on('keyup', function () {
|
|
97
|
+
const term = $(this).val().toLowerCase();
|
|
98
|
+
$listContainer.find('.var-row').each(function () {
|
|
99
|
+
const text = $(this).find('div:nth-child(2)').text().toLowerCase(); // Key is 2nd column
|
|
100
|
+
$(this).toggle(text.indexOf(term) > -1);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
88
103
|
|
|
89
|
-
|
|
90
|
-
|
|
104
|
+
// Fetch Handler
|
|
105
|
+
$fetchBtn.on('click', function () {
|
|
106
|
+
const configNodeId = $('#node-input-connection').val();
|
|
107
|
+
const providerId = $('#node-input-providerId').val();
|
|
91
108
|
|
|
92
|
-
|
|
93
|
-
|
|
109
|
+
if (!configNodeId || configNodeId === '_ADD_') {
|
|
110
|
+
$statusMsg.text('Select Config Node!').css('color', 'red');
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (!providerId) {
|
|
114
|
+
$statusMsg.text('Enter Provider ID!').css('color', 'red');
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
$fetchBtn.prop('disabled', true);
|
|
119
|
+
$statusMsg.text('Fetching...').css('color', 'blue');
|
|
120
|
+
$listContainer.html('<div style="padding:20px; text-align:center;"><i class="fa fa-spinner fa-spin"></i> Loading...</div>');
|
|
121
|
+
|
|
122
|
+
$.getJSON('uos/providers/' + configNodeId + '/' + providerId + '/variables', function (data) {
|
|
123
|
+
$fetchBtn.prop('disabled', false);
|
|
124
|
+
$statusMsg.text('');
|
|
125
|
+
|
|
126
|
+
// Filter: Only writable variables (User Request)
|
|
127
|
+
// access strings come from lib/payloads.js: 'READWRITE' or 'READ_WRITE' or checking accessType
|
|
128
|
+
const writableVars = data.filter(v => {
|
|
129
|
+
const acc = (v.access || v.accessType || '').toUpperCase();
|
|
130
|
+
return acc.includes('WRITE');
|
|
94
131
|
});
|
|
95
132
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
buttons: {
|
|
102
|
-
"Cancel": function () {
|
|
103
|
-
$(this).dialog("close");
|
|
104
|
-
$(this).remove();
|
|
105
|
-
}
|
|
106
|
-
}
|
|
133
|
+
// Sort: ID ascending (User Request: "tiefste Zahl zuoberst")
|
|
134
|
+
currentVariables = writableVars.sort((a, b) => {
|
|
135
|
+
const idA = (a.id !== undefined) ? parseInt(a.id) : (a.Id !== undefined ? parseInt(a.Id) : Infinity);
|
|
136
|
+
const idB = (b.id !== undefined) ? parseInt(b.id) : (b.Id !== undefined ? parseInt(b.Id) : Infinity);
|
|
137
|
+
return idA - idB;
|
|
107
138
|
});
|
|
108
139
|
|
|
140
|
+
renderList(currentVariables);
|
|
141
|
+
$statusMsg.text(`Loaded ${currentVariables.length} writable variables.`).css('color', 'green');
|
|
109
142
|
}).fail(function (jqxhr) {
|
|
110
|
-
|
|
111
|
-
|
|
143
|
+
$fetchBtn.prop('disabled', false);
|
|
144
|
+
$statusMsg.text('Error: ' + (jqxhr.responseJSON?.error || 'Unknown')).css('color', 'red');
|
|
145
|
+
$listContainer.html('<div style="padding:15px; color:#c00; text-align:center;">Failed to load variables.</div>');
|
|
112
146
|
});
|
|
113
147
|
});
|
|
114
148
|
|
|
115
|
-
//
|
|
149
|
+
// Initial state
|
|
150
|
+
$listContainer.html('<div style="padding:20px; text-align:center; color:#999;">Click <b>Load Variables</b> to browse.</div>');
|
|
151
|
+
|
|
152
|
+
// Set initial display
|
|
153
|
+
updateSelectionDisplay(node.variableId, node.variableKey);
|
|
154
|
+
|
|
116
155
|
const validateVar = () => {
|
|
117
|
-
const hasId = $
|
|
118
|
-
const hasKey = $
|
|
156
|
+
const hasId = $inputId.val();
|
|
157
|
+
const hasKey = $inputKey.val();
|
|
119
158
|
if (!hasId && !hasKey) {
|
|
120
159
|
$('#var-validation-error').show();
|
|
121
|
-
return false;
|
|
122
160
|
} else {
|
|
123
161
|
$('#var-validation-error').hide();
|
|
124
|
-
return true;
|
|
125
162
|
}
|
|
126
163
|
};
|
|
127
|
-
|
|
128
|
-
$('#node-input-variableId, #node-input-variableKey').on('change keyup', validateVar);
|
|
164
|
+
$inputId.on('change keyup', validateVar);
|
|
129
165
|
},
|
|
130
166
|
oneditsave: function () {
|
|
131
|
-
//
|
|
167
|
+
// Nothing special to save, inputs are handled automatically
|
|
132
168
|
}
|
|
133
169
|
});
|
|
134
170
|
</script>
|
|
@@ -146,32 +182,47 @@
|
|
|
146
182
|
|
|
147
183
|
<div class="form-row">
|
|
148
184
|
<label for="node-input-providerId"><i class="fa fa-server"></i> Provider ID</label>
|
|
149
|
-
<
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
<
|
|
154
|
-
<input type="number" id="node-input-variableId" placeholder="e.g. 5 (optional)">
|
|
185
|
+
<div style="display:flex; gap:5px;">
|
|
186
|
+
<input type="text" id="node-input-providerId" placeholder="e.g. u_os_adm" style="flex:1;">
|
|
187
|
+
<button id="btn-fetch-vars" class="red-ui-button"><i class="fa fa-refresh"></i> Load Variables</button>
|
|
188
|
+
</div>
|
|
189
|
+
<div id="fetch-status" style="margin-top:5px; font-size:0.9em; min-height:1.2em;"></div>
|
|
155
190
|
</div>
|
|
156
191
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
<div style="
|
|
160
|
-
<
|
|
161
|
-
<
|
|
192
|
+
<!-- Variable List Section -->
|
|
193
|
+
<div class="form-row" style="border:1px solid #ccc; padding:0; border-radius:4px; background:#fff;">
|
|
194
|
+
<div style="background:#f7f7f7; padding:8px 10px; border-bottom:1px solid #ddd; display:flex; justify-content:space-between; align-items:center;">
|
|
195
|
+
<span style="font-weight:bold; font-size:12px;"><i class="fa fa-list-ul"></i> Select Variable</span>
|
|
196
|
+
<input type="text" id="node-input-var-search" placeholder="Filter..." style="width:120px; font-size:11px; padding:2px;">
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
<div style="display:grid; grid-template-columns: 60px 1fr 80px 80px; background:#eee; font-size:11px; font-weight:bold; padding:4px 0; border-bottom:1px solid #ccc;">
|
|
200
|
+
<div style="text-align:center;">ID</div>
|
|
201
|
+
<div style="padding-left:10px;">Name</div>
|
|
202
|
+
<div style="padding-left:5px;">Type</div>
|
|
203
|
+
<div style="padding-left:5px;">Access</div>
|
|
162
204
|
</div>
|
|
163
|
-
</div>
|
|
164
205
|
|
|
165
|
-
|
|
166
|
-
|
|
206
|
+
<div id="variable-list-container" style="height:200px; overflow-y:auto; background:white;">
|
|
207
|
+
<!-- Variables -->
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
<div style="background:#f7f7f7; padding:10px; border-top:1px solid #ddd; font-size:12px; display:flex; align-items:center;">
|
|
211
|
+
<span style="font-weight:bold; margin-right:10px;">Currently Selected:</span>
|
|
212
|
+
<span id="selected-variable-display"></span>
|
|
213
|
+
</div>
|
|
167
214
|
</div>
|
|
215
|
+
|
|
216
|
+
<!-- Hidden Inputs (Required by Node-RED to save state) -->
|
|
217
|
+
<input type="hidden" id="node-input-variableId">
|
|
218
|
+
<input type="hidden" id="node-input-variableKey">
|
|
168
219
|
|
|
169
|
-
<div class="form-tips">
|
|
170
|
-
<i class="fa fa-
|
|
220
|
+
<div id="var-validation-error" class="form-tips" style="display:none; color:#c00; margin-top:10px;">
|
|
221
|
+
<i class="fa fa-warning"></i> <b>Please select a variable from the list.</b>
|
|
171
222
|
</div>
|
|
172
223
|
|
|
173
224
|
<div class="form-tips" style="margin-top:10px;">
|
|
174
|
-
<i class="fa fa-info-circle"></i> Send <code>msg.payload</code> with the value to write
|
|
225
|
+
<i class="fa fa-info-circle"></i> Send <code>msg.payload</code> with the value to write.
|
|
175
226
|
</div>
|
|
176
227
|
</script>
|
|
177
228
|
|
|
@@ -192,9 +243,9 @@
|
|
|
192
243
|
<p>The target provider to write to (e.g., <code>u_os_adm</code>, <code>u_os_sbm</code>).</p>
|
|
193
244
|
<p><b>Find it:</b> u-OS Web UI → Data Hub → Providers</p>
|
|
194
245
|
|
|
195
|
-
<h4>Variable
|
|
196
|
-
<p>
|
|
197
|
-
<p
|
|
246
|
+
<h4>Select Variable</h4>
|
|
247
|
+
<p>Click <b>Load Variables</b> to browse the provider's writable variables.</p>
|
|
248
|
+
<p>Select a variable from the list to automatically target it.</p>
|
|
198
249
|
|
|
199
250
|
<h3>Input Format</h3>
|
|
200
251
|
<p>Send the new value as <code>msg.payload</code>:</p>
|
package/nodes/datahub-write.js
CHANGED
|
@@ -202,69 +202,5 @@ module.exports = function (RED) {
|
|
|
202
202
|
|
|
203
203
|
RED.nodes.registerType('datahub-write', DataHubWriteNode);
|
|
204
204
|
|
|
205
|
-
// Admin API to fetch variables
|
|
206
|
-
RED.httpAdmin.get('/datahub-nats/variables/:id/:provider', RED.auth.needsPermission('datahub-write.read'), async function (req, res) {
|
|
207
|
-
const configNodeId = req.params.id;
|
|
208
|
-
const providerId = req.params.provider;
|
|
209
205
|
|
|
210
|
-
if (!configNodeId || !providerId) {
|
|
211
|
-
return res.status(400).send('Missing config ID or Provider ID');
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
const configNode = RED.nodes.getNode(configNodeId);
|
|
215
|
-
if (!configNode) {
|
|
216
|
-
return res.status(404).send('Config node not found');
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
try {
|
|
220
|
-
// We need a temporary connection or use the existing one if active?
|
|
221
|
-
// Config node has acquire(), but that might be for runtime nodes.
|
|
222
|
-
// For Admin API, we ideally want to just "use" the active connection if possible,
|
|
223
|
-
// or create a temp one. `acquire()` is designed for nodes.
|
|
224
|
-
// Let's try to use acquire() but release immediately.
|
|
225
|
-
|
|
226
|
-
const nc = await configNode.acquire();
|
|
227
|
-
if (!nc) {
|
|
228
|
-
return res.status(503).send('NATS connection not ready');
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
try {
|
|
232
|
-
// Dynamically import payloads to ensure we have the encoder
|
|
233
|
-
// We can't easily use import() here if it's CJS module, but we can try
|
|
234
|
-
// reusing the logic if possible or just manual encoding if simple.
|
|
235
|
-
// Actually, `payloadModuleUrl` is available in scope.
|
|
236
|
-
const payloads = await import(payloadModuleUrl);
|
|
237
|
-
|
|
238
|
-
const query = payloads.buildReadProviderDefinitionQuery();
|
|
239
|
-
const subject = `v1.loc.registry.providers.${providerId}.def.qry.read`;
|
|
240
|
-
|
|
241
|
-
// Request with timeout
|
|
242
|
-
const response = await nc.request(subject, query, { timeout: 3000 });
|
|
243
|
-
const definition = payloads.decodeProviderDefinition(response.data);
|
|
244
|
-
|
|
245
|
-
if (!definition) {
|
|
246
|
-
return res.status(404).send('No definition returned');
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// Return simple JSON list
|
|
250
|
-
const vars = definition.variables.map(v => ({
|
|
251
|
-
id: v.id,
|
|
252
|
-
key: v.key,
|
|
253
|
-
dataType: v.dataType,
|
|
254
|
-
access: v.access
|
|
255
|
-
}));
|
|
256
|
-
|
|
257
|
-
res.json(vars);
|
|
258
|
-
|
|
259
|
-
} catch (err) {
|
|
260
|
-
res.status(500).send(`Query failed: ${err.message}`);
|
|
261
|
-
// If timeout, it means provider didn't answer
|
|
262
|
-
} finally {
|
|
263
|
-
configNode.release();
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
} catch (err) {
|
|
267
|
-
res.status(500).send(`Internal error: ${err.message}`);
|
|
268
|
-
}
|
|
269
|
-
});
|
|
270
206
|
};
|
package/nodes/uos-config.js
CHANGED
|
@@ -9,18 +9,58 @@ if (!process.env.NODE_TLS_REJECT_UNAUTHORIZED) {
|
|
|
9
9
|
|
|
10
10
|
let adminRoutesRegistered = false;
|
|
11
11
|
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const { pathToFileURL } = require('url');
|
|
14
|
+
|
|
15
|
+
// Dynamic Import Helper (copied from datahub-input.js)
|
|
16
|
+
const payloadModuleUrl = pathToFileURL(path.join(__dirname, '..', 'lib', 'payloads.js')).href;
|
|
17
|
+
const subjectsModuleUrl = pathToFileURL(path.join(__dirname, '..', 'lib', 'subjects.js')).href;
|
|
18
|
+
const definitionResponseUrl = pathToFileURL(path.join(__dirname, '..', 'lib', 'fbs', 'weidmueller', 'ucontrol', 'hub', 'read-provider-definition-query-response.js')).href;
|
|
19
|
+
|
|
20
|
+
const loadModules = async (nodeInstance) => {
|
|
21
|
+
try {
|
|
22
|
+
nodeInstance.log(`Loading ESM modules from: ${payloadModuleUrl}, ${subjectsModuleUrl}`);
|
|
23
|
+
const [payloads, subjects, fbsDesc] = await Promise.all([
|
|
24
|
+
import(payloadModuleUrl),
|
|
25
|
+
import(subjectsModuleUrl),
|
|
26
|
+
import(definitionResponseUrl),
|
|
27
|
+
]);
|
|
28
|
+
nodeInstance.payloads = payloads;
|
|
29
|
+
nodeInstance.subjects = subjects;
|
|
30
|
+
nodeInstance.fbsDesc = fbsDesc;
|
|
31
|
+
nodeInstance.log('ESM Modules loaded successfully.');
|
|
32
|
+
} catch (err) {
|
|
33
|
+
nodeInstance.error(`CRITICAL: Failed to load ESM modules: ${err.message}`);
|
|
34
|
+
nodeInstance.error(err.stack);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
12
38
|
module.exports = function (RED) {
|
|
13
39
|
function UosConfigNode(config) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
40
|
+
try {
|
|
41
|
+
RED.nodes.createNode(this, config);
|
|
42
|
+
this.log('Initializing UosConfigNode...');
|
|
43
|
+
this.host = config.host || '127.0.0.1';
|
|
44
|
+
this.port = Number(config.port) || 49360;
|
|
45
|
+
this.clientName = config.clientName || 'nodered';
|
|
46
|
+
this.scope = DEFAULT_SCOPE;
|
|
47
|
+
this.clientId = this.credentials ? this.credentials.clientId : null;
|
|
48
|
+
this.clientSecret = this.credentials ? this.credentials.clientSecret : null;
|
|
49
|
+
this.tokenInfo = null;
|
|
50
|
+
this.nc = null;
|
|
51
|
+
this.users = 0;
|
|
52
|
+
this.nodeId = this.id; // Store ID for logging
|
|
53
|
+
|
|
54
|
+
this.payloads = null;
|
|
55
|
+
this.subjects = null;
|
|
56
|
+
this.fbsDesc = null;
|
|
57
|
+
|
|
58
|
+
// Load modules
|
|
59
|
+
loadModules(this);
|
|
60
|
+
|
|
61
|
+
} catch (e) {
|
|
62
|
+
console.error("UosConfigNode Constructor Error:", e);
|
|
63
|
+
}
|
|
24
64
|
|
|
25
65
|
if (!this.clientId || !this.clientSecret) {
|
|
26
66
|
this.warn('CLIENT_ID oder CLIENT_SECRET fehlen. Bitte in den Node-RED Einstellungen setzen.');
|
|
@@ -28,12 +68,30 @@ module.exports = function (RED) {
|
|
|
28
68
|
|
|
29
69
|
const tokenMarginMs = 60 * 1000;
|
|
30
70
|
|
|
31
|
-
|
|
71
|
+
// Timer for background refresh
|
|
72
|
+
this.refreshTimer = null;
|
|
73
|
+
|
|
74
|
+
this.startTokenRefresh = (expiresInSeconds) => {
|
|
75
|
+
if (this.refreshTimer) clearTimeout(this.refreshTimer);
|
|
76
|
+
// Refresh 60 seconds before expiration
|
|
77
|
+
const delay = Math.max(1000, (expiresInSeconds - 60) * 1000);
|
|
78
|
+
this.refreshTimer = setTimeout(async () => {
|
|
79
|
+
try {
|
|
80
|
+
await this.getToken(true); // Force refresh
|
|
81
|
+
} catch (e) {
|
|
82
|
+
this.warn(`Token refresh failed: ${e.message}`);
|
|
83
|
+
// Retry soon? Let's verify logic.
|
|
84
|
+
// If fail, we try again in 1 minute.
|
|
85
|
+
this.startTokenRefresh(60 + 60);
|
|
86
|
+
}
|
|
87
|
+
}, delay);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
this.getToken = async (force = false) => {
|
|
32
91
|
const now = Date.now();
|
|
33
|
-
if (this.tokenInfo && now < this.tokenInfo.expiresAt - tokenMarginMs) {
|
|
92
|
+
if (!force && this.tokenInfo && now < this.tokenInfo.expiresAt - tokenMarginMs) {
|
|
34
93
|
return this.tokenInfo.token;
|
|
35
94
|
}
|
|
36
|
-
// Force refresh if expired or about to expire
|
|
37
95
|
this.log(`Retrieving new access token for ${this.clientId}`);
|
|
38
96
|
const params = new URLSearchParams({
|
|
39
97
|
grant_type: 'client_credentials',
|
|
@@ -63,6 +121,10 @@ module.exports = function (RED) {
|
|
|
63
121
|
expiresAt: now + ((json.expires_in || 3600) * 1000),
|
|
64
122
|
grantedScope: json.scope || this.scope,
|
|
65
123
|
};
|
|
124
|
+
|
|
125
|
+
// Schedule next refresh
|
|
126
|
+
this.startTokenRefresh(json.expires_in || 3600);
|
|
127
|
+
|
|
66
128
|
return this.tokenInfo.token;
|
|
67
129
|
};
|
|
68
130
|
|
|
@@ -70,31 +132,46 @@ module.exports = function (RED) {
|
|
|
70
132
|
if (this.nc) {
|
|
71
133
|
return this.nc;
|
|
72
134
|
}
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
|
|
135
|
+
// Ensure we have a valid token initially
|
|
136
|
+
await this.getToken();
|
|
137
|
+
|
|
76
138
|
// Use jwtAuthenticator to allow dynamic token refresh on reconnect
|
|
77
139
|
try {
|
|
78
140
|
this.nc = await connect({
|
|
79
141
|
servers: `nats://${this.host}:${this.port}`,
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
142
|
+
// Authenticator must be SYNCHRONOUS. We rely on background refresh to keep this.tokenInfo current.
|
|
143
|
+
// Token function must be SYNCHRONOUS if we rely on background refresh.
|
|
144
|
+
token: () => {
|
|
145
|
+
return this.tokenInfo ? this.tokenInfo.token : '';
|
|
83
146
|
},
|
|
84
|
-
name:
|
|
147
|
+
name: this.clientName,
|
|
85
148
|
inboxPrefix: `_INBOX.${this.clientName}`,
|
|
86
149
|
maxReconnectAttempts: -1, // Infinite reconnects
|
|
87
150
|
reconnectTimeWait: 2000,
|
|
88
151
|
});
|
|
152
|
+
this.log(`NATS connecting with Name: '${this.clientName}'`);
|
|
89
153
|
} catch (e) {
|
|
90
154
|
this.error(`NATS connect failed: ${e.message}`);
|
|
91
155
|
throw e;
|
|
92
156
|
}
|
|
93
157
|
this.nc.closed().then(() => {
|
|
94
158
|
this.nc = null;
|
|
95
|
-
|
|
159
|
+
this.emit('disconnected');
|
|
160
|
+
}).catch((err) => {
|
|
96
161
|
this.nc = null;
|
|
162
|
+
this.emit('disconnected', err);
|
|
97
163
|
});
|
|
164
|
+
|
|
165
|
+
// Monitor for reconnects to emit 'reconnected' event
|
|
166
|
+
(async () => {
|
|
167
|
+
if (!this.nc) return;
|
|
168
|
+
for await (const s of this.nc.status()) {
|
|
169
|
+
if (s.type === 'reconnect') {
|
|
170
|
+
this.emit('reconnected');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
})();
|
|
174
|
+
|
|
98
175
|
return this.nc;
|
|
99
176
|
};
|
|
100
177
|
|
|
@@ -149,13 +226,25 @@ module.exports = function (RED) {
|
|
|
149
226
|
const res = await fetch(url, { headers });
|
|
150
227
|
if (!res.ok) {
|
|
151
228
|
if (res.status === 404) return null;
|
|
152
|
-
throw
|
|
229
|
+
// Don't throw yet, legitimate for fallback logic
|
|
230
|
+
return null;
|
|
153
231
|
}
|
|
154
232
|
return res;
|
|
155
233
|
};
|
|
156
234
|
|
|
157
|
-
//
|
|
158
|
-
let res = await tryFetch(`https://${this.host}/u-os-hub/api/v1/providers/${providerId}
|
|
235
|
+
// 0. Try getting Provider Metadata (likely contains Definitions with IDs)
|
|
236
|
+
let res = await tryFetch(`https://${this.host}/u-os-hub/api/v1/providers/${providerId}`);
|
|
237
|
+
if (res) {
|
|
238
|
+
const meta = await res.json();
|
|
239
|
+
// Check if metadata contains variables definition
|
|
240
|
+
if (meta && Array.isArray(meta.variables) && meta.variables.length > 0 && (meta.variables[0].id !== undefined || meta.variables[0].Id !== undefined)) {
|
|
241
|
+
this.log(`Fetched variables via Provider Metadata (found IDs).`);
|
|
242
|
+
return meta.variables;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// 1. Try standard u-OS API (Variables List - often only values)
|
|
247
|
+
res = await tryFetch(`https://${this.host}/u-os-hub/api/v1/providers/${providerId}/variables`);
|
|
159
248
|
|
|
160
249
|
// 2. Fallback to older datahub API
|
|
161
250
|
if (!res) {
|
|
@@ -168,7 +257,17 @@ module.exports = function (RED) {
|
|
|
168
257
|
}
|
|
169
258
|
|
|
170
259
|
const json = await res.json();
|
|
171
|
-
|
|
260
|
+
if (Array.isArray(json) && json.length > 0) {
|
|
261
|
+
this.log(`Fetched ${json.length} vars via variables list. Using Heuristic: ID = Index if missing.`);
|
|
262
|
+
|
|
263
|
+
json.forEach((v, i) => {
|
|
264
|
+
// If ID is missing, assign the index as ID (matches u_os_adm behavior)
|
|
265
|
+
if (v.id === undefined && v.Id === undefined) {
|
|
266
|
+
v.id = i;
|
|
267
|
+
v.missingId = true; // Flag for debug
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
}
|
|
172
271
|
return json;
|
|
173
272
|
};
|
|
174
273
|
|
|
@@ -179,14 +278,20 @@ module.exports = function (RED) {
|
|
|
179
278
|
|
|
180
279
|
this.release = async () => {
|
|
181
280
|
this.users = Math.max(0, this.users - 1);
|
|
182
|
-
if (this.users === 0
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
await nc.drain();
|
|
281
|
+
if (this.users === 0) {
|
|
282
|
+
if (this.refreshTimer) {
|
|
283
|
+
clearTimeout(this.refreshTimer);
|
|
284
|
+
this.refreshTimer = null;
|
|
187
285
|
}
|
|
188
|
-
|
|
189
|
-
|
|
286
|
+
if (this.nc) {
|
|
287
|
+
const nc = this.nc;
|
|
288
|
+
this.nc = null;
|
|
289
|
+
try {
|
|
290
|
+
await nc.drain();
|
|
291
|
+
}
|
|
292
|
+
catch (err) {
|
|
293
|
+
this.warn(`Fehler beim Schließen der NATS-Verbindung: ${err.message}`);
|
|
294
|
+
}
|
|
190
295
|
}
|
|
191
296
|
}
|
|
192
297
|
};
|
|
@@ -289,7 +394,7 @@ module.exports = function (RED) {
|
|
|
289
394
|
let apiRes = await tryFetch(`https://${host}/u-os-hub/api/v1/providers`);
|
|
290
395
|
if (!apiRes) apiRes = await tryFetch(`https://${host}/datahub/v1/providers`);
|
|
291
396
|
|
|
292
|
-
if (!apiRes) throw new Error('API
|
|
397
|
+
if (!apiRes) throw new Error('API endpoint not found');
|
|
293
398
|
|
|
294
399
|
const providers = await apiRes.json();
|
|
295
400
|
const count = Array.isArray(providers) ? providers.length : 0;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-uos-nats",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.94",
|
|
4
4
|
"description": "Node-RED nodes for Weidmüller u-OS Data Hub. Read, write, and provide variables via NATS protocol with OAuth2 authentication. Features: Variable Key resolution, custom icons, example flows, and provider definition caching.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "IoTUeli",
|