• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In
Build has been canceled!

RobotWebTools / rclnodejs / 23936778848

03 Apr 2026 06:34AM UTC coverage: 85.387% (+0.06%) from 85.327%
23936778848

Pull #1474

github

web-flow
Merge 53ef17de0 into 601a6a1e7
Pull Request #1474: Add ParameterEventHandler node filtering support

1563 of 1990 branches covered (78.54%)

Branch coverage included in aggregate %.

24 of 25 new or added lines in 1 file covered. (96.0%)

8 existing lines in 1 file now uncovered.

3170 of 3553 relevant lines covered (89.22%)

444.5 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

87.61
/lib/parameter_event_handler.js
1
// Copyright (c) 2026, The Robot Web Tools Contributors
2
//
3
// Licensed under the Apache License, Version 2.0 (the "License");
4
// you may not use this file except in compliance with the License.
5
// You may obtain a copy of the License at
6
//
7
//     http://www.apache.org/licenses/LICENSE-2.0
8
//
9
// Unless required by applicable law or agreed to in writing, software
10
// distributed under the License is distributed on an "AS IS" BASIS,
11
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
// See the License for the specific language governing permissions and
13
// limitations under the License.
14

15
'use strict';
16

17
const { TypeValidationError, OperationError } = require('./errors');
26✔
18
const { normalizeNodeName } = require('./utils');
26✔
19
const debug = require('debug')('rclnodejs:parameter_event_handler');
26✔
20

21
const PARAMETER_EVENT_MSG_TYPE = 'rcl_interfaces/msg/ParameterEvent';
26✔
22
const PARAMETER_EVENT_TOPIC = '/parameter_events';
26✔
23

24
/**
25
 * @class ParameterCallbackHandle
26
 * Opaque handle returned when adding a parameter callback.
27
 * Used to remove the callback later.
28
 */
29
class ParameterCallbackHandle {
30
  /**
31
   * @param {string} parameterName - The parameter name
32
   * @param {string} nodeName - The fully qualified node name
33
   * @param {Function} callback - The callback function
34
   * @hideconstructor
35
   */
36
  constructor(parameterName, nodeName, callback) {
37
    this.parameterName = parameterName;
7✔
38
    this.nodeName = nodeName;
7✔
39
    this.callback = callback;
7✔
40
  }
41
}
42

43
/**
44
 * @class ParameterEventCallbackHandle
45
 * Opaque handle returned when adding a parameter event callback.
46
 * Used to remove the callback later.
47
 */
48
class ParameterEventCallbackHandle {
49
  /**
50
   * @param {Function} callback - The callback function
51
   * @hideconstructor
52
   */
53
  constructor(callback) {
54
    this.callback = callback;
45✔
55
  }
56
}
57

58
/**
59
 * @class ParameterEventHandler
60
 *
61
 * Monitors and responds to parameter changes on any node in the ROS 2 graph
62
 * by subscribing to the `/parameter_events` topic.
63
 *
64
 * Unlike {@link ParameterWatcher}, which is tied to a single remote node and
65
 * requires waiting for that node's parameter services, ParameterEventHandler
66
 * responds to parameter events from any node without needing service availability.
67
 *
68
 * Two types of callbacks are supported:
69
 * - **Parameter callbacks**: fired when a specific parameter on a specific node
70
 *   is added or changed (new_parameters + changed_parameters).
71
 *   Note: deleted parameters are not dispatched to parameter callbacks;
72
 *   use event callbacks to observe deletions.
73
 * - **Event callbacks**: fired for every ParameterEvent message received,
74
 *   including deletions.
75
 *
76
 * @example
77
 * const handler = node.createParameterEventHandler();
78
 *
79
 * // Watch a specific parameter on a specific node
80
 * const handle = handler.addParameterCallback(
81
 *   'my_param',
82
 *   '/my_node',
83
 *   (parameter) => {
84
 *     console.log(`Parameter changed: ${parameter.name} = ${parameter.value}`);
85
 *   }
86
 * );
87
 *
88
 * // Watch all parameter events
89
 * const eventHandle = handler.addParameterEventCallback((event) => {
90
 *   console.log(`Event from node: ${event.node}`);
91
 * });
92
 *
93
 * // Remove callbacks when done
94
 * handler.removeParameterCallback(handle);
95
 * handler.removeParameterEventCallback(eventHandle);
96
 *
97
 * // Destroy when no longer needed
98
 * handler.destroy();
99
 */
