node-red-contrib-uos-nats 1.0.7 → 1.1.0

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/lib/payloads.js CHANGED
@@ -19,7 +19,7 @@ import { ReadVariablesQueryResponseT } from './fbs/weidmueller/ucontrol/hub/read
19
19
  import { ReadVariablesQueryRequestT } from './fbs/weidmueller/ucontrol/hub/read-variables-query-request.js';
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
- import { WriteVariablesCommandT } from './fbs/weidmueller/ucontrol/hub/write-variables-command.js';
22
+ import { WriteVariablesCommandT, WriteVariablesCommand } from './fbs/weidmueller/ucontrol/hub/write-variables-command.js';
23
23
  import { StateChangedEvent } from './fbs/weidmueller/ucontrol/hub/state-changed-event.js';
24
24
  import { State } from './fbs/weidmueller/ucontrol/hub/state.js';
25
25
 
@@ -206,6 +206,12 @@ export function encodeWriteVariablesCommand(variables, fingerprint = BigInt(0))
206
206
  return builder.asUint8Array();
207
207
  }
208
208
 
209
+ export function decodeWriteVariablesCommand(buffer) {
210
+ const bb = new flatbuffers.ByteBuffer(buffer);
211
+ const cmd = WriteVariablesCommand.getRootAsWriteVariablesCommand(bb);
212
+ return decodeVariableList(cmd.variables());
213
+ }
214
+
209
215
  export function decodeVariableList(list) {
210
216
  if (!list)
211
217
  return [];
@@ -6,10 +6,12 @@
6
6
  name: { value: '' },
7
7
  connection: { type: 'uos-config', required: true },
8
8
  providerId: { value: '', required: false },
9
- heartbeatInterval: { value: 300, required: true, validate: RED.validators.number() } // Default 5 mins
9
+ heartbeatInterval: { value: 300, required: true, validate: RED.validators.number() }, // Default 5 mins
10
+ enableWrites: { value: false }
10
11
  },
11
12
  inputs: 1,
12
- outputs: 1,
13
+ outputs: 2,
14
+ outputLabels: ["Input Passthrough", "External Write Command"],
13
15
  icon: "datahub-provider.svg",
