UNPKG

rclnodejs

Version:
567 lines (492 loc) 17.2 kB
// Copyright (c) 2026, The Robot Web Tools Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. 'use strict'; 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'; const PARAMETER_EVENT_TOPIC = '/parameter_events'; /** * @class ParameterCallbackHandle * Opaque handle returned when adding a parameter callback. * Used to remove the callback later. */ class ParameterCallbackHandle { /** * @param {string} parameterName - The parameter name * @param {string} nodeName - The fully qualified node name * @param {Function} callback - The callback function * @hideconstructor */ constructor(parameterName, nodeName, callback) { this.parameterName = parameterName; this.nodeName = nodeName; this.callback = callback; } } /** * @class ParameterEventCallbackHandle * Opaque handle returned when adding a parameter event callback. * Used to remove the callback later. */ class ParameterEventCallbackHandle { /** * @param {Function} callback - The callback function * @hideconstructor */ constructor(callback) { this.callback = callback; } } /** * @class ParameterEventHandler * * Monitors and responds to parameter changes on any node in the ROS 2 graph * by subscribing to the `/parameter_events` topic. * * Unlike {@link ParameterWatcher}, which is tied to a single remote node and * requires waiting for that node's parameter services, ParameterEventHandler * responds to parameter events from any node without needing service availability. * * Two types of callbacks are supported: * - **Parameter callbacks**: fired when a specific parameter on a specific node * is added or changed (new_parameters + changed_parameters). * Note: deleted parameters are not dispatched to parameter callbacks; * use event callbacks to observe deletions. * - **Event callbacks**: fired for every ParameterEvent message received, * including deletions. * * @example * const handler = node.createParameterEventHandler(); * * // Watch a specific parameter on a specific node * const handle = handler.addParameterCallback( * 'my_param', * '/my_node', * (parameter) => { * console.log(`Parameter changed: ${parameter.name} = ${parameter.value}`); * } * ); * * // Watch all parameter events * const eventHandle = handler.addParameterEventCallback((event) => { * console.log(`Event from node: ${event.node}`); * }); * * // Remove callbacks when done * handler.removeParameterCallback(handle); * handler.removeParameterEventCallback(eventHandle); * * // Destroy when no longer needed * handler.destroy(); */ class ParameterEventHandler { #node; #subscription; #parameterCallbacks; // Map<string, ParameterCallbackHandle[]> keyed by "paramName\0nodeName" #eventCallbacks; // ParameterEventCallbackHandle[] #destroyed; /** * Create a ParameterEventHandler. * * @param {object} node - The rclnodejs Node used to create the subscription * @param {object} [options] - Options * @param {object} [options.qos] - QoS profile for the parameter_events subscription */ constructor(node, options = {}) { if (!node || typeof node.createSubscription !== 'function') { throw new TypeValidationError('node', node, 'Node instance', { entityType: 'parameter event handler', }); } if ( options !== undefined && options !== null && typeof options !== 'object' ) { throw new TypeValidationError('options', options, 'object or undefined', { entityType: 'parameter event handler', }); } const opts = options || {}; this.#node = node; this.#parameterCallbacks = new Map(); this.#eventCallbacks = []; this.#destroyed = false; const subscriptionOptions = opts.qos ? { qos: opts.qos } : undefined; this.#subscription = node.createSubscription( PARAMETER_EVENT_MSG_TYPE, PARAMETER_EVENT_TOPIC, subscriptionOptions, (event) => this.#handleEvent(event) ); debug('Created ParameterEventHandler on node=%s', node.name()); } /** * Add a callback for a specific parameter on a specific node. * * The callback is invoked whenever the named parameter is added or changed * on the specified node. The callback receives the parameter message object * (rcl_interfaces/msg/Parameter) with `name` and `value` fields. * * @param {string} parameterName - Name of the parameter to monitor * @param {string} nodeName - Fully qualified name of the node (e.g., '/my_node') * @param {Function} callback - Called with (parameter) when the parameter changes * @returns {ParameterCallbackHandle} Handle for removing this callback later * @throws {Error} If the handler has been destroyed * @throws {TypeError} If arguments are invalid */ addParameterCallback(parameterName, nodeName, callback) { this.#checkNotDestroyed(); if (typeof parameterName !== 'string' || parameterName.trim() === '') { throw new TypeValidationError( 'parameterName', parameterName, 'non-empty string', { entityType: 'parameter event handler' } ); } if (typeof nodeName !== 'string' || nodeName.trim() === '') { throw new TypeValidationError('nodeName', nodeName, 'non-empty string', { entityType: 'parameter event handler', }); } if (typeof callback !== 'function') { throw new TypeValidationError('callback', callback, 'function', { entityType: 'parameter event handler', }); } const resolvedNodeName = normalizeNodeName(nodeName); const resolvedParamName = parameterName.trim(); const handle = new ParameterCallbackHandle( resolvedParamName, resolvedNodeName, callback ); const key = this.#makeKey(resolvedParamName, resolvedNodeName); if (!this.#parameterCallbacks.has(key)) { this.#parameterCallbacks.set(key, []); } // Insert at front (FILO order, matching rclpy behavior) this.#parameterCallbacks.get(key).unshift(handle); debug( 'Added parameter callback: param=%s node=%s', resolvedParamName, resolvedNodeName ); 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. * * @param {ParameterCallbackHandle} handle - The handle returned by addParameterCallback * @throws {Error} If the handle is not found or handler is destroyed */ removeParameterCallback(handle) { this.#checkNotDestroyed(); if (!(handle instanceof ParameterCallbackHandle)) { throw new TypeValidationError( 'handle', handle, 'ParameterCallbackHandle', { entityType: 'parameter event handler' } ); } const key = this.#makeKey(handle.parameterName, handle.nodeName); const callbacks = this.#parameterCallbacks.get(key); if (!callbacks) { throw new OperationError( `No callbacks registered for parameter '${handle.parameterName}' on node '${handle.nodeName}'`, { entityType: 'parameter event handler' } ); } const index = callbacks.indexOf(handle); if (index === -1) { throw new OperationError("Callback doesn't exist", { entityType: 'parameter event handler', }); } callbacks.splice(index, 1); if (callbacks.length === 0) { this.#parameterCallbacks.delete(key); } debug( 'Removed parameter callback: param=%s node=%s', handle.parameterName, handle.nodeName ); } /** * Add a callback that is invoked for every parameter event. * * The callback receives the full ParameterEvent message * (rcl_interfaces/msg/ParameterEvent) with `node`, `new_parameters`, * `changed_parameters`, and `deleted_parameters` fields. * * @param {Function} callback - Called with (event) for every ParameterEvent * @returns {ParameterEventCallbackHandle} Handle for removing this callback later * @throws {Error} If the handler has been destroyed * @throws {TypeError} If callback is not a function */ addParameterEventCallback(callback) { this.#checkNotDestroyed(); if (typeof callback !== 'function') { throw new TypeValidationError('callback', callback, 'function', { entityType: 'parameter event handler', }); } const handle = new ParameterEventCallbackHandle(callback); // Insert at front (FILO order) this.#eventCallbacks.unshift(handle); debug('Added parameter event callback'); return handle; } /** * Remove a previously added parameter event callback. * * @param {ParameterEventCallbackHandle} handle - The handle returned by addParameterEventCallback * @throws {Error} If the handle is not found or handler is destroyed */ removeParameterEventCallback(handle) { this.#checkNotDestroyed(); if (!(handle instanceof ParameterEventCallbackHandle)) { throw new TypeValidationError( 'handle', handle, 'ParameterEventCallbackHandle', { entityType: 'parameter event handler' } ); } const index = this.#eventCallbacks.indexOf(handle); if (index === -1) { throw new OperationError("Callback doesn't exist", { entityType: 'parameter event handler', }); } this.#eventCallbacks.splice(index, 1); debug('Removed parameter event callback'); } /** * Check if the handler has been destroyed. * * @returns {boolean} True if destroyed */ isDestroyed() { return this.#destroyed; } /** * Destroy the handler and clean up resources. * Removes the subscription and clears all callbacks. */ destroy() { if (this.#destroyed) { return; } debug('Destroying ParameterEventHandler'); if (this.#subscription) { try { this.#node.destroySubscription(this.#subscription); } catch (error) { debug('Error destroying subscription: %s', error.message); } this.#subscription = null; } this.#parameterCallbacks.clear(); this.#eventCallbacks.length = 0; this.#destroyed = true; } /** * Get a specific parameter from a ParameterEvent message. * * @param {object} event - A ParameterEvent message * @param {string} parameterName - The parameter name to look for * @param {string} nodeName - The node name to match * @returns {object|null} The matching parameter message, or null * @static */ static getParameterFromEvent(event, parameterName, nodeName) { const resolvedNodeName = normalizeNodeName(nodeName); const resolvedParamName = (parameterName || '').trim(); if (normalizeNodeName(event.node) !== resolvedNodeName) { return null; } const allParams = [ ...(event.new_parameters || []), ...(event.changed_parameters || []), ]; for (const param of allParams) { if (param.name === resolvedParamName) { return param; } } return null; } /** * Get all parameters from a ParameterEvent message (new + changed). * * @param {object} event - A ParameterEvent message * @returns {object[]} Array of parameter messages * @static */ static getParametersFromEvent(event) { return [ ...(event.new_parameters || []), ...(event.changed_parameters || []), ]; } /** * Handle incoming parameter event. * @private */ #handleEvent(event) { const eventNodeName = normalizeNodeName(event.node); // Dispatch parameter-specific callbacks by iterating event params // and doing direct Map lookups (O(event_params) instead of O(registered_callbacks)) const allParams = [ ...(event.new_parameters || []), ...(event.changed_parameters || []), ]; for (const parameter of allParams) { const key = this.#makeKey(parameter.name, eventNodeName); const callbacks = this.#parameterCallbacks.get(key); if (callbacks) { for (const handle of callbacks.slice()) { try { handle.callback(parameter); } catch (err) { debug( 'Error in parameter callback for %s on %s: %s', parameter.name, eventNodeName, err.message ); } } } } // Dispatch event-level callbacks for (const handle of this.#eventCallbacks.slice()) { try { handle.callback(event); } catch (err) { debug('Error in parameter event callback: %s', err.message); } } } /** * Create a map key from parameter name and node name. * @private */ #makeKey(paramName, nodeName) { 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 */ #checkNotDestroyed() { if (this.#destroyed) { throw new OperationError('ParameterEventHandler has been destroyed', { entityType: 'parameter event handler', }); } } } module.exports = ParameterEventHandler; module.exports.ParameterCallbackHandle = ParameterCallbackHandle; module.exports.ParameterEventCallbackHandle = ParameterEventCallbackHandle;