Skip to content

Commit 64322f3

Browse files
committed
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
1 parent 5ac338a commit 64322f3

4 files changed

Lines changed: 289 additions & 0 deletions

File tree

lib/parameter_event_handler.js

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
const { TypeValidationError, OperationError } = require('./errors');
1818
const { normalizeNodeName } = require('./utils');
19+
const validator = require('./validator');
1920
const debug = require('debug')('rclnodejs:parameter_event_handler');
2021

2122
const PARAMETER_EVENT_MSG_TYPE = 'rcl_interfaces/msg/ParameterEvent';
@@ -210,6 +211,64 @@ class ParameterEventHandler {
210211
return handle;
211212
}
212213

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+
213272
/**
214273
* Remove a previously added parameter callback.
215274
*
@@ -450,6 +509,45 @@ class ParameterEventHandler {
450509
return `${paramName}\0${nodeName}`;
451510
}
452511

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+
453551
/**
454552
* Check if the handler has been destroyed and throw if so.
455553
* @private

test/test-parameter-event-handler.js

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,16 @@
1717
const assert = require('assert');
1818
const rclnodejs = require('../index.js');
1919

20+
function createFakeHandlerNode(subscription) {
21+
return {
22+
createSubscription: () => subscription,
23+
destroySubscription: () => {},
24+
getFullyQualifiedName: () => '/test_ns/peh_handler_node',
25+
name: () => 'peh_handler_node',
26+
namespace: () => '/test_ns',
27+
};
28+
}
29+
2030
describe('ParameterEventHandler tests', function () {
2131
this.timeout(60 * 1000);
2232

@@ -297,6 +307,154 @@ describe('ParameterEventHandler tests', function () {
297307
});
298308
});
299309

310+
describe('configureNodesFilter', function () {
311+
it('should apply a content filter for absolute node names', function () {
312+
let hasFilter = false;
313+
let lastFilter;
314+
const subscription = {
315+
setContentFilter: (filter) => {
316+
lastFilter = filter;
317+
hasFilter = true;
318+
return true;
319+
},
320+
clearContentFilter: () => {
321+
hasFilter = false;
322+
return true;
323+
},
324+
hasContentFilter: () => hasFilter,
325+
};
326+
327+
handler = new rclnodejs.ParameterEventHandler(
328+
createFakeHandlerNode(subscription)
329+
);
330+
331+
assert.strictEqual(
332+
handler.configureNodesFilter(['/remote_node_1', '/remote_node_2']),
333+
true
334+
);
335+
assert.deepStrictEqual(lastFilter, {
336+
expression: 'node = %0 OR node = %1',
337+
parameters: ["'/remote_node_1'", "'/remote_node_2'"],
338+
});
339+
});
340+
341+
it('should resolve relative node names against the handler namespace', function () {
342+
let lastFilter;
343+
const subscription = {
344+
setContentFilter: (filter) => {
345+
lastFilter = filter;
346+
return true;
347+
},
348+
clearContentFilter: () => true,
349+
hasContentFilter: () => true,
350+
};
351+
352+
handler = new rclnodejs.ParameterEventHandler(
353+
createFakeHandlerNode(subscription)
354+
);
355+
356+
assert.strictEqual(handler.configureNodesFilter(['remote_node']), true);
357+
assert.deepStrictEqual(lastFilter, {
358+
expression: 'node = %0',
359+
parameters: ["'/test_ns/remote_node'"],
360+
});
361+
});
362+
363+
it('should normalize repeated and trailing slashes in node names', function () {
364+
let lastFilter;
365+
const subscription = {
366+
setContentFilter: (filter) => {
367+
lastFilter = filter;
368+
return true;
369+
},
370+
clearContentFilter: () => true,
371+
hasContentFilter: () => true,
372+
};
373+
374+
handler = new rclnodejs.ParameterEventHandler(
375+
createFakeHandlerNode(subscription)
376+
);
377+
378+
assert.strictEqual(
379+
handler.configureNodesFilter([
380+
'/test_ns//remote_node/',
381+
'nested//node/',
382+
]),
383+
true
384+
);
385+
assert.deepStrictEqual(lastFilter, {
386+
expression: 'node = %0 OR node = %1',
387+
parameters: ["'/test_ns/remote_node'", "'/test_ns/nested/node'"],
388+
});
389+
});
390+
391+
it('should clear the content filter when nodeNames is omitted', function () {
392+
let hasFilter = true;
393+
const subscription = {
394+
setContentFilter: () => true,
395+
clearContentFilter: () => {
396+
hasFilter = false;
397+
return true;
398+
},
399+
hasContentFilter: () => hasFilter,
400+
};
401+
402+
handler = new rclnodejs.ParameterEventHandler(
403+
createFakeHandlerNode(subscription)
404+
);
405+
406+
assert.strictEqual(handler.configureNodesFilter(), true);
407+
assert.strictEqual(hasFilter, false);
408+
});
409+
410+
it('should clear the content filter when nodeNames is empty', function () {
411+
let hasFilter = true;
412+
const subscription = {
413+
setContentFilter: () => true,
414+
clearContentFilter: () => {
415+
hasFilter = false;
416+
return true;
417+
},
418+
hasContentFilter: () => hasFilter,
419+
};
420+
421+
handler = new rclnodejs.ParameterEventHandler(
422+
createFakeHandlerNode(subscription)
423+
);
424+
425+
assert.strictEqual(handler.configureNodesFilter([]), true);
426+
assert.strictEqual(hasFilter, false);
427+
});
428+
429+
it('should throw for invalid nodeNames', function () {
430+
const subscription = {
431+
setContentFilter: () => true,
432+
clearContentFilter: () => true,
433+
hasContentFilter: () => false,
434+
};
435+
436+
handler = new rclnodejs.ParameterEventHandler(
437+
createFakeHandlerNode(subscription)
438+
);
439+
440+
assert.throws(() => {
441+
handler.configureNodesFilter('not-an-array');
442+
});
443+
assert.throws(() => {
444+
handler.configureNodesFilter(['']);
445+
});
446+
assert.throws(() => {
447+
handler.configureNodesFilter([1]);
448+
});
449+
assert.throws(() => {
450+
handler.configureNodesFilter(["bad'node"]);
451+
});
452+
assert.throws(() => {
453+
handler.configureNodesFilter(['/invalid_node?']);
454+
});
455+
});
456+
});
457+
300458
describe('static methods', function () {
301459
it('getParameterFromEvent should find matching parameter', function () {
302460
const event = {

test/types/index.test-d.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,28 @@ expectType<rclnodejs.Options<string | rclnodejs.QoS>>(
111111
);
112112
expectType<string>(node.getFullyQualifiedName());
113113
expectType<string>(node.getRMWImplementationIdentifier());
114+
const parameterEventHandler = node.createParameterEventHandler();
115+
expectType<rclnodejs.ParameterEventHandler>(parameterEventHandler);
116+
expectType<boolean>(parameterEventHandler.configureNodesFilter());
117+
expectType<boolean>(parameterEventHandler.configureNodesFilter(['/test_node']));
118+
119+
const parameterCallbackHandle = parameterEventHandler.addParameterCallback(
120+
'test_param',
121+
'/test_node',
122+
(parameter: any) => {
123+
const receivedParameter = parameter;
124+
}
125+
);
126+
expectType<rclnodejs.ParameterCallbackHandle>(parameterCallbackHandle);
127+
128+
const parameterEventCallbackHandle =
129+
parameterEventHandler.addParameterEventCallback((event: any) => {
130+
const receivedEvent = event;
131+
});
132+
expectType<rclnodejs.ParameterEventCallbackHandle>(
133+
parameterEventCallbackHandle
134+
);
135+
114136
const nodeWithArgs = rclnodejs.createNode(
115137
NODE_NAME,
116138
'topic',

types/parameter_event_handler.d.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,17 @@ declare module 'rclnodejs' {
9797
callback: (event: any) => void
9898
): ParameterEventCallbackHandle;
9999

100+
/**
101+
* Configure which node parameter events will be received.
102+
*
103+
* If nodeNames is omitted or empty, the node filter is cleared.
104+
* Relative names are resolved against the handler node namespace.
105+
*
106+
* @param nodeNames - Node names to filter parameter events from.
107+
* @returns True if the filter is active or was successfully cleared.
108+
*/
109+
configureNodesFilter(nodeNames?: string[]): boolean;
110+
100111
/**
101112
* Remove a previously added parameter event callback.
102113
*

0 commit comments

Comments
 (0)