Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions lib/parameter_event_handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

const { TypeValidationError, OperationError } = require('./errors');
const { normalizeNodeName } = require('./utils');
const validator = require('./validator');
const debug = require('debug')('rclnodejs:parameter_event_handler');

const PARAMETER_EVENT_MSG_TYPE = 'rcl_interfaces/msg/ParameterEvent';
Expand Down Expand Up @@ -210,6 +211,64 @@ class ParameterEventHandler {
return handle;
}

/**
* Configure which node parameter events will be received.
*
* If nodeNames is omitted or empty, the current node filter is cleared.
* When a filter is active, parameter and event callbacks only receive
* events from the specified nodes.
*
* @param {string[]} [nodeNames] - Node names to filter parameter events from.
* Relative names are resolved against the handler node namespace.
* @returns {boolean} True if the filter is active or was successfully cleared.
*/
configureNodesFilter(nodeNames) {
this.#checkNotDestroyed();

if (nodeNames === undefined || nodeNames === null) {
this.#subscription.clearContentFilter();
return !this.#subscription.hasContentFilter();
}

if (!Array.isArray(nodeNames)) {
throw new TypeValidationError('nodeNames', nodeNames, 'string[]', {
entityType: 'parameter event handler',
});
}

if (nodeNames.length === 0) {
this.#subscription.clearContentFilter();
return !this.#subscription.hasContentFilter();
}

const resolvedNodeNames = nodeNames.map((nodeName, index) => {
if (typeof nodeName !== 'string' || nodeName.trim() === '') {
throw new TypeValidationError(
`nodeNames[${index}]`,
nodeName,
'non-empty string',
{
entityType: 'parameter event handler',
}
);
}

const resolvedNodeName = this.#resolvePath(nodeName.trim());
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#validateFullyQualifiedNodePath() strips trailing slashes only for validation, but resolvedNodeName is used unchanged in the filter parameters. Inputs like /ns/node/ can pass validation yet generate a filter that won’t match ParameterEvent.node (published without trailing slashes), causing missed events. Normalize resolvedNodeName (strip trailing / except for /, and ideally collapse repeated /) before building the filter.

Suggested change
const resolvedNodeName = this.#resolvePath(nodeName.trim());
const resolvedNodeName = normalizeNodeName(
this.#resolvePath(nodeName.trim())
);

Copilot uses AI. Check for mistakes.
this.#validateFullyQualifiedNodePath(resolvedNodeName);
return resolvedNodeName;
});

const contentFilter = {
expression: resolvedNodeNames
.map((_, index) => `node = %${index}`)
.join(' OR '),
parameters: resolvedNodeNames.map((nodeName) => `'${nodeName}'`),
};
Comment on lines +244 to +266
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

configureNodesFilter() wraps each node name in single quotes for the content-filter parameter ('${nodeName}') but does not escape or reject ' characters in nodeNames. Passing a value containing a quote will produce an invalid filter expression (or allow filter-expression injection). Consider validating node names against ROS name rules (or at least escaping/rejecting ') before building contentFilter.parameters.

Copilot uses AI. Check for mistakes.

Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

configureNodesFilter() calls setContentFilter() unconditionally. Content-filtered topics are only supported on some ROS distros/RMWs (see Subscription.isContentFilterSupported()), and setContentFilter() will throw a native exception when unsupported. Consider checking this.#subscription.isContentFilterSupported() (when available) and either returning false or throwing an OperationError with a clear message before attempting to set a filter.

Suggested change
if (
typeof this.#subscription.isContentFilterSupported === 'function' &&
!this.#subscription.isContentFilterSupported()
) {
throw new OperationError(
'Node filtering requires content-filtered topic support, which is not available in the current ROS distro/RMW implementation',
{ entityType: 'parameter event handler' }
);
}

Copilot uses AI. Check for mistakes.
this.#subscription.setContentFilter(contentFilter);
return this.#subscription.hasContentFilter();
}

/**
* Remove a previously added parameter callback.
*
Expand Down Expand Up @@ -450,6 +509,45 @@ class ParameterEventHandler {
return `${paramName}\0${nodeName}`;
}

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

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

// Preserve the root namespace as '/' and strip trailing slashes everywhere
// else so the filter matches the canonical ParameterEvent.node format.
if (resolvedPath === '/') {
return resolvedPath;
}

return resolvedPath.replace(/\/+$/, '');
}

/**
* Validate a fully qualified node path before using it in a content filter.
* @private
*/
#validateFullyQualifiedNodePath(nodePath) {
const normalizedPath =
nodePath.length > 1 ? nodePath.replace(/\/+$/, '') : nodePath;
const separatorIndex = normalizedPath.lastIndexOf('/');
const nodeNamespace =
separatorIndex === 0 ? '/' : normalizedPath.slice(0, separatorIndex);
const nodeName = normalizedPath.slice(separatorIndex + 1);

validator.validateNamespace(nodeNamespace);
validator.validateNodeName(nodeName);
}

