diff --git a/lib/parameter_event_handler.js b/lib/parameter_event_handler.js index 27c7dbfa..31d91e3d 100644 --- a/lib/parameter_event_handler.js +++ b/lib/parameter_event_handler.js @@ -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'; @@ -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()); + this.#validateFullyQualifiedNodePath(resolvedNodeName); + return resolvedNodeName; + }); + + const contentFilter = { + expression: resolvedNodeNames + .map((_, index) => `node = %${index}`) + .join(' OR '), + parameters: resolvedNodeNames.map((nodeName) => `'${nodeName}'`), + }; + + this.#subscription.setContentFilter(contentFilter); + return this.#subscription.hasContentFilter(); + } + /** * Remove a previously added parameter callback. * @@ -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 diff --git a/test/test-parameter-event-handler.js b/test/test-parameter-event-handler.js index 69cd55de..2d82abf1 100644 --- a/test/test-parameter-event-handler.js +++ b/test/test-parameter-event-handler.js @@ -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); @@ -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 = { diff --git a/test/types/index.test-d.ts b/test/types/index.test-d.ts index 5fdf6360..8439bab7 100644 --- a/test/types/index.test-d.ts +++ b/test/types/index.test-d.ts @@ -111,6 +111,28 @@ expectType>( ); expectType(node.getFullyQualifiedName()); expectType(node.getRMWImplementationIdentifier()); +const parameterEventHandler = node.createParameterEventHandler(); +expectType(parameterEventHandler); +expectType(parameterEventHandler.configureNodesFilter()); +expectType(parameterEventHandler.configureNodesFilter(['/test_node'])); + +const parameterCallbackHandle = parameterEventHandler.addParameterCallback( + 'test_param', + '/test_node', + (parameter: any) => { + const receivedParameter = parameter; + } +); +expectType(parameterCallbackHandle); + +const parameterEventCallbackHandle = + parameterEventHandler.addParameterEventCallback((event: any) => { + const receivedEvent = event; + }); +expectType( + parameterEventCallbackHandle +); + const nodeWithArgs = rclnodejs.createNode( NODE_NAME, 'topic', diff --git a/types/parameter_event_handler.d.ts b/types/parameter_event_handler.d.ts index 8bea9ec5..186de52a 100644 --- a/types/parameter_event_handler.d.ts +++ b/types/parameter_event_handler.d.ts @@ -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. *