14
16
  oneditprepare: function () {
15
17
  const node = this;
@@ -119,6 +121,11 @@
119
121
  <input type="number" id="node-input-heartbeatInterval" placeholder="300" style="width:100px;">
120
122
  <span style="color:#777; font-size:0.9em; margin-left:10px;">(Default: 300s / 5min)</span>
121
123
  </div>
124
+ <div class="form-row">
125
+ <label for="node-input-enableWrites"><i class="fa fa-pencil"></i> Access</label>
126
+ <input type="checkbox" id="node-input-enableWrites" style="width:auto; vertical-align:top;">
127
+ <span style="margin-left:5px;">Allow external writes (Bidirectional)</span>
128
+ </div>
122
129
  </script>
123
130
 
124
131
  <script type="text/html" data-help-name="datahub-output">
@@ -130,8 +137,15 @@
130
137
  <dd>The unique ID for this provider.</dd>
131
138
  <dd><b>Auto Mode:</b> Shows the <em>Client Name</em> from your Config. Uses this name automatically.</dd>
132
139
  <dt>Keep-Alive (s)</dt>
133
- <dd>Interval in seconds to refresh the provider definition. Default: <b>300s (5 min)</b>. Lower values increase traffic but might be needed for very strict networks.</dd>
140
+ <dd>Interval in seconds to refresh the provider definition. Default: <b>300s (5 min)</b>.</dd>
141
+ <dt>Allow external writes</dt>
142
+ <dd>If checked, variables are published as <code>READ_WRITE</code>. External changes are emitted on the <b>2nd Output</b>.</dd>
134
143
  </dl>
144
+ <h3>Outputs</h3>
145
+ <ol>
146
+ <li><b>Input Passthrough:</b> The original message sent to the node.</li>
147
+ <li><b>External Write Command:</b> Emits a message when another system writes to a variable. <code>msg.var</code> contains the key, <code>msg.value</code> the new value.</li>
148
+ </ol>
135
149
  <p>Send a JSON object as <code>msg.payload</code>. Nested objects are flattened using dot-notation to create <strong>subcategories</strong> automatically:</p>
136
150
  <pre>{
137
151
  "machine": {
@@ -96,7 +96,7 @@ module.exports = function (RED) {
96
96
  id: nextId += 1,
97
97
  key: normalized,
98
98
  dataType,
99
- access: 'READ_ONLY',
99
+ access: config.enableWrites ? 'READ_WRITE' : 'READ_ONLY',
100
100
  };
101
101
  defMap.set(normalized, def);
102
102
  definitions.push(def);
@@ -146,38 +146,21 @@ module.exports = function (RED) {
146
146
 
147
147
  const sendValuesUpdate = async () => {
148
148
  if (!nc || nc.isClosed()) {
149
- console.log('[DataHub Output] Heartbeat skipped: NATS closed or missing.');
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
149
+ // console.log('[DataHub Output] Heartbeat skipped: NATS closed.');
150
+ return;
163
151
  }
164
152
  if (!loadedPayloads || !loadedSubjects) return;
165
153
 
166
154
  // If we have no definitions yet, nothing to send
167
- if (definitions.length === 0) {
168
- // console.log('[DataHub Output] Heartbeat skipped: No definitions.');
169
- return;
170
- }
155
+ if (definitions.length === 0) return;
171
156
 
172
157
  // Log heartbeat occasionally to prove aliveness
173
158
  const nowMs = Date.now();
174
159
  if (!this.lastHeartbeatLog || nowMs - this.lastHeartbeatLog > 10000) {
175
- console.log(`[DataHub Output] Sending heartbeat for ${definitions.length} vars...`);
176
160
  this.lastHeartbeatLog = nowMs;
177
161
  }
178
162
 
179
163
  const stateObj = {};
180
- const nowNs = Date.now() * 1_000_000;
181
164
  for (const s of stateMap.values()) {
182
165
  s.timestamp = BigInt(Date.now()) * 1_000_000n; // Force refresh timestamp
183
166
  stateObj[s.id] = s;
@@ -187,7 +170,7 @@ module.exports = function (RED) {
187
170
  const subject = loadedSubjects.varsChangedEvent(this.providerId);
188
171
 
189
172
  await nc.publish(subject, payload);
190
- await nc.flush(); // Ensure NATS accepts the packet (catches Permission Errors)
173
+ await nc.flush();
191
174
  } catch (err) {
192
175
  this.warn(`Heartbeat error: ${err.message}`);
193
176
  }
@@ -197,6 +180,18 @@ module.exports = function (RED) {
197
180
  sendValuesUpdate();
198
181
  }, 1000); // 1.0s interval matches Python SDK
199
182
 
183
+ // Reconnect Handler (Defined outside start to be referenced in close)
184
+ const onReconnect = () => {
185
+ console.log('[DataHub Output] Connection restored (event). Re-sending definition...');
186
+ this.status({ fill: 'green', shape: 'ring', text: 'reconnected' });
187
+ // Force definition update immediately upon reconnection
188
+ if (loadedPayloads && loadedSubjects) {
189
+ sendDefinitionUpdate(loadedPayloads, loadedSubjects).catch(err => {
190
+ this.warn(`Reconnect update error: ${err.message}`);
191
+ });
192
+ }
193
+ };
194
+
200
195
  const start = async () => {
201
196
  try {
202
197
  console.log('[DataHub Output] Starting...');
@@ -209,13 +204,14 @@ module.exports = function (RED) {
209
204
  nc = await connection.acquire();
210
205
  console.log('[DataHub Output] NATS acquired.');
211
206
 
207
+ // ATTACH LISTENER HERE
208
+ connection.on('reconnected', onReconnect);
209
+
212
210
  if (definitions.length > 0) {
213
211
  // Initial publish
214
212
  await sendDefinitionUpdate(payloads, subjects);
215
213
 
216
214
  // 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
215
  nc.subscribe(subjects.registryStateEvent(), {
220
216
  callback: (err, msg) => {
221
217
  if (err) return;
@@ -237,10 +233,7 @@ module.exports = function (RED) {
237
233
  sub = nc.subscribe(subjects.readVariablesQuery(this.providerId), {
238
234
  callback: (err, msg) => {
239
235
  if (err) {
240
- // Suppress permission violation error as it's expected for some tokens
241
- // and doesn't prevent pushing data.
242
236
  if (err.message.includes('Permissions Violation')) {
243
- this.trace(`Read request permission invalid (expected for push-only): ${err.message}`);
244
237
  return;
245
238
  }
246
239
  this.warn(`Read request error: ${err.message}`);
@@ -251,28 +244,80 @@ module.exports = function (RED) {
251
244
  });
252
245
  console.log('[DataHub Output] Subscribed to Read Query.');
253
246
 
254
- // Listen for Definition READ requests (Discovery)
255
- // SKIPPED: Permission Violation on v1.loc.<id>.def.qry.read
256
- // Data Hub seems to discover providers via initial announcement or direct variable reads.
257
- /*
258
- const defSub = nc.subscribe(subjects.readProviderDefinitionQuery(this.providerId), {
259
- callback: (err, msg) => {
260
- if (err) {
261
- this.warn(`Def request error: ${err.message}`);
262
- return;
263
- }
264
- if (!msg.reply) return;
265
-
266
- // Send known definition
267
- const { payload } = payloads.buildProviderDefinitionEvent(definitions);
268
- nc.publish(msg.reply, payload);
269
- }
270
- });
271
- */
247
+ // LISTEN FOR WRITE COMMANDS (BIDIRECTIONAL)
248
+ if (config.enableWrites) {
249
+ const writeSubject = subjects.writeVariablesCommand(this.providerId);
250
+ console.log(`[DataHub Output] Subscribing to WRITE command: ${writeSubject}`);
272
251
 
273
- // Track the subscription to close it later if needed (though existing code only tracks 'sub')
274
- // Ideally we should track both or use a subscription manager, but for now let's hope 'sub' isn't the only one closed.
275
- // Actually, looking at close(), it likely calls connection.release(). NATS connection close cleans up subs.
252
+ nc.subscribe(writeSubject, {
253
+ callback: (err, msg) => {
254
+ if (err) {
255
+ this.warn(`Write command error: ${err.message}`);
256
+ return;
257
+ }
258
+ try {
259
+ const updates = payloads.decodeWriteVariablesCommand(msg.data);
260
+ if (!updates || updates.length === 0) return;
261
+
262
+ console.log(`[DataHub Output] Received WRITE for ${updates.length} vars.`);
263
+
264
+ // Prepare output for Node-RED Flow
265
+ const outputMsgs = [];
266
+ let stateChanged = false;
267
+
268
+ updates.forEach(update => {
269
+ // Find defining key by ID
270
+ // This is expensive O(N). Optimization: Map<ID, Key>
271
+ // But definitions are usually few (<1000).
272
+ // We have stateMap keyed by ID!
273
+ const currentState = stateMap.get(update.id);
274
+ if (currentState) {
275
+ // Update internal state
276
+ currentState.value = update.value;
277
+ currentState.timestamp = BigInt(Date.now()) * 1_000_000n;
278
+ currentState.quality = 'GOOD_LOCAL_OVERRIDE'; // Mark as written? Or just GOOD?
279
+ // NATS Sample uses GOOD.
280
+
281
+ // Find key (optional, for convenience in msg)
282
+ const def = definitions.find(d => d.id === update.id);
283
+ const key = def ? def.key : `id_${update.id}`;
284
+
285
+ outputMsgs.push({
286
+ topic: key,
287
+ payload: update.value,
288
+ var: key,
289
+ value: update.value,
290
+ providerId: this.providerId,
291
+ _msgid: RED.util.generateId()
292
+ });
293
+ stateChanged = true;
294
+ }
295
+ });
296
+
297
+ if (stateChanged) {
298
+ // ACK changes back to DataHub (VariablesChangedEvent)
299
+ // This confirms to the writer that the value was accepted.
300
+ sendValuesUpdate();
301
+
302
+ // Emit to 2nd Output
303
+ if (outputMsgs.length > 0) {
304
+ // Node-RED send: [output1, output2]
305
+ // We send null to output1 (pass-through not involved here)
306
+ // We send array of msgs to output2? Or one by one?
307
+ // Ideally one array if multiple updates?
308
+ // Let's send them individually to be safe with flows.
309
+ outputMsgs.forEach(m => {
310
+ this.send([null, m]);
311
+ });
312
+ }
313
+ }
314
+
315
+ } catch (e) {
316
+ this.warn(`Failed to process write command: ${e.message}`);
317
+ }
318
+ }
319
+ });
320
+ }
276
321
 
277
322
  this.status({ fill: 'green', shape: 'dot', text: 'ready' });
278
323
 
@@ -317,7 +362,6 @@ module.exports = function (RED) {
317
362
  }
318
363
 
319
364
  let definitionsChanged = false;
320
- const states = [];
321
365
 
322
366
  entries.forEach(({ key, value }) => {
323
367
  // Ensure we don't accidentally send undefined/null as value if logic slipped through
@@ -364,11 +408,15 @@ module.exports = function (RED) {
364
408
 
365
409
  this.on('close', async (done) => {
366
410
  try {
411
+ // REMOVE LISTENER
412
+ if (connection && onReconnect) {
413
+ connection.removeListener('reconnected', onReconnect);
414
+ }
415
+
367
416
  if (valueHeartbeat) clearInterval(valueHeartbeat);
368
417
  if (sub) {
369
418
  await sub.drain();
370
419
  }
371
- // if (outputHeartbeat) clearInterval(outputHeartbeat);
372
420
  await connection.release();
373
421
  }
374
422
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-uos-nats",
3
- "version": "1.0.7",
3
+ "version": "1.1.0",
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",