100
class ParameterEventHandler {
101
  #node;
102
  #subscription;
103
  #parameterCallbacks; // Map<string, ParameterCallbackHandle[]> keyed by "paramName\0nodeName"
104
  #eventCallbacks; // ParameterEventCallbackHandle[]
105
  #destroyed;
106

107
  /**
108
   * Create a ParameterEventHandler.
109
   *
110
   * @param {object} node - The rclnodejs Node used to create the subscription
111
   * @param {object} [options] - Options
112
   * @param {object} [options.qos] - QoS profile for the parameter_events subscription
113
   */
114
  constructor(node, options = {}) {
49✔
115
    if (!node || typeof node.createSubscription !== 'function') {
67✔
116
      throw new TypeValidationError('node', node, 'Node instance', {
2✔
117
        entityType: 'parameter event handler',
118
      });
119
    }
120

121
    if (
65!
122
      options !== undefined &&
195✔
123
      options !== null &&
124
      typeof options !== 'object'
125
    ) {
UNCOV
126
      throw new TypeValidationError('options', options, 'object or undefined', {
×
127
        entityType: 'parameter event handler',
128
      });
129
    }
130

131
    const opts = options || {};
65!
132

133
    this.#node = node;
65✔
134
    this.#parameterCallbacks = new Map();
65✔
135
    this.#eventCallbacks = [];
65✔
136
    this.#destroyed = false;
65✔
137

138
    const subscriptionOptions = opts.qos ? { qos: opts.qos } : undefined;
65!
139

140
    this.#subscription = node.createSubscription(
65✔
141
      PARAMETER_EVENT_MSG_TYPE,
142
      PARAMETER_EVENT_TOPIC,
143
      subscriptionOptions,
144
      (event) => this.#handleEvent(event)
17✔
145
    );
146

147
    debug('Created ParameterEventHandler on node=%s', node.name());
65✔
148
  }
149

150
  /**
151
   * Add a callback for a specific parameter on a specific node.
152
   *
153
   * The callback is invoked whenever the named parameter is added or changed
154
   * on the specified node. The callback receives the parameter message object
155
   * (rcl_interfaces/msg/Parameter) with `name` and `value` fields.
156
   *
157
   * @param {string} parameterName - Name of the parameter to monitor
158
   * @param {string} nodeName - Fully qualified name of the node (e.g., '/my_node')
159
   * @param {Function} callback - Called with (parameter) when the parameter changes
160
   * @returns {ParameterCallbackHandle} Handle for removing this callback later
161
   * @throws {Error} If the handler has been destroyed
162
   * @throws {TypeError} If arguments are invalid
163
   */
164
  addParameterCallback(parameterName, nodeName, callback) {
165
    this.#checkNotDestroyed();
11✔
166

167
    if (typeof parameterName !== 'string' || parameterName.trim() === '') {
10✔
168
      throw new TypeValidationError(
1✔
169
        'parameterName',
170
        parameterName,
171
        'non-empty string',
172
        { entityType: 'parameter event handler' }
173
      );
174
    }
175

176
    if (typeof nodeName !== 'string' || nodeName.trim() === '') {
9✔
177
      throw new TypeValidationError('nodeName', nodeName, 'non-empty string', {
1✔
178
        entityType: 'parameter event handler',
179
      });
180
    }
181

182
    if (typeof callback !== 'function') {
8✔
183
      throw new TypeValidationError('callback', callback, 'function', {
1✔
184
        entityType: 'parameter event handler',
185
      });
186
    }
187

188
    const resolvedNodeName = normalizeNodeName(nodeName);
7✔
189
    const resolvedParamName = parameterName.trim();
7✔
190
    const handle = new ParameterCallbackHandle(
7✔
191
      resolvedParamName,
192
      resolvedNodeName,
193
      callback
194
    );
195
    const key = this.#makeKey(resolvedParamName, resolvedNodeName);
7✔
196

197
    if (!this.#parameterCallbacks.has(key)) {
7!
198
      this.#parameterCallbacks.set(key, []);
7✔
199
    }
200

201
    // Insert at front (FILO order, matching rclpy behavior)
202
    this.#parameterCallbacks.get(key).unshift(handle);
7✔
203

204
    debug(
7✔
205
      'Added parameter callback: param=%s node=%s',
206
      resolvedParamName,
207
      resolvedNodeName
208
    );
209

210
    return handle;
7✔
211
  }
