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 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 | Icon | 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. |
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:** Target `Provider ID` and `Variable ID` (or Key).
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.
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }
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
+ }
@@ -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 (Event/Poll) Visibility ---
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
- // --- Manual Table Logic ---
40
- const $manualList = $('#node-input-manual-def-list');
41
- const $manualHidden = $('#node-input-manualVariables');
42
-
43
- $manualList.editableList({
44
- addItem: function (row, index, data) {
45
- row.css({ display: 'flex', alignItems: 'center', gap: '5px' });
46
-
47
- $('<input/>', { class: 'node-input-manual-name', type: 'text', placeholder: 'Variable Name', style: 'flex:1;' })
48
- .val(data.name || '')
49
- .appendTo(row);
50
-
51
- $('<input/>', { class: 'node-input-manual-id', type: 'number', placeholder: 'ID', style: 'width:80px;' })
52
- .val(data.id || '')
53
- .appendTo(row);
54
- },
55
- removable: true,
56
- header: $('<div></div>').append(
57
- $.parseHTML('<div style="display:flex; gap:5px; padding-left:5px;"><div style="flex:1;">Variable Name (Key)</div><div style="width:80px;">ID (Number)</div></div>')
58
- ),
59
- addButton: 'Add Variable'
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
- const currentManual = node.manualVariables || '';
63
- if (currentManual) {
64
- currentManual.split(',').forEach(entry => {
65
- const parts = entry.split(':');
66
- if (parts.length === 2) {
67
- const n = parts[0].trim();
68
- const i = parts[1].trim();
69
- if (n && i) $manualList.editableList('addItem', { name: n, id: i });
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
- const $manualList = $('#node-input-manual-def-list');
187
+ // 1. Save Variables
76
188
  const items = [];
77
- $manualList.editableList('items').each(function () {
78
- const name = $(this).find('.node-input-manual-name').val().trim();
79
- const id = $(this).find('.node-input-manual-id').val().trim();
80
- if (name && id) {
81
- items.push(`${name}:${id}`);
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
- <input type="text" id="node-input-providerId" placeholder="e.g. u_os_sbm">
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
- <label><i class="fa fa-table"></i> Variables</label>
107
- <div style="width:100%; margin-top:8px;">
108
- <ol id="node-input-manual-def-list"></ol>
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
- <div class="form-tips">
114
- <i class="fa fa-info-circle"></i> Enter Variable Names and their IDs. Only these variables will be read.
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 (ms)</label>
129
- <input type="number" id="node-input-pollingInterval" placeholder="e.g. 1000">
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
 
@@ -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
- if (this.variables.length > 0) {
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
- if (targetIds.length === 0 && defMap.size > 0) {
197
- // Warning logic ...
198
- const sampleKeys = Array.from(defMap.values()).filter(d => d.type !== 'MANUAL').map(d => `'${d.key}'`).slice(0, 5).join(', ');
199
- this.warn(`Snapshot Warning: None of the ${this.variables.length} configured variables were found in the Provider Definition.`);
200
- if (!sampleKeys && this.manualDefs.length > 0) {
201
- this.warn(' -> Manual Definitions are configured but did not match the requested variable names? Check capitalization.');
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 we have specific IDs, request only those. Otherwise request all (empty array).
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);
@@ -109,12 +109,22 @@ module.exports = function (RED) {
109
109
  return { def, created: true };
110
110
  };
111
111
 
112
- const sendDefinitionUpdate = async (payloads, subjects) => {
113
- console.log(`[DataHub Output] Publishing definition with ${definitions.length} vars...`);
114
- const { payload, fingerprint: fp } = payloads.buildProviderDefinitionEvent(definitions);
115
- fingerprint = fp;
116
- await nc.publish(subjects.providerDefinitionChanged(this.providerId), payload);
117
- console.log(`[DataHub Output] Definition published. FP: ${fp}`);
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
- return;
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
@@ -17,118 +17,154 @@
17
17
  },
18
18
  paletteLabel: "DataHub - Write",
19
19
  oneditprepare: function () {
20
- // Search Button Handler
21
- $('#node-config-lookup-var').click(function () {
22
- const configId = $('#node-input-connection').val();
23
- const providerId = $('#node-input-providerId').val();
24
-
25
- if (!configId || configId === '_ADD_') {
26
- RED.notify("Please select a valid Configuration Node first.", "error");
27
- return;
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
- if (!providerId) {
30
- RED.notify("Please enter a Provider ID to search.", "error");
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
- const notification = RED.notify("Searching variables... please wait...", "info");
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
- $.getJSON('datahub-nats/variables/' + configId + '/' + providerId, function (data) {
37
- notification.close();
60
+ const row = $('<div>', { class: 'var-row', style: rowStyle });
38
61
 
39
- // Build table
40
- let rows = "";
41
- data.sort((a, b) => a.key.localeCompare(b.key));
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
- data.forEach(v => {
44
- rows += `<tr class="node-dialog-var-row" style="cursor:pointer;" data-id="${v.id}" data-key="${v.key}">
45
- <td>${v.id}</td>
46
- <td>${v.key}</td>
47
- <td>${v.dataType}</td>
48
- <td>${v.access}</td>
49
- </tr>`;
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
- const dialogHtml = `
53
- <div id="node-lookup-var-dialog" title="Select Variable">
54
- <input type="text" id="node-lookup-var-search" placeholder="Filter variables..." style="width:100%; margin-bottom:10px;">
55
- <div style="height:300px; overflow-y:scroll; border:1px solid #ccc;">
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
- // Row Click Logic
85
- $('.node-dialog-var-row').click(function () {
86
- const id = $(this).data('id');
87
- const key = $(this).data('key');
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
- $('#node-input-variableId').val(id);
90
- $('#node-input-variableKey').val(key);
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
- $dialog.dialog('close');
93
- $dialog.remove();
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
- $dialog.dialog({
97
- modal: true,
98
- autoOpen: true,
99
- width: 600,
100
- title: `Variables for ${providerId}`,
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
- notification.close();
111
- RED.notify("Search failed: " + jqxhr.responseText, "error");
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
- // Validate: at least one of ID or Key required
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 = $('#node-input-variableId').val();
118
- const hasKey = $('#node-input-variableKey').val();
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
- // Loose validation - server side will check
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
- <input type="text" id="node-input-providerId" placeholder="e.g. u_os_adm">
150
- </div>
151
-
152
- <div class="form-row">
153
- <label for="node-input-variableId"><i class="fa fa-hashtag"></i> Variable ID</label>
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
- <div class="form-row">
158
- <label for="node-input-variableKey"><i class="fa fa-key"></i> Variable Key</label>
159
- <div style="display:inline-block; position:relative; width:70%;">
160
- <input type="text" id="node-input-variableKey" placeholder="e.g. machine.temp (optional)" style="width:100%;">
161
- <a id="node-config-lookup-var" class="btn" style="position:absolute; right:-40px; top:0;"><i class="fa fa-search"></i></a>
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
- <div id="var-validation-error" class="form-tips" style="display:none; color:#c00;">
166
- <i class="fa fa-warning"></i> <b>Either Variable ID or Variable Key is required</b>
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-info-circle"></i> Provide <b>either</b> Variable ID (numeric) <b>or</b> Variable Key (text). Key will be resolved to ID automatically.
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 (e.g., <code>true</code>, <code>42</code>, <code>"hello"</code>)
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 ID</h4>
196
- <p>The numeric ID of the variable to write.</p>
197
- <p><b>Find it:</b> u-OS Web UI Data Hub Providers → (Select Provider) → Variables tab</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>
@@ -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
  };
@@ -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
- RED.nodes.createNode(this, config);
15
- this.host = config.host || '127.0.0.1';
16
- this.port = Number(config.port) || 49360;
17
- this.clientName = config.clientName || 'nodered';
18
- this.scope = DEFAULT_SCOPE;
19
- this.clientId = this.credentials ? this.credentials.clientId : null;
20
- this.clientSecret = this.credentials ? this.credentials.clientSecret : null;
21
- this.tokenInfo = null;
22
- this.nc = null;
23
- this.users = 0;
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
- this.getToken = async () => {
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
- // Token is now fetched dynamically via authenticator
74
- // const token = await this.getToken();
75
- // Use jwtAuthenticator to allow dynamic token refresh on reconnect
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
- authenticator: async () => {
81
- const t = await this.getToken();
82
- return { token: t };
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: `${this.clientName}-nodered`,
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
- }).catch(() => {
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 new Error(`API error ${res.status} from ${url}`);
229
+ // Don't throw yet, legitimate for fallback logic
230
+ return null;
153
231
  }
154
232
  return res;
155
233
  };
156
234
 
157
- // 1. Try standard u-OS API
158
- let res = await tryFetch(`https://${this.host}/u-os-hub/api/v1/providers/${providerId}/variables`);
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
- this.log(`Fetched ${Array.isArray(json) ? json.length : 'unknown'} variables via API for provider ${providerId}`);
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 && this.nc) {
183
- const nc = this.nc;
184
- this.nc = null;
185
- try {
186
- await nc.drain();
281
+ if (this.users === 0) {
282
+ if (this.refreshTimer) {
283
+ clearTimeout(this.refreshTimer);
284
+ this.refreshTimer = null;
187
285
  }
188
- catch (err) {
189
- this.warn(`Fehler beim Schließen der NATS-Verbindung: ${err.message}`);
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 endoint not found');
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.76",
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",