• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

RobotWebTools / rclnodejs / 23940401301

03 Apr 2026 08:47AM UTC coverage: 85.411% (-0.4%) from 85.83%
23940401301

push

github

minggangw
Add ParameterEventHandler node filtering support (#1474)

- add `ParameterEventHandler.configureNodesFilter(nodeNames?)` to apply or clear `/parameter_events` subscription content filters for selected nodes
- resolve relative node names against the handler node namespace
- reuse `Node.getFullyQualifiedName()` for handler node fully qualified name resolution instead of duplicating that logic
- add TypeScript declarations for `configureNodesFilter()`
- add focused `ParameterEventHandler` tests for:
  - absolute node filters
  - relative node name resolution
  - clearing filters when `nodeNames` is omitted
  - clearing filters when `nodeNames` is empty
  - invalid `nodeNames` validation

Testing:
- `npx mocha test/test-parameter-event-handler.js`
- `npx tsd`

Fix: #1473

1565 of 1992 branches covered (78.56%)

Branch coverage included in aggregate %.

31 of 32 new or added lines in 1 file covered. (96.88%)

59 existing lines in 1 file now uncovered.

3177 of 3560 relevant lines covered (89.24%)

445.38 hits per line

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

88.11
/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 validator = require('./validator');
26✔
20
const debug = require('debug')('rclnodejs:parameter_event_handler');
26✔
21

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

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

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

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

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

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

132
    const opts = options || {};
66!
133

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

139
    const subscriptionOptions = opts.qos ? { qos: opts.qos } : undefined;
66!
140

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

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

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

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

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

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

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

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

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

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

211
    return handle;
7✔
212
  }
213

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

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

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

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

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

256
      const resolvedNodeName = this.#resolvePath(nodeName.trim());
7✔
257
      this.#validateFullyQualifiedNodePath(resolvedNodeName);
7✔
258
      return resolvedNodeName;
5✔
259
    });
260

261
    const contentFilter = {
3✔
262
      expression: resolvedNodeNames
263
        .map((_, index) => `node = %${index}`)
5✔
264
        .join(' OR '),
265
      parameters: resolvedNodeNames.map((nodeName) => `'${nodeName}'`),
5✔
266
    };
267

268
    this.#subscription.setContentFilter(contentFilter);
3✔
269
    return this.#subscription.hasContentFilter();
3✔
270
  }
271

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

281
    if (!(handle instanceof ParameterCallbackHandle)) {
4!
282
      throw new TypeValidationError(
×
283
        'handle',
284
        handle,
285
        'ParameterCallbackHandle',
286
        { entityType: 'parameter event handler' }
287
      );
288
    }
289

290
    const key = this.#makeKey(handle.parameterName, handle.nodeName);
4✔
291
    const callbacks = this.#parameterCallbacks.get(key);
4✔
292

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

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

307
    callbacks.splice(index, 1);
3✔
308

309
    if (callbacks.length === 0) {
3!
310
      this.#parameterCallbacks.delete(key);
3✔
311
    }
312

313
    debug(
3✔
314
      'Removed parameter callback: param=%s node=%s',
315
      handle.parameterName,
316
      handle.nodeName
317
    );
318
  }
319

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

335
    if (typeof callback !== 'function') {
46✔
336
      throw new TypeValidationError('callback', callback, 'function', {
1✔
337
        entityType: 'parameter event handler',
338
      });
339
    }
340

341
    const handle = new ParameterEventCallbackHandle(callback);
45✔
342

343
    // Insert at front (FILO order)
344
    this.#eventCallbacks.unshift(handle);
45✔
345

346
    debug('Added parameter event callback');
45✔
347

348
    return handle;
45✔
349
  }
350

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

360
    if (!(handle instanceof ParameterEventCallbackHandle)) {
3!
361
      throw new TypeValidationError(
×
362
        'handle',
363
        handle,
364
        'ParameterEventCallbackHandle',
365
        { entityType: 'parameter event handler' }
366
      );
367
    }
368

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

376
    this.#eventCallbacks.splice(index, 1);
2✔
377

378
    debug('Removed parameter event callback');
2✔
379
  }
380

381
  /**
382
   * Check if the handler has been destroyed.
383
   *
384
   * @returns {boolean} True if destroyed
385
   */
386
  isDestroyed() {
387
    return this.#destroyed;
29✔
388
  }
389

390
  /**
391
   * Destroy the handler and clean up resources.
392
   * Removes the subscription and clears all callbacks.
393
   */
394
  destroy() {
395
    if (this.#destroyed) {
84✔
396
      return;
18✔
397
    }
398

399
    debug('Destroying ParameterEventHandler');
66✔
400

401
    if (this.#subscription) {
66!
402
      try {
66✔
403
        this.#node.destroySubscription(this.#subscription);
66✔
404
      } catch (error) {
405
        debug('Error destroying subscription: %s', error.message);
×
406
      }
407
      this.#subscription = null;
66✔
408
    }
409

410
    this.#parameterCallbacks.clear();
66✔
411
    this.#eventCallbacks.length = 0;
66✔
412
    this.#destroyed = true;
66✔
413
  }
414

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

428
    if (normalizeNodeName(event.node) !== resolvedNodeName) {
2✔
429
      return null;
1✔
430
    }
431

432
    const allParams = [
1✔
433
      ...(event.new_parameters || []),
1!
434
      ...(event.changed_parameters || []),
1!
435
    ];
436

437
    for (const param of allParams) {
1✔
438
      if (param.name === resolvedParamName) {
2✔
439
        return param;
1✔
440
      }
441
    }
442

443
    return null;
×
444
  }
445

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

460
  /**
461
   * Handle incoming parameter event.
462
   * @private
463
   */
464
  #handleEvent(event) {
465
    const eventNodeName = normalizeNodeName(event.node);
18✔
466

467
    // Dispatch parameter-specific callbacks by iterating event params
468
    // and doing direct Map lookups (O(event_params) instead of O(registered_callbacks))
469
    const allParams = [
18✔
470
      ...(event.new_parameters || []),
18!
471
      ...(event.changed_parameters || []),
18!
472
    ];
473

474
    for (const parameter of allParams) {
18✔
475
      const key = this.#makeKey(parameter.name, eventNodeName);
18✔
476
      const callbacks = this.#parameterCallbacks.get(key);
18✔
477

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

494
    // Dispatch event-level callbacks
495
    for (const handle of this.#eventCallbacks.slice()) {
18✔
496
      try {
14✔
497
        handle.callback(event);
14✔
498
      } catch (err) {
499
        debug('Error in parameter event callback: %s', err.message);
×
500
      }
501
    }
502
  }
503

504
  /**
505
   * Create a map key from parameter name and node name.
506
   * @private
507
   */
508
  #makeKey(paramName, nodeName) {
509
    return `${paramName}\0${nodeName}`;
29✔
510
  }
511

512
  /**
513
   * Resolve a node path to the fully qualified name used in ParameterEvent.node.
514
   * @private
515
   */
516
  #resolvePath(nodePath) {
517
    // Absolute node paths are already rooted. Relative names are resolved
518
    // against the handler node namespace before building the content filter.
519
    const unresolvedPath = nodePath.startsWith('/')
7✔
520
      ? nodePath
521
      : `${this.#node.namespace().replace(/\/+$/, '')}/${nodePath}`;
522

523
    // Collapse repeated separators for inputs like '/ns//node/' or 'nested//node'.
524
    const resolvedPath = unresolvedPath.replace(/\/+/g, '/');
7✔
525

526
    // Preserve the root namespace as '/' and strip trailing slashes everywhere
527
    // else so the filter matches the canonical ParameterEvent.node format.
528
    if (resolvedPath === '/') {
7!
NEW
529
      return resolvedPath;
×
530
    }
531

532
    return resolvedPath.replace(/\/+$/, '');
7✔
533
  }
534

535
  /**
536
   * Validate a fully qualified node path before using it in a content filter.
537
   * @private
538
   */
539
  #validateFullyQualifiedNodePath(nodePath) {
540
    const normalizedPath =
541
      nodePath.length > 1 ? nodePath.replace(/\/+$/, '') : nodePath;
7!
542
    const separatorIndex = normalizedPath.lastIndexOf('/');
7✔
543
    const nodeNamespace =
544
      separatorIndex === 0 ? '/' : normalizedPath.slice(0, separatorIndex);
7✔
545
    const nodeName = normalizedPath.slice(separatorIndex + 1);
7✔
546

547
    validator.validateNamespace(nodeNamespace);
7✔
548
    validator.validateNodeName(nodeName);
7✔
549
  }
550

551
  /**
552
   * Check if the handler has been destroyed and throw if so.
553
   * @private
554
   */
555
  #checkNotDestroyed() {
556
    if (this.#destroyed) {
75✔
557
      throw new OperationError('ParameterEventHandler has been destroyed', {
2✔
558
        entityType: 'parameter event handler',
559
      });
560
    }
561
  }
562
}
563

564
module.exports = ParameterEventHandler;
26✔
565
module.exports.ParameterCallbackHandle = ParameterCallbackHandle;
26✔
566
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