212

213
  /**
214
   * Configure which node parameter events will be received.
215
   *
216
   * If nodeNames is omitted or empty, the current node filter is cleared.
217
   * When a filter is active, parameter and event callbacks only receive
218
   * events from the specified nodes.
219
   *
220
   * @param {string[]} [nodeNames] - Node names to filter parameter events from.
221
   *   Relative names are resolved against the handler node namespace.
222
   * @returns {boolean} True if the filter is active or was successfully cleared.
223
   */
224
  configureNodesFilter(nodeNames) {
225
    this.#checkNotDestroyed();
7✔
226

227
    if (nodeNames === undefined || nodeNames === null) {
7✔
228
      this.#subscription.clearContentFilter();
1✔
229
      return !this.#subscription.hasContentFilter();
1✔
230
    }
231

232
    if (!Array.isArray(nodeNames)) {
6✔
233
      throw new TypeValidationError('nodeNames', nodeNames, 'string[]', {
1✔
234
        entityType: 'parameter event handler',
235
      });
236
    }
237

238
    if (nodeNames.length === 0) {
5✔
239
      this.#subscription.clearContentFilter();
1✔
240
      return !this.#subscription.hasContentFilter();
1✔
241
    }
242

243
    const resolvedNodeNames = nodeNames.map((nodeName, index) => {
4✔
244
      if (typeof nodeName !== 'string' || nodeName.trim() === '') {
5✔
245
        throw new TypeValidationError(
2✔
246
          `nodeNames[${index}]`,
247
          nodeName,
248
          'non-empty string',
249
          {
250
            entityType: 'parameter event handler',
251
          }
252
        );
253
      }
254
      return this.#resolvePath(nodeName.trim());
3✔
255
    });
256

257
    const contentFilter = {
2✔
258
      expression: resolvedNodeNames
259
        .map((_, index) => `node = %${index}`)
3✔
260
        .join(' OR '),
261
      parameters: resolvedNodeNames.map((nodeName) => `'${nodeName}'`),
3✔
262
    };
263

264
    this.#subscription.setContentFilter(contentFilter);
2✔
265
    return this.#subscription.hasContentFilter();
2✔
266
  }
267

268
  /**
269
   * Remove a previously added parameter callback.
270
   *
271
   * @param {ParameterCallbackHandle} handle - The handle returned by addParameterCallback
272
   * @throws {Error} If the handle is not found or handler is destroyed
273
   */
274
  removeParameterCallback(handle) {
275
    this.#checkNotDestroyed();
4✔
276

277
    if (!(handle instanceof ParameterCallbackHandle)) {
4!
UNCOV
278
      throw new TypeValidationError(
×
279
        'handle',
280
        handle,
281
        'ParameterCallbackHandle',
282
        { entityType: 'parameter event handler' }
283
      );
284
    }
285

286
    const key = this.#makeKey(handle.parameterName, handle.nodeName);
4✔
287
    const callbacks = this.#parameterCallbacks.get(key);
4✔
288

289
    if (!callbacks) {
4✔
290
      throw new OperationError(
1✔
291
        `No callbacks registered for parameter '${handle.parameterName}' on node '${handle.nodeName}'`,
292
        { entityType: 'parameter event handler' }
293
      );
294
    }
295

296
    const index = callbacks.indexOf(handle);
3✔
297
    if (index === -1) {
3!
UNCOV
298
      throw new OperationError("Callback doesn't exist", {
×
299
        entityType: 'parameter event handler',
300
      });
301
    }
302

303
    callbacks.splice(index, 1);
3✔
304

305
    if (callbacks.length === 0) {
3!
306
      this.#parameterCallbacks.delete(key);
3✔
307
    }
308

309
    debug(
3✔
310
      'Removed parameter callback: param=%s node=%s',
311
      handle.parameterName,
312
      handle.nodeName
313
    );
314
  }
