rclnodejs 1.8.3 → 1.9.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.
Files changed (90) hide show
  1. package/README.md +46 -37
  2. package/index.js +39 -0
  3. package/lib/action/client.js +61 -3
  4. package/lib/action/server_goal_handle.js +26 -1
  5. package/lib/message_info.js +94 -0
  6. package/lib/node.js +260 -13
  7. package/lib/parameter_event_handler.js +566 -0
  8. package/lib/parameter_watcher.js +12 -12
  9. package/lib/qos_overriding_options.js +358 -0
  10. package/lib/subscription.js +38 -5
  11. package/lib/timer.js +3 -2
  12. package/lib/wait_for_message.js +111 -0
  13. package/package.json +7 -4
  14. package/prebuilds/linux-arm64/humble-jammy-arm64-rclnodejs.node +0 -0
  15. package/prebuilds/linux-arm64/jazzy-noble-arm64-rclnodejs.node +0 -0
  16. package/prebuilds/linux-arm64/kilted-noble-arm64-rclnodejs.node +0 -0
  17. package/prebuilds/linux-x64/humble-jammy-x64-rclnodejs.node +0 -0
  18. package/prebuilds/linux-x64/jazzy-noble-x64-rclnodejs.node +0 -0
  19. package/prebuilds/linux-x64/kilted-noble-x64-rclnodejs.node +0 -0
  20. package/scripts/run_asan_test.sh +118 -0
  21. package/src/executor.cpp +36 -2
  22. package/src/executor.h +11 -0
  23. package/src/rcl_action_client_bindings.cpp +70 -1
  24. package/src/rcl_context_bindings.cpp +3 -3
  25. package/src/rcl_graph_bindings.cpp +2 -2
  26. package/src/rcl_subscription_bindings.cpp +70 -2
  27. package/src/rcl_timer_bindings.cpp +21 -2
  28. package/src/rcl_utilities.cpp +2 -2
  29. package/tools/jsdoc/Makefile +5 -0
  30. package/tools/jsdoc/README.md +96 -0
  31. package/tools/jsdoc/build-index.js +610 -0
  32. package/tools/jsdoc/publish.js +854 -0
  33. package/tools/jsdoc/regenerate-published-docs.js +605 -0
  34. package/tools/jsdoc/static/fonts/OpenSans-Bold-webfont.eot +0 -0
  35. package/tools/jsdoc/static/fonts/OpenSans-Bold-webfont.svg +1830 -0
  36. package/tools/jsdoc/static/fonts/OpenSans-Bold-webfont.woff +0 -0
  37. package/tools/jsdoc/static/fonts/OpenSans-BoldItalic-webfont.eot +0 -0
  38. package/tools/jsdoc/static/fonts/OpenSans-BoldItalic-webfont.svg +1830 -0
  39. package/tools/jsdoc/static/fonts/OpenSans-BoldItalic-webfont.woff +0 -0
  40. package/tools/jsdoc/static/fonts/OpenSans-Italic-webfont.eot +0 -0
  41. package/tools/jsdoc/static/fonts/OpenSans-Italic-webfont.svg +1830 -0
  42. package/tools/jsdoc/static/fonts/OpenSans-Italic-webfont.woff +0 -0
  43. package/tools/jsdoc/static/fonts/OpenSans-Light-webfont.eot +0 -0
  44. package/tools/jsdoc/static/fonts/OpenSans-Light-webfont.svg +1831 -0
  45. package/tools/jsdoc/static/fonts/OpenSans-Light-webfont.woff +0 -0
  46. package/tools/jsdoc/static/fonts/OpenSans-LightItalic-webfont.eot +0 -0
  47. package/tools/jsdoc/static/fonts/OpenSans-LightItalic-webfont.svg +1835 -0
  48. package/tools/jsdoc/static/fonts/OpenSans-LightItalic-webfont.woff +0 -0
  49. package/tools/jsdoc/static/fonts/OpenSans-Regular-webfont.eot +0 -0
  50. package/tools/jsdoc/static/fonts/OpenSans-Regular-webfont.svg +1831 -0
  51. package/tools/jsdoc/static/fonts/OpenSans-Regular-webfont.woff +0 -0
  52. package/tools/jsdoc/static/scripts/linenumber.js +25 -0
  53. package/tools/jsdoc/static/scripts/prettify/Apache-License-2.0.txt +202 -0
  54. package/tools/jsdoc/static/scripts/prettify/lang-css.js +36 -0
  55. package/tools/jsdoc/static/scripts/prettify/prettify.js +738 -0
  56. package/tools/jsdoc/static/styles/jsdoc-default.css +1012 -0
  57. package/tools/jsdoc/static/styles/prettify-jsdoc.css +111 -0
  58. package/tools/jsdoc/static/styles/prettify-tomorrow.css +132 -0
  59. package/tools/jsdoc/tmpl/augments.tmpl +10 -0
  60. package/tools/jsdoc/tmpl/container.tmpl +193 -0
  61. package/tools/jsdoc/tmpl/details.tmpl +143 -0
  62. package/tools/jsdoc/tmpl/example.tmpl +2 -0
  63. package/tools/jsdoc/tmpl/examples.tmpl +13 -0
  64. package/tools/jsdoc/tmpl/exceptions.tmpl +17 -0
  65. package/tools/jsdoc/tmpl/layout.tmpl +83 -0
  66. package/tools/jsdoc/tmpl/mainpage.tmpl +163 -0
  67. package/tools/jsdoc/tmpl/members.tmpl +43 -0
  68. package/tools/jsdoc/tmpl/method.tmpl +124 -0
  69. package/tools/jsdoc/tmpl/params.tmpl +133 -0
  70. package/tools/jsdoc/tmpl/properties.tmpl +110 -0
  71. package/tools/jsdoc/tmpl/returns.tmpl +12 -0
  72. package/tools/jsdoc/tmpl/source.tmpl +8 -0
  73. package/tools/jsdoc/tmpl/tutorial.tmpl +19 -0
  74. package/tools/jsdoc/tmpl/type.tmpl +7 -0
  75. package/types/action_client.d.ts +8 -0
  76. package/types/index.d.ts +34 -0
  77. package/types/message_info.d.ts +72 -0
  78. package/types/node.d.ts +90 -5
  79. package/types/parameter_event_handler.d.ts +150 -0
  80. package/types/qos.d.ts +55 -0
  81. package/types/subscription.d.ts +14 -2
  82. package/types/timer.d.ts +3 -2
  83. package/test_data_integrity.js +0 -108
  84. package/test_repro_exact.js +0 -57
  85. package/test_repro_hz.js +0 -86
  86. package/test_repro_pub.js +0 -36
  87. package/test_repro_stress.js +0 -83
  88. package/test_repro_sub.js +0 -64
  89. package/test_xproc_data.js +0 -64
  90. package/types/interfaces.d.ts +0 -8895
@@ -0,0 +1,566 @@
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');
18
+ const { normalizeNodeName } = require('./utils');
19
+ const validator = require('./validator');
20
+ const debug = require('debug')('rclnodejs:parameter_event_handler');
21
+
22
+ const PARAMETER_EVENT_MSG_TYPE = 'rcl_interfaces/msg/ParameterEvent';
23
+ const PARAMETER_EVENT_TOPIC = '/parameter_events';
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;
39
+ this.nodeName = nodeName;
40
+ this.callback = callback;
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;
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 = {}) {
116
+ if (!node || typeof node.createSubscription !== 'function') {
117
+ throw new TypeValidationError('node', node, 'Node instance', {
118
+ entityType: 'parameter event handler',
119
+ });
120
+ }
121
+
122
+ if (
123
+ options !== undefined &&
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 || {};
133
+
134
+ this.#node = node;
135
+ this.#parameterCallbacks = new Map();
136
+ this.#eventCallbacks = [];
137
+ this.#destroyed = false;
138
+
139
+ const subscriptionOptions = opts.qos ? { qos: opts.qos } : undefined;
140
+
141
+ this.#subscription = node.createSubscription(
142
+ PARAMETER_EVENT_MSG_TYPE,
143
+ PARAMETER_EVENT_TOPIC,
144
+ subscriptionOptions,
145
+ (event) => this.#handleEvent(event)
146
+ );
147
+
148
+ debug('Created ParameterEventHandler on node=%s', node.name());
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();
167
+
168
+ if (typeof parameterName !== 'string' || parameterName.trim() === '') {
169
+ throw new TypeValidationError(
170
+ 'parameterName',
171
+ parameterName,
172
+ 'non-empty string',
173
+ { entityType: 'parameter event handler' }
174
+ );
175
+ }
176
+
177
+ if (typeof nodeName !== 'string' || nodeName.trim() === '') {
178
+ throw new TypeValidationError('nodeName', nodeName, 'non-empty string', {
179
+ entityType: 'parameter event handler',
180
+ });
181
+ }
182
+
183
+ if (typeof callback !== 'function') {
184
+ throw new TypeValidationError('callback', callback, 'function', {
185
+ entityType: 'parameter event handler',
186
+ });
187
+ }
188
+
189
+ const resolvedNodeName = normalizeNodeName(nodeName);
190
+ const resolvedParamName = parameterName.trim();
191
+ const handle = new ParameterCallbackHandle(
192
+ resolvedParamName,
193
+ resolvedNodeName,
194
+ callback
195
+ );
196
+ const key = this.#makeKey(resolvedParamName, resolvedNodeName);
197
+
198
+ if (!this.#parameterCallbacks.has(key)) {
199
+ this.#parameterCallbacks.set(key, []);
200
+ }
201
+
202
+ // Insert at front (FILO order, matching rclpy behavior)
203
+ this.#parameterCallbacks.get(key).unshift(handle);
204
+
205
+ debug(
206
+ 'Added parameter callback: param=%s node=%s',
207
+ resolvedParamName,
208
+ resolvedNodeName
209
+ );
210
+
211
+ return handle;
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();
227
+
228
+ if (nodeNames === undefined || nodeNames === null) {
229
+ this.#subscription.clearContentFilter();
230
+ return !this.#subscription.hasContentFilter();
231
+ }
232
+
233
+ if (!Array.isArray(nodeNames)) {
234
+ throw new TypeValidationError('nodeNames', nodeNames, 'string[]', {
235
+ entityType: 'parameter event handler',
236
+ });
237
+ }
238
+
239
+ if (nodeNames.length === 0) {
240
+ this.#subscription.clearContentFilter();
241
+ return !this.#subscription.hasContentFilter();
242
+ }
243
+
244
+ const resolvedNodeNames = nodeNames.map((nodeName, index) => {
245
+ if (typeof nodeName !== 'string' || nodeName.trim() === '') {
246
+ throw new TypeValidationError(
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());
257
+ this.#validateFullyQualifiedNodePath(resolvedNodeName);
258
+ return resolvedNodeName;
259
+ });
260
+
261
+ const contentFilter = {
262
+ expression: resolvedNodeNames
263
+ .map((_, index) => `node = %${index}`)
264
+ .join(' OR '),
265
+ parameters: resolvedNodeNames.map((nodeName) => `'${nodeName}'`),
266
+ };
267
+
268
+ this.#subscription.setContentFilter(contentFilter);
269
+ return this.#subscription.hasContentFilter();
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();
280
+
281
+ if (!(handle instanceof ParameterCallbackHandle)) {
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);
291
+ const callbacks = this.#parameterCallbacks.get(key);
292
+
293
+ if (!callbacks) {
294
+ throw new OperationError(
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);
301
+ if (index === -1) {
302
+ throw new OperationError("Callback doesn't exist", {
303
+ entityType: 'parameter event handler',
304
+ });
305
+ }
306
+
307
+ callbacks.splice(index, 1);
308
+
309
+ if (callbacks.length === 0) {
310
+ this.#parameterCallbacks.delete(key);
311
+ }
312
+
313
+ debug(
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();
334
+
335
+ if (typeof callback !== 'function') {
336
+ throw new TypeValidationError('callback', callback, 'function', {
337
+ entityType: 'parameter event handler',
338
+ });
339
+ }
340
+
341
+ const handle = new ParameterEventCallbackHandle(callback);
342
+
343
+ // Insert at front (FILO order)
344
+ this.#eventCallbacks.unshift(handle);
345
+
346
+ debug('Added parameter event callback');
347
+
348
+ return handle;
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();
359
+
360
+ if (!(handle instanceof ParameterEventCallbackHandle)) {
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);
370
+ if (index === -1) {
371
+ throw new OperationError("Callback doesn't exist", {
372
+ entityType: 'parameter event handler',
373
+ });
374
+ }
375
+
376
+ this.#eventCallbacks.splice(index, 1);
377
+
378
+ debug('Removed parameter event callback');
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;
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) {
396
+ return;
397
+ }
398
+
399
+ debug('Destroying ParameterEventHandler');
400
+
401
+ if (this.#subscription) {
402
+ try {
403
+ this.#node.destroySubscription(this.#subscription);
404
+ } catch (error) {
405
+ debug('Error destroying subscription: %s', error.message);
406
+ }
407
+ this.#subscription = null;
408
+ }
409
+
410
+ this.#parameterCallbacks.clear();
411
+ this.#eventCallbacks.length = 0;
412
+ this.#destroyed = true;
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);
426
+ const resolvedParamName = (parameterName || '').trim();
427
+
428
+ if (normalizeNodeName(event.node) !== resolvedNodeName) {
429
+ return null;
430
+ }
431
+
432
+ const allParams = [
433
+ ...(event.new_parameters || []),
434
+ ...(event.changed_parameters || []),
435
+ ];
436
+
437
+ for (const param of allParams) {
438
+ if (param.name === resolvedParamName) {
439
+ return param;
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 [
455
+ ...(event.new_parameters || []),
456
+ ...(event.changed_parameters || []),
457
+ ];
458
+ }
459
+
460
+ /**
461
+ * Handle incoming parameter event.
462
+ * @private
463
+ */
464
+ #handleEvent(event) {
465
+ const eventNodeName = normalizeNodeName(event.node);
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 = [
470
+ ...(event.new_parameters || []),
471
+ ...(event.changed_parameters || []),
472
+ ];
473
+
474
+ for (const parameter of allParams) {
475
+ const key = this.#makeKey(parameter.name, eventNodeName);
476
+ const callbacks = this.#parameterCallbacks.get(key);
477
+
478
+ if (callbacks) {
479
+ for (const handle of callbacks.slice()) {
480
+ try {
481
+ handle.callback(parameter);
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()) {
496
+ try {
497
+ handle.callback(event);
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}`;
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('/')
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, '/');
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 === '/') {
529
+ return resolvedPath;
530
+ }
531
+
532
+ return resolvedPath.replace(/\/+$/, '');
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;
542
+ const separatorIndex = normalizedPath.lastIndexOf('/');
543
+ const nodeNamespace =
544
+ separatorIndex === 0 ? '/' : normalizedPath.slice(0, separatorIndex);
545
+ const nodeName = normalizedPath.slice(separatorIndex + 1);
546
+
547
+ validator.validateNamespace(nodeNamespace);
548
+ validator.validateNodeName(nodeName);
549
+ }
550
+
551
+ /**
552
+ * Check if the handler has been destroyed and throw if so.
553
+ * @private
554
+ */
555
+ #checkNotDestroyed() {
556
+ if (this.#destroyed) {
557
+ throw new OperationError('ParameterEventHandler has been destroyed', {
558
+ entityType: 'parameter event handler',
559
+ });
560
+ }
561
+ }
562
+ }
563
+
564
+ module.exports = ParameterEventHandler;
565
+ module.exports.ParameterCallbackHandle = ParameterCallbackHandle;
566
+ module.exports.ParameterEventCallbackHandle = ParameterEventCallbackHandle;
@@ -17,6 +17,7 @@
17
17
  const EventEmitter = require('events');
18
18
  const { TypeValidationError, OperationError } = require('./errors');
19
19
  const { normalizeNodeName } = require('./utils');
20
+ const ParameterEventHandler = require('./parameter_event_handler.js');
20
21
  const debug = require('debug')('rclnodejs:parameter_watcher');
21
22
 
22
23
  /**
@@ -30,7 +31,7 @@ const debug = require('debug')('rclnodejs:parameter_watcher');
30
31
  class ParameterWatcher extends EventEmitter {
31
32
  #node;
32
33
  #paramClient;
33
- #subscription;
34
+ #eventHandler;
34
35
  #watchedParams;
35
36
  #remoteNodeName;
36
37
  #destroyed;
@@ -82,7 +83,7 @@ class ParameterWatcher extends EventEmitter {
82
83
  this.#paramClient = node.createParameterClient(remoteNodeName, options);
83
84
  // Cache the remote node name for error messages (in case paramClient is destroyed)
84
85
  this.#remoteNodeName = this.#paramClient.remoteNodeName;
85
- this.#subscription = null;
86
+ this.#eventHandler = null;
86
87
  this.#destroyed = false;
87
88
 
88
89
  debug(
@@ -133,14 +134,13 @@ class ParameterWatcher extends EventEmitter {
133
134
  return false;
134
135
  }
135
136
 
136
- if (!this.#subscription) {
137
- this.#subscription = this.#node.createSubscription(
138
- 'rcl_interfaces/msg/ParameterEvent',
139
- '/parameter_events',
140
- (event) => this.#handleParameterEvent(event)
137
+ if (!this.#eventHandler) {
138
+ this.#eventHandler = new ParameterEventHandler(this.#node);
139
+ this.#eventHandler.addParameterEventCallback((event) =>
140
+ this.#handleParameterEvent(event)
141
141
  );
142
142
 
143
- debug('Subscribed to /parameter_events');
143
+ debug('Subscribed to /parameter_events via ParameterEventHandler');
144
144
  }
145
145
 
146
146
  return true;
@@ -227,13 +227,13 @@ class ParameterWatcher extends EventEmitter {
227
227
 
228
228
  debug('Destroying ParameterWatcher for node=%s', this.remoteNodeName);
229
229
 
230
- if (this.#subscription) {
230
+ if (this.#eventHandler) {
231
231
  try {
232
- this.#node.destroySubscription(this.#subscription);
232
+ this.#eventHandler.destroy();
233
233
  } catch (error) {
234
- debug('Error destroying subscription: %s', error.message);
234
+ debug('Error destroying event handler: %s', error.message);
235
235
  }
236
- this.#subscription = null;
236
+ this.#eventHandler = null;
237
237
  }
238
238
 
239
239
  if (this.#paramClient) {