/**
* Check if the handler has been destroyed and throw if so.
* @private
Expand Down
158 changes: 158 additions & 0 deletions test/test-parameter-event-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@
const assert = require('assert');
const rclnodejs = require('../index.js');

function createFakeHandlerNode(subscription) {
return {
createSubscription: () => subscription,
destroySubscription: () => {},
getFullyQualifiedName: () => '/test_ns/peh_handler_node',
name: () => 'peh_handler_node',
namespace: () => '/test_ns',
};
}

describe('ParameterEventHandler tests', function () {
this.timeout(60 * 1000);

Expand Down Expand Up @@ -297,6 +307,154 @@ describe('ParameterEventHandler tests', function () {
});
});

describe('configureNodesFilter', function () {
it('should apply a content filter for absolute node names', function () {
let hasFilter = false;
let lastFilter;
const subscription = {
setContentFilter: (filter) => {
lastFilter = filter;
hasFilter = true;
return true;
},
clearContentFilter: () => {
hasFilter = false;
return true;
},
hasContentFilter: () => hasFilter,
};

handler = new rclnodejs.ParameterEventHandler(
createFakeHandlerNode(subscription)
);

assert.strictEqual(
handler.configureNodesFilter(['/remote_node_1', '/remote_node_2']),
true
);
assert.deepStrictEqual(lastFilter, {
expression: 'node = %0 OR node = %1',
parameters: ["'/remote_node_1'", "'/remote_node_2'"],
});
});

it('should resolve relative node names against the handler namespace', function () {
let lastFilter;
const subscription = {
setContentFilter: (filter) => {
lastFilter = filter;
return true;
},
clearContentFilter: () => true,
hasContentFilter: () => true,
};

handler = new rclnodejs.ParameterEventHandler(
createFakeHandlerNode(subscription)
);

assert.strictEqual(handler.configureNodesFilter(['remote_node']), true);
assert.deepStrictEqual(lastFilter, {
expression: 'node = %0',
parameters: ["'/test_ns/remote_node'"],
});
});

it('should normalize repeated and trailing slashes in node names', function () {
let lastFilter;
const subscription = {
setContentFilter: (filter) => {
lastFilter = filter;
return true;
},
clearContentFilter: () => true,
hasContentFilter: () => true,
};

handler = new rclnodejs.ParameterEventHandler(
createFakeHandlerNode(subscription)
);

assert.strictEqual(
handler.configureNodesFilter([
'/test_ns//remote_node/',
'nested//node/',
]),
true
);
assert.deepStrictEqual(lastFilter, {
expression: 'node = %0 OR node = %1',
parameters: ["'/test_ns/remote_node'", "'/test_ns/nested/node'"],
});
});

it('should clear the content filter when nodeNames is omitted', function () {
let hasFilter = true;
const subscription = {
setContentFilter: () => true,
clearContentFilter: () => {
hasFilter = false;
return true;
},
hasContentFilter: () => hasFilter,
};

handler = new rclnodejs.ParameterEventHandler(
createFakeHandlerNode(subscription)
);

assert.strictEqual(handler.configureNodesFilter(), true);
assert.strictEqual(hasFilter, false);
});

it('should clear the content filter when nodeNames is empty', function () {
let hasFilter = true;
const subscription = {
setContentFilter: () => true,
clearContentFilter: () => {
hasFilter = false;
return true;
},
hasContentFilter: () => hasFilter,
};

handler = new rclnodejs.ParameterEventHandler(
createFakeHandlerNode(subscription)
);

assert.strictEqual(handler.configureNodesFilter([]), true);
assert.strictEqual(hasFilter, false);
});

it('should throw for invalid nodeNames', function () {
const subscription = {
setContentFilter: () => true,
clearContentFilter: () => true,
hasContentFilter: () => false,
};

handler = new rclnodejs.ParameterEventHandler(
createFakeHandlerNode(subscription)
);

assert.throws(() => {
handler.configureNodesFilter('not-an-array');
});
assert.throws(() => {
handler.configureNodesFilter(['']);
});
assert.throws(() => {
handler.configureNodesFilter([1]);
});
assert.throws(() => {
handler.configureNodesFilter(["bad'node"]);
});
assert.throws(() => {
handler.configureNodesFilter(['/invalid_node?']);
});
});
});

describe('static methods', function () {
it('getParameterFromEvent should find matching parameter', function () {
const event = {
Expand Down
22 changes: 22 additions & 0 deletions test/types/index.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,28 @@ expectType<rclnodejs.Options<string | rclnodejs.QoS>>(
);
expectType<string>(node.getFullyQualifiedName());
expectType<string>(node.getRMWImplementationIdentifier());
const parameterEventHandler = node.createParameterEventHandler();
expectType<rclnodejs.ParameterEventHandler>(parameterEventHandler);
expectType<boolean>(parameterEventHandler.configureNodesFilter());
expectType<boolean>(parameterEventHandler.configureNodesFilter(['/test_node']));

const parameterCallbackHandle = parameterEventHandler.addParameterCallback(
'test_param',
'/test_node',
(parameter: any) => {
const receivedParameter = parameter;
}
);
expectType<rclnodejs.ParameterCallbackHandle>(parameterCallbackHandle);

const parameterEventCallbackHandle =
parameterEventHandler.addParameterEventCallback((event: any) => {
const receivedEvent = event;
});
expectType<rclnodejs.ParameterEventCallbackHandle>(
parameterEventCallbackHandle
);

const nodeWithArgs = rclnodejs.createNode(
NODE_NAME,
'topic',
Expand Down
11 changes: 11 additions & 0 deletions types/parameter_event_handler.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,17 @@ declare module 'rclnodejs' {
callback: (event: any) => void
): ParameterEventCallbackHandle;

/**
* Configure which node parameter events will be received.
*
* If nodeNames is omitted or empty, the node filter is cleared.
* Relative names are resolved against the handler node namespace.
*
* @param nodeNames - Node names to filter parameter events from.
* @returns True if the filter is active or was successfully cleared.
*/
configureNodesFilter(nodeNames?: string[]): boolean;

/**
* Remove a previously added parameter event callback.
*
Expand Down
Loading