315

316
  /**
317
   * Add a callback that is invoked for every parameter event.
318
   *
319
   * The callback receives the full ParameterEvent message
320
   * (rcl_interfaces/msg/ParameterEvent) with `node`, `new_parameters`,
321
   * `changed_parameters`, and `deleted_parameters` fields.
322
   *
323
   * @param {Function} callback - Called with (event) for every ParameterEvent
324
   * @returns {ParameterEventCallbackHandle} Handle for removing this callback later
325
   * @throws {Error} If the handler has been destroyed
326
   * @throws {TypeError} If callback is not a function
327
   */
328
  addParameterEventCallback(callback) {
329
    this.#checkNotDestroyed();
47✔
330

331
    if (typeof callback !== 'function') {
46✔
332
      throw new TypeValidationError('callback', callback, 'function', {
1✔
333
        entityType: 'parameter event handler',
334
      });
335
    }
336

337
    const handle = new ParameterEventCallbackHandle(callback);
45✔
338

339
    // Insert at front (FILO order)
340
    this.#eventCallbacks.unshift(handle);
45✔
341

342
    debug('Added parameter event callback');
45✔
343

344
    return handle;
45✔
345
  }
346

347
  /**
348
   * Remove a previously added parameter event callback.
349
   *
350
   * @param {ParameterEventCallbackHandle} handle - The handle returned by addParameterEventCallback
351
   * @throws {Error} If the handle is not found or handler is destroyed
352
   */
353
  removeParameterEventCallback(handle) {
354
    this.#checkNotDestroyed();
3✔
355

356
    if (!(handle instanceof ParameterEventCallbackHandle)) {
3!
UNCOV
357
      throw new TypeValidationError(
×
358
        'handle',
359
        handle,
360
        'ParameterEventCallbackHandle',
361
        { entityType: 'parameter event handler' }
362
      );
363
    }
364

365
    const index = this.#eventCallbacks.indexOf(handle);
3✔
366
    if (index === -1) {
3✔
367
      throw new OperationError("Callback doesn't exist", {
1✔
368
        entityType: 'parameter event handler',
369
      });
370
    }
371

372
    this.#eventCallbacks.splice(index, 1);
2✔
373

374
    debug('Removed parameter event callback');
2✔
375
  }
376

377
  /**
378
   * Check if the handler has been destroyed.
379
   *
380
   * @returns {boolean} True if destroyed
381
   */
382
  isDestroyed() {
383
    return this.#destroyed;
28✔
384
  }
385

386
  /**
387
   * Destroy the handler and clean up resources.
388
   * Removes the subscription and clears all callbacks.
389
   */
390
  destroy() {
391
    if (this.#destroyed) {
83✔
392
      return;
18✔
393
    }
394

395
    debug('Destroying ParameterEventHandler');
65✔
396

397
    if (this.#subscription) {
65!
398
      try {
65✔
399
        this.#node.destroySubscription(this.#subscription);
65✔
400
      } catch (error) {
UNCOV
401
        debug('Error destroying subscription: %s', error.message);
×
402
      }
403
      this.#subscription = null;
65✔
404
    }
405

406
    this.#parameterCallbacks.clear();
65✔
407
    this.#eventCallbacks.length = 0;
65✔
408
    this.#destroyed = true;
65✔
409
  }
410

411
  /**
412
   * Get a specific parameter from a ParameterEvent message.
413
   *
414
   * @param {object} event - A ParameterEvent message
415
   * @param {string} parameterName - The parameter name to look for
416
   * @param {string} nodeName - The node name to match
417
   * @returns {object|null} The matching parameter message, or null
418
   * @static
419
   */
420
  static getParameterFromEvent(event, parameterName, nodeName) {
421
    const resolvedNodeName = normalizeNodeName(nodeName);
2✔
422
    const resolvedParamName = (parameterName || '').trim();
2!
423

424
    if (normalizeNodeName(event.node) !== resolvedNodeName) {
2✔
425
      return null;
1✔
426
    }
427

428
    const allParams = [
1✔
429
      ...(event.new_parameters || []),
1!
430
      ...(event.changed_parameters || []),
1!
431
    ];
432

433
    for (const param of allParams) {
1✔
434
      if (param.name === resolvedParamName) {
2✔
435
        return param;
1✔
436
      }
437
    }
438

UNCOV
439
    return null;
×
440
  }
441

442
  /**
443
   * Get all parameters from a ParameterEvent message (new + changed).
444
   *
445
   * @param {object} event - A ParameterEvent message
446
   * @returns {object[]} Array of parameter messages
447
   * @static
448
   */
449
  static getParametersFromEvent(event) {
450
    return [
1✔
451
      ...(event.new_parameters || []),
1!
452
      ...(event.changed_parameters || []),
1!
453
    ];
454
  }
455

456
  /**
457
   * Handle incoming parameter event.
458
   * @private
459
   */
460
  #handleEvent(event) {
461
    const eventNodeName = normalizeNodeName(event.node);
17✔
462

463
    // Dispatch parameter-specific callbacks by iterating event params
464
    // and doing direct Map lookups (O(event_params) instead of O(registered_callbacks))
465
    const allParams = [
17✔
466
      ...(event.new_parameters || []),
17!
467
      ...(event.changed_parameters || []),
17!
468
    ];
469

470
    for (const parameter of allParams) {
17✔
471
      const key = this.#makeKey(parameter.name, eventNodeName);
17✔
472
      const callbacks = this.#parameterCallbacks.get(key);
17✔
473

474
      if (callbacks) {
17✔
475
        for (const handle of callbacks.slice()) {
1✔
476
          try {
1✔
477
            handle.callback(parameter);
1✔
478
          } catch (err) {
UNCOV
479
            debug(
×
480
              'Error in parameter callback for %s on %s: %s',
481
              parameter.name,
482
              eventNodeName,
483
              err.message
484
            );
485
          }
486
        }
487
      }
488
    }
489

490
    // Dispatch event-level callbacks
491
    for (const handle of this.#eventCallbacks.slice()) {
17✔
492
      try {
13✔
493
        handle.callback(event);
13✔
494
      } catch (err) {
UNCOV
495
        debug('Error in parameter event callback: %s', err.message);
×
496
      }
497
    }
498
  }
499

500
  /**
501
   * Create a map key from parameter name and node name.
502
   * @private
503
   */
504
  #makeKey(paramName, nodeName) {
505
    return `${paramName}\0${nodeName}`;
28✔
506
  }
507

508
  /**
509
   * Resolve a node path to the fully qualified name used in ParameterEvent.node.
510
   * @private
511
   */
512
  #resolvePath(nodePath) {
513
    if (!nodePath) {
3!
NEW
514
      return this.#node.getFullyQualifiedName();
×
515
    }
516

517
    if (nodePath.startsWith('/')) {
3✔
518
      return nodePath;
2✔
519
    }
520

521
    const nodeNamespace = this.#node.namespace().replace(/\/+$/, '');
1✔
522
    const resolvedPath = `${nodeNamespace}/${nodePath}`.replace(/\/+/g, '/');
1✔
523
    return resolvedPath.startsWith('/') ? resolvedPath : `/${resolvedPath}`;
1!
524
  }
525

526
  /**
527
   * Check if the handler has been destroyed and throw if so.
528
   * @private
529
   */
530
  #checkNotDestroyed() {
531
    if (this.#destroyed) {
72✔
532
      throw new OperationError('ParameterEventHandler has been destroyed', {
2✔
533
        entityType: 'parameter event handler',
534
      });
535
    }
536
  }
537
}
538

539
module.exports = ParameterEventHandler;
26✔
540
module.exports.ParameterCallbackHandle = ParameterCallbackHandle;
26✔
541
module.exports.ParameterEventCallbackHandle = ParameterEventCallbackHandle;
26✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc