diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 36089ae2..a840a477 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -41,6 +41,7 @@ - **[Mahmoud Alghalayini](https://github.com/mahmoud-ghalayini)** - JSON safe serialization improvements - Promise-based service calls implementation + - Add ParameterClient for external parameter access - **[Martins Mozeiko](https://github.com/martins-mozeiko)** - QoS new/delete fix diff --git a/example/parameter/README.md b/example/parameter/README.md index 9de84852..5b1944ed 100644 --- a/example/parameter/README.md +++ b/example/parameter/README.md @@ -20,7 +20,9 @@ Parameters are ideal for: ## Parameter Examples -### 1. Parameter Declaration (`parameter-declaration-example.js`) +### Local Parameters (On Current Node) + +#### 1. Parameter Declaration (`parameter-declaration-example.js`) **Purpose**: Demonstrates how to declare and use parameters in a ROS 2 node. @@ -41,7 +43,7 @@ Parameters are ideal for: - **Default Value**: `"hello world"` - **Run Command**: `node parameter-declaration-example.js` -### 2. Parameter Override (`parameter-override-example.js`) +#### 2. Parameter Override (`parameter-override-example.js`) **Purpose**: Shows how to override parameter values using command-line arguments. @@ -63,6 +65,74 @@ Parameters are ideal for: - **Override Value**: `"hello ros2"` (via command line) - **Run Command**: `node parameter-override-example.js` +### Remote Parameter Access (On Other Nodes) + +#### 3. ParameterClient Basic (`parameter-client-basic-example.js`) + +**Purpose**: Demonstrates accessing and modifying parameters on a remote node using `ParameterClient`. + +- **Functionality**: + - Creates a client node and ParameterClient + - Connects to turtlesim node's parameters + - Lists all available parameters + - Gets and sets individual parameters + - Retrieves multiple parameters at once +- **Features**: + - Service availability checking with `waitForService()` + - Parameter listing with `listParameters()` + - Single parameter get/set operations + - Batch parameter retrieval + - Automatic type inference for parameter values +- **Target Node**: `turtlesim` (run: `ros2 run turtlesim turtlesim_node`) +- **Run Command**: `node parameter-client-basic-example.js` + +#### 4. ParameterClient Advanced (`parameter-client-advanced-example.js`) + +**Purpose**: Comprehensive example showing all ParameterClient features and capabilities. + +- **Functionality**: + - Creates target and client nodes + - Declares parameters on target node + - Demonstrates all ParameterClient operations: + - List parameters + - Get single/multiple parameters + - Set single/multiple parameters + - Describe parameters (get metadata) + - Get parameter types + - Timeout handling + - Request cancellation with AbortController +- **Features**: + - Complete API coverage + - Error handling examples + - Timeout and cancellation patterns + - Automatic BigInt conversion for integers + - Type inference demonstrations + - Lifecycle management +- **Run Command**: `node parameter-client-advanced-example.js` + +**ParameterClient Key Features**: + +- **Remote Access**: Query/modify parameters on any node +- **Async/Await API**: Modern promise-based interface +- **Type-Safe**: Automatic type inference and BigInt conversion +- **Timeout Support**: Per-request or default timeout settings +- **Cancellation**: AbortController integration for request cancellation +- **Lifecycle Management**: Automatic cleanup when parent node is destroyed + +**Public API**: + +- `remoteNodeName` (getter) - Get the target node name +- `waitForService(timeout?)` - Wait for remote services to be available +- `getParameter(name, options?)` - Get a single parameter +- `getParameters(names, options?)` - Get multiple parameters +- `setParameter(name, value, options?)` - Set a single parameter +- `setParameters(parameters, options?)` - Set multiple parameters +- `listParameters(options?)` - List all parameters with optional filtering +- `describeParameters(names, options?)` - Get parameter descriptors +- `getParameterTypes(names, options?)` - Get parameter types +- `isDestroyed()` - Check if client has been destroyed +- `destroy()` - Clean up and destroy the client + ## How to Run the Examples ### Prerequisites @@ -128,6 +198,53 @@ ParameterDescriptor { Notice how the value changed from "hello world" to "hello ros2" due to the command-line override. +### Running ParameterClient Basic Example + +First, in one terminal, run turtlesim: + +```bash +ros2 run turtlesim turtlesim_node +``` + +Then in another terminal: + +```bash +cd example/parameter +node parameter-client-basic-example.js +``` + +**Expected Output**: + +``` +Current background_b: 255n +Updated background_b: 200n +``` + +### Running ParameterClient Advanced Example + +```bash +cd example/parameter +node parameter-client-advanced-example.js +``` + +**Expected Output**: + +``` +Available parameters: [ 'use_sim_time', 'max_speed', 'debug_mode', 'retry_count' ] +max_speed = 10.5 +Retrieved parameters: [ 'max_speed', 'debug_mode', 'retry_count' ] +max_speed descriptor: { + name: 'max_speed', + type: 3, + description: 'Maximum speed in m/s', + additional_constraints: '', + read_only: false, + dynamic_typing: false, + floating_point_range: [], + integer_range: [] +} +``` + ## Using ROS 2 Parameter Tools You can interact with these examples using standard ROS 2 parameter tools: @@ -276,19 +393,16 @@ const doubleParam = new Parameter( ### Common Issues 1. **Parameter Not Found**: - - Ensure parameter is declared before accessing - Check parameter name spelling - Verify node has been properly initialized 2. **Type Mismatch**: - - Ensure parameter type matches declaration - Check ParameterType constants are correct - Verify value type matches parameter type 3. **Override Not Working**: - - Check command-line syntax: `--ros-args -p node_name:param_name:=value` - Ensure node name matches exactly - Verify rclnodejs.init() is called with argv diff --git a/example/parameter/parameter-client-advanced-example.js b/example/parameter/parameter-client-advanced-example.js new file mode 100644 index 00000000..0e03dfbc --- /dev/null +++ b/example/parameter/parameter-client-advanced-example.js @@ -0,0 +1,101 @@ +// Copyright (c) 2025 Mahmoud Alghalayini. All rights reserved. +// +// 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 rclnodejs = require('../../index.js'); + +const { ParameterType, Parameter, ParameterDescriptor } = rclnodejs; + +async function main() { + await rclnodejs.init(); + + const targetNode = rclnodejs.createNode('target_node'); + const clientNode = rclnodejs.createNode('client_node'); + + targetNode.declareParameter( + new Parameter('max_speed', ParameterType.PARAMETER_DOUBLE, 10.5), + new ParameterDescriptor( + 'max_speed', + ParameterType.PARAMETER_DOUBLE, + 'Maximum speed in m/s' + ) + ); + + targetNode.declareParameter( + new Parameter('debug_mode', ParameterType.PARAMETER_BOOL, false), + new ParameterDescriptor( + 'debug_mode', + ParameterType.PARAMETER_BOOL, + 'Enable debug logging' + ) + ); + + targetNode.declareParameter( + new Parameter('retry_count', ParameterType.PARAMETER_INTEGER, BigInt(3)), + new ParameterDescriptor( + 'retry_count', + ParameterType.PARAMETER_INTEGER, + 'Number of retries' + ) + ); + + rclnodejs.spin(targetNode); + rclnodejs.spin(clientNode); + + const paramClient = clientNode.createParameterClient('target_node'); + + try { + await paramClient.waitForService(10000); + + const { names } = await paramClient.listParameters(); + console.log('Available parameters:', names); + + const maxSpeed = await paramClient.getParameter('max_speed'); + console.log(`max_speed = ${maxSpeed.value}`); + + const params = await paramClient.getParameters([ + 'max_speed', + 'debug_mode', + 'retry_count', + ]); + console.log( + 'Retrieved parameters:', + params.map((p) => p.name) + ); + + await paramClient.setParameter('max_speed', 15.0); + await paramClient.setParameters([ + { name: 'debug_mode', value: true }, + { name: 'retry_count', value: 5 }, + ]); + + const descriptors = await paramClient.describeParameters(['max_speed']); + console.log(`max_speed descriptor:`, descriptors[0]); + + try { + await paramClient.getParameter('max_speed', { timeout: 1 }); + } catch (error) { + // Expected timeout with 1ms + } + } catch (error) { + console.error('Error:', error.message); + } finally { + clientNode.destroy(); + targetNode.destroy(); + rclnodejs.shutdown(); + } +} + +main(); diff --git a/example/parameter/parameter-client-basic-example.js b/example/parameter/parameter-client-basic-example.js new file mode 100644 index 00000000..f7424227 --- /dev/null +++ b/example/parameter/parameter-client-basic-example.js @@ -0,0 +1,51 @@ +// Copyright (c) 2025 Mahmoud Alghalayini. All rights reserved. +// +// 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 rclnodejs = require('../../index.js'); + +async function main() { + await rclnodejs.init(); + + const node = rclnodejs.createNode('param_client_node'); + + const paramClient = node.createParameterClient('turtlesim', { + timeout: 5000, + }); + + try { + const available = await paramClient.waitForService(10000); + + if (!available) { + console.log('Turtlesim node not available. Please run:'); + console.log(' ros2 run turtlesim turtlesim_node'); + return; + } + + const param = await paramClient.getParameter('background_b'); + console.log(`Current background_b: ${param.value}`); + + await paramClient.setParameter('background_b', 200); + const updated = await paramClient.getParameter('background_b'); + console.log(`Updated background_b: ${updated.value}`); + } catch (error) { + console.error('Error:', error.message); + } finally { + node.destroy(); + rclnodejs.shutdown(); + } +} + +main(); diff --git a/index.js b/index.js index 8015e807..de3f2e38 100644 --- a/index.js +++ b/index.js @@ -58,6 +58,7 @@ const { serializeMessage, deserializeMessage, } = require('./lib/serialization.js'); +const ParameterClient = require('./lib/parameter_client.js'); const { spawn } = require('child_process'); /** @@ -217,6 +218,9 @@ let rcl = { /** {@link ParameterType} */ ParameterType: ParameterType, + /** {@link ParameterClient} class */ + ParameterClient: ParameterClient, + /** {@link QoS} class */ QoS: QoS, diff --git a/lib/node.js b/lib/node.js index b371419f..412eb8df 100644 --- a/lib/node.js +++ b/lib/node.js @@ -33,6 +33,7 @@ const { } = require('./parameter.js'); const { isValidSerializationMode } = require('./message_serialization.js'); const ParameterService = require('./parameter_service.js'); +const ParameterClient = require('./parameter_client.js'); const Publisher = require('./publisher.js'); const QoS = require('./qos.js'); const Rates = require('./rate.js'); @@ -111,6 +112,7 @@ class Node extends rclnodejs.ShadowNode { this._events = []; this._actionClients = []; this._actionServers = []; + this._parameterClients = []; this._rateTimerServer = null; this._parameterDescriptors = new Map(); this._parameters = new Map(); @@ -823,6 +825,28 @@ class Node extends rclnodejs.ShadowNode { return service; } + /** + * Create a ParameterClient for accessing parameters on a remote node. + * @param {string} remoteNodeName - The name of the remote node whose parameters to access. + * @param {object} [options] - Options for parameter client. + * @param {number} [options.timeout=5000] - Default timeout in milliseconds for service calls. + * @return {ParameterClient} - An instance of ParameterClient. + */ + createParameterClient(remoteNodeName, options = {}) { + if (typeof remoteNodeName !== 'string' || remoteNodeName.trim() === '') { + throw new TypeError('Remote node name must be a non-empty string'); + } + + const parameterClient = new ParameterClient(this, remoteNodeName, options); + debug( + 'Finish creating parameter client for remote node = %s.', + remoteNodeName + ); + this._parameterClients.push(parameterClient); + + return parameterClient; + } + /** * Create a guard condition. * @param {Function} callback - The callback to be called when the guard condition is triggered. @@ -856,6 +880,8 @@ class Node extends rclnodejs.ShadowNode { this._actionClients.forEach((actionClient) => actionClient.destroy()); this._actionServers.forEach((actionServer) => actionServer.destroy()); + this._parameterClients.forEach((paramClient) => paramClient.destroy()); + this.context.onNodeDestroyed(this); this.handle.release(); @@ -868,6 +894,7 @@ class Node extends rclnodejs.ShadowNode { this._guards = []; this._actionClients = []; this._actionServers = []; + this._parameterClients = []; if (this._rateTimerServer) { this._rateTimerServer.shutdown(); @@ -936,6 +963,19 @@ class Node extends rclnodejs.ShadowNode { this._destroyEntity(service, this._services); } + /** + * Destroy a ParameterClient. + * @param {ParameterClient} parameterClient - The ParameterClient to be destroyed. + * @return {undefined} + */ + destroyParameterClient(parameterClient) { + if (!(parameterClient instanceof ParameterClient)) { + throw new TypeError('Invalid argument'); + } + this._removeEntityFromArray(parameterClient, this._parameterClients); + parameterClient.destroy(); + } + /** * Destroy a Timer. * @param {Timer} timer - The Timer to be destroyed. @@ -1002,7 +1042,7 @@ class Node extends rclnodejs.ShadowNode { /** * Get the current time using the node's clock. - * @returns {Time} - The current time. + * @returns {Timer} - The current time. */ now() { return this.getClock().now(); @@ -1247,14 +1287,14 @@ class Node extends rclnodejs.ShadowNode { * * @param {Parameter} parameter - Parameter to declare. * @param {ParameterDescriptor} [descriptor] - Optional descriptor for parameter. - * @param {boolean} [ignoreOveride] - When true disregard any parameter-override that may be present. + * @param {boolean} [ignoreOverride] - When true disregard any parameter-override that may be present. * @return {Parameter} - The newly declared parameter. */ - declareParameter(parameter, descriptor, ignoreOveride = false) { + declareParameter(parameter, descriptor, ignoreOverride = false) { const parameters = this.declareParameters( [parameter], descriptor ? [descriptor] : [], - ignoreOveride + ignoreOverride ); return parameters.length == 1 ? parameters[0] : null; } diff --git a/lib/parameter.js b/lib/parameter.js index d29c3b7d..8e2381a0 100644 --- a/lib/parameter.js +++ b/lib/parameter.js @@ -693,29 +693,67 @@ class IntegerRange extends Range { * @param {any} value - The value to infer it's ParameterType * @returns {ParameterType} - The ParameterType that best scribes the value. */ -// eslint-disable-next-line no-unused-vars function parameterTypeFromValue(value) { - if (!value) return ParameterType.PARAMETER_NOT_SET; - if (typeof value === 'boolean') return ParameterType.PARAMETER_BOOL; - if (typeof value === 'string') return ParameterType.PARAMETER_STRING; - if (typeof value === 'number') return ParameterType.PARAMETER_DOUBLE; - if (Array.isArray(value)) { + if (value === null || value === undefined) { + return ParameterType.PARAMETER_NOT_SET; + } + + if (typeof value === 'boolean') { + return ParameterType.PARAMETER_BOOL; + } + + if (typeof value === 'string') { + return ParameterType.PARAMETER_STRING; + } + + if (typeof value === 'bigint') { + return ParameterType.PARAMETER_INTEGER; + } + + if (typeof value === 'number') { + // Distinguish between integer and double + return Number.isInteger(value) + ? ParameterType.PARAMETER_INTEGER + : ParameterType.PARAMETER_DOUBLE; + } + + // Handle TypedArrays + if (ArrayBuffer.isView(value) && !(value instanceof DataView)) { + if (value instanceof Uint8Array) { + return ParameterType.PARAMETER_BYTE_ARRAY; + } + // For other typed arrays, infer from first element if (value.length > 0) { - const elementType = parameterTypeFromValue(value[0]); - switch (elementType) { - case ParameterType.PARAMETER_BOOL: - return ParameterType.PARAMETER_BOOL_ARRAY; - case ParameterType.PARAMETER_DOUBLE: - return ParameterType.PARAMETER_DOUBLE_ARRAY; - case ParameterType.PARAMETER_STRING: - return ParameterType.PARAMETER_STRING_ARRAY; + const firstType = parameterTypeFromValue(value[0]); + if (firstType === ParameterType.PARAMETER_INTEGER) { + return ParameterType.PARAMETER_INTEGER_ARRAY; } + return ParameterType.PARAMETER_DOUBLE_ARRAY; } - return ParameterType.PARAMETER_NOT_SET; } - // otherwise unrecognized value + if (Array.isArray(value)) { + if (value.length === 0) { + return ParameterType.PARAMETER_NOT_SET; + } + + const elementType = parameterTypeFromValue(value[0]); + switch (elementType) { + case ParameterType.PARAMETER_BOOL: + return ParameterType.PARAMETER_BOOL_ARRAY; + case ParameterType.PARAMETER_INTEGER: + return ParameterType.PARAMETER_INTEGER_ARRAY; + case ParameterType.PARAMETER_DOUBLE: + return ParameterType.PARAMETER_DOUBLE_ARRAY; + case ParameterType.PARAMETER_STRING: + return ParameterType.PARAMETER_STRING_ARRAY; + default: + return ParameterType.PARAMETER_NOT_SET; + } + } + + // Unrecognized value type throw new TypeError('Unrecognized parameter type.'); } @@ -826,4 +864,5 @@ module.exports = { FloatingPointRange, IntegerRange, DEFAULT_NUMERIC_RANGE_TOLERANCE, + parameterTypeFromValue, }; diff --git a/lib/parameter_client.js b/lib/parameter_client.js new file mode 100644 index 00000000..fc4b4777 --- /dev/null +++ b/lib/parameter_client.js @@ -0,0 +1,500 @@ +// Copyright (c) 2025 Mahmoud Alghalayini. All rights reserved. +// +// 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 { + Parameter, + ParameterType, + parameterTypeFromValue, +} = require('./parameter.js'); +const validator = require('./validator.js'); +const debug = require('debug')('rclnodejs:parameter_client'); + +/** + * @class - Class representing a Parameter Client for accessing parameters on remote nodes + * @hideconstructor + */ +class ParameterClient { + #node; + #remoteNodeName; + #timeout; + #clients; + #destroyed; + + /** + * Create a ParameterClient instance. + * @param {Node} node - The node to use for creating service clients. + * @param {string} remoteNodeName - The name of the remote node whose parameters to access. + * @param {object} [options] - Options for parameter client. + * @param {number} [options.timeout=5000] - Default timeout in milliseconds for service calls. + */ + constructor(node, remoteNodeName, options = {}) { + if (!node) { + throw new TypeError('Node is required'); + } + if (!remoteNodeName || typeof remoteNodeName !== 'string') { + throw new TypeError('Remote node name must be a non-empty string'); + } + + this.#node = node; + this.#remoteNodeName = this.#normalizeNodeName(remoteNodeName); + validator.validateNodeName(this.#remoteNodeName); + + this.#timeout = options.timeout || 5000; + this.#clients = new Map(); + this.#destroyed = false; + + debug( + `ParameterClient created for remote node: ${this.#remoteNodeName} with timeout: ${this.#timeout}ms` + ); + } + + /** + * Get the remote node name this client is connected to. + * @return {string} - The remote node name. + */ + get remoteNodeName() { + return this.#remoteNodeName; + } + + /** + * Get a single parameter from the remote node. + * @param {string} name - The name of the parameter to retrieve. + * @param {object} [options] - Options for the service call. + * @param {number} [options.timeout] - Timeout in milliseconds for this specific call. + * @param {AbortSignal} [options.signal] - AbortSignal to cancel the request. + * @return {Promise} - Promise that resolves with the Parameter object. + * @throws {Error} If the parameter is not found or service call fails. + */ + async getParameter(name, options = {}) { + this.#throwErrorIfClientDestroyed(); + + const parameters = await this.getParameters([name], options); + if (parameters.length === 0) { + throw new Error( + `Parameter '${name}' not found on node '${this.#remoteNodeName}'` + ); + } + + return parameters[0]; + } + + /** + * Get multiple parameters from the remote node. + * @param {string[]} names - Array of parameter names to retrieve. + * @param {object} [options] - Options for the service call. + * @param {number} [options.timeout] - Timeout in milliseconds for this specific call. + * @param {AbortSignal} [options.signal] - AbortSignal to cancel the request. + * @return {Promise} - Promise that resolves with an array of Parameter objects. + * @throws {Error} If the service call fails. + */ + async getParameters(names, options = {}) { + this.#throwErrorIfClientDestroyed(); + + if (!Array.isArray(names) || names.length === 0) { + throw new TypeError('Names must be a non-empty array'); + } + + const client = this.#getOrCreateClient('GetParameters'); + const request = { names }; + + debug( + `Getting ${names.length} parameter(s) from node ${this.#remoteNodeName}` + ); + + const response = await client.sendRequestAsync(request, { + timeout: options.timeout || this.#timeout, + signal: options.signal, + }); + + const parameters = []; + for (let i = 0; i < names.length; i++) { + const value = response.values[i]; + if (value.type !== ParameterType.PARAMETER_NOT_SET) { + parameters.push( + new Parameter( + names[i], + value.type, + this.#deserializeParameterValue(value) + ) + ); + } + } + + debug(`Retrieved ${parameters.length} parameter(s)`); + return parameters; + } + + /** + * Set a single parameter on the remote node. + * @param {string} name - The name of the parameter to set. + * @param {*} value - The value to set. Type is automatically inferred. + * @param {object} [options] - Options for the service call. + * @param {number} [options.timeout] - Timeout in milliseconds for this specific call. + * @param {AbortSignal} [options.signal] - AbortSignal to cancel the request. + * @return {Promise} - Promise that resolves with the result {successful: boolean, reason: string}. + * @throws {Error} If the service call fails. + */ + async setParameter(name, value, options = {}) { + this.#throwErrorIfClientDestroyed(); + + const results = await this.setParameters([{ name, value }], options); + return results[0]; + } + + /** + * Set multiple parameters on the remote node. + * @param {Array<{name: string, value: *}>} parameters - Array of parameter objects with name and value. + * @param {object} [options] - Options for the service call. + * @param {number} [options.timeout] - Timeout in milliseconds for this specific call. + * @param {AbortSignal} [options.signal] - AbortSignal to cancel the request. + * @return {Promise>} - Promise that resolves with an array of results. + * @throws {Error} If the service call fails. + */ + async setParameters(parameters, options = {}) { + this.#throwErrorIfClientDestroyed(); + + if (!Array.isArray(parameters) || parameters.length === 0) { + throw new TypeError('Parameters must be a non-empty array'); + } + + const client = this.#getOrCreateClient('SetParameters'); + const request = { + parameters: parameters.map((param) => ({ + name: param.name, + value: this.#serializeParameterValue(param.value), + })), + }; + + debug( + `Setting ${parameters.length} parameter(s) on node ${this.#remoteNodeName}` + ); + + const response = await client.sendRequestAsync(request, { + timeout: options.timeout || this.#timeout, + signal: options.signal, + }); + + const results = response.results.map((result, index) => ({ + name: parameters[index].name, + successful: result.successful, + reason: result.reason || '', + })); + + debug( + `Set ${results.filter((r) => r.successful).length}/${results.length} parameter(s) successfully` + ); + return results; + } + + /** + * List all parameters available on the remote node. + * @param {object} [options] - Options for listing parameters. + * @param {string[]} [options.prefixes] - Optional array of parameter name prefixes to filter by. + * @param {number} [options.depth=0] - Depth of parameter namespace to list (0 = unlimited). + * @param {number} [options.timeout] - Timeout in milliseconds for this specific call. + * @param {AbortSignal} [options.signal] - AbortSignal to cancel the request. + * @return {Promise<{names: string[], prefixes: string[]}>} - Promise that resolves with parameter names and prefixes. + * @throws {Error} If the service call fails. + */ + async listParameters(options = {}) { + this.#throwErrorIfClientDestroyed(); + + const client = this.#getOrCreateClient('ListParameters'); + const request = { + prefixes: options.prefixes || [], + depth: options.depth !== undefined ? BigInt(options.depth) : BigInt(0), + }; + + debug(`Listing parameters on node ${this.#remoteNodeName}`); + + const response = await client.sendRequestAsync(request, { + timeout: options.timeout || this.#timeout, + signal: options.signal, + }); + + debug( + `Listed ${response.result.names.length} parameter(s) and ${response.result.prefixes.length} prefix(es)` + ); + + return { + names: response.result.names || [], + prefixes: response.result.prefixes || [], + }; + } + + /** + * Describe parameters on the remote node. + * @param {string[]} names - Array of parameter names to describe. + * @param {object} [options] - Options for the service call. + * @param {number} [options.timeout] - Timeout in milliseconds for this specific call. + * @param {AbortSignal} [options.signal] - AbortSignal to cancel the request. + * @return {Promise>} - Promise that resolves with an array of parameter descriptors. + * @throws {Error} If the service call fails. + */ + async describeParameters(names, options = {}) { + this.#throwErrorIfClientDestroyed(); + + if (!Array.isArray(names) || names.length === 0) { + throw new TypeError('Names must be a non-empty array'); + } + + const client = this.#getOrCreateClient('DescribeParameters'); + const request = { names }; + + debug( + `Describing ${names.length} parameter(s) on node ${this.#remoteNodeName}` + ); + + const response = await client.sendRequestAsync(request, { + timeout: options.timeout || this.#timeout, + signal: options.signal, + }); + + debug(`Described ${response.descriptors.length} parameter(s)`); + return response.descriptors || []; + } + + /** + * Get the types of parameters on the remote node. + * @param {string[]} names - Array of parameter names. + * @param {object} [options] - Options for the service call. + * @param {number} [options.timeout] - Timeout in milliseconds for this specific call. + * @param {AbortSignal} [options.signal] - AbortSignal to cancel the request. + * @return {Promise>} - Promise that resolves with an array of parameter types. + * @throws {Error} If the service call fails. + */ + async getParameterTypes(names, options = {}) { + this.#throwErrorIfClientDestroyed(); + + if (!Array.isArray(names) || names.length === 0) { + throw new TypeError('Names must be a non-empty array'); + } + + const client = this.#getOrCreateClient('GetParameterTypes'); + const request = { names }; + + debug( + `Getting types for ${names.length} parameter(s) on node ${this.#remoteNodeName}` + ); + + const response = await client.sendRequestAsync(request, { + timeout: options.timeout || this.#timeout, + signal: options.signal, + }); + + return response.types || []; + } + + /** + * Wait for the parameter services to be available on the remote node. + * @param {number} [timeout] - Optional timeout in milliseconds. + * @return {Promise} - Promise that resolves to true if services are available. + */ + async waitForService(timeout) { + this.#throwErrorIfClientDestroyed(); + + const client = this.#getOrCreateClient('GetParameters'); + return await client.waitForService(timeout); + } + + /** + * Check if the parameter client has been destroyed. + * @return {boolean} - True if destroyed, false otherwise. + */ + isDestroyed() { + return this.#destroyed; + } + + /** + * Destroy the parameter client and clean up all service clients. + * @return {undefined} + */ + destroy() { + if (this.#destroyed) { + return; + } + + debug(`Destroying ParameterClient for node ${this.#remoteNodeName}`); + + for (const [serviceType, client] of this.#clients.entries()) { + try { + this.#node.destroyClient(client); + debug(`Destroyed client for service type: ${serviceType}`); + } catch (error) { + debug( + `Error destroying client for service type ${serviceType}:`, + error + ); + } + } + + this.#clients.clear(); + this.#destroyed = true; + + debug('ParameterClient destroyed'); + } + + /** + * Get or create a service client for the specified service type. + * @private + * @param {string} serviceType - The service type (e.g., 'GetParameters', 'SetParameters'). + * @return {Client} - The service client. + */ + #getOrCreateClient(serviceType) { + if (this.#clients.has(serviceType)) { + return this.#clients.get(serviceType); + } + + const serviceName = `/${this.#remoteNodeName}/${this.#toSnakeCase(serviceType)}`; + const serviceInterface = `rcl_interfaces/srv/${serviceType}`; + + debug(`Creating client for service: ${serviceName}`); + + const client = this.#node.createClient(serviceInterface, serviceName); + this.#clients.set(serviceType, client); + + return client; + } + + /** + * Serialize a JavaScript value to a ParameterValue message. + * @private + * @param {*} value - The value to serialize. + * @return {object} - The ParameterValue message. + */ + #serializeParameterValue(value) { + const type = parameterTypeFromValue(value); + + const paramValue = { + type, + bool_value: false, + integer_value: BigInt(0), + double_value: 0.0, + string_value: '', + byte_array_value: [], + bool_array_value: [], + integer_array_value: [], + double_array_value: [], + string_array_value: [], + }; + + switch (type) { + case ParameterType.PARAMETER_BOOL: + paramValue.bool_value = value; + break; + case ParameterType.PARAMETER_INTEGER: + paramValue.integer_value = + typeof value === 'bigint' ? value : BigInt(value); + break; + case ParameterType.PARAMETER_DOUBLE: + paramValue.double_value = value; + break; + case ParameterType.PARAMETER_STRING: + paramValue.string_value = value; + break; + case ParameterType.PARAMETER_BOOL_ARRAY: + paramValue.bool_array_value = Array.from(value); + break; + case ParameterType.PARAMETER_INTEGER_ARRAY: + paramValue.integer_array_value = Array.from(value).map((v) => + typeof v === 'bigint' ? v : BigInt(v) + ); + break; + case ParameterType.PARAMETER_DOUBLE_ARRAY: + paramValue.double_array_value = Array.from(value); + break; + case ParameterType.PARAMETER_STRING_ARRAY: + paramValue.string_array_value = Array.from(value); + break; + case ParameterType.PARAMETER_BYTE_ARRAY: + paramValue.byte_array_value = Array.from(value).map((v) => + Math.trunc(v) + ); + break; + } + + return paramValue; + } + + /** + * Deserialize a ParameterValue message to a JavaScript value. + * @private + * @param {object} paramValue - The ParameterValue message. + * @return {*} - The deserialized value. + */ + #deserializeParameterValue(paramValue) { + switch (paramValue.type) { + case ParameterType.PARAMETER_BOOL: + return paramValue.bool_value; + case ParameterType.PARAMETER_INTEGER: + return paramValue.integer_value; + case ParameterType.PARAMETER_DOUBLE: + return paramValue.double_value; + case ParameterType.PARAMETER_STRING: + return paramValue.string_value; + case ParameterType.PARAMETER_BYTE_ARRAY: + return Array.from(paramValue.byte_array_value || []); + case ParameterType.PARAMETER_BOOL_ARRAY: + return Array.from(paramValue.bool_array_value || []); + case ParameterType.PARAMETER_INTEGER_ARRAY: + return Array.from(paramValue.integer_array_value || []).map((v) => + typeof v === 'bigint' ? v : BigInt(v) + ); + case ParameterType.PARAMETER_DOUBLE_ARRAY: + return Array.from(paramValue.double_array_value || []); + case ParameterType.PARAMETER_STRING_ARRAY: + return Array.from(paramValue.string_array_value || []); + case ParameterType.PARAMETER_NOT_SET: + default: + return null; + } + } + + /** + * Normalize a node name by removing leading slash if present. + * @private + * @param {string} nodeName - The node name to normalize. + * @return {string} - The normalized node name. + */ + #normalizeNodeName(nodeName) { + return nodeName.startsWith('/') ? nodeName.substring(1) : nodeName; + } + + /** + * Convert a service type name from PascalCase to snake_case. + * @private + * @param {string} name - The name to convert. + * @return {string} - The snake_case name. + */ + #toSnakeCase(name) { + return name.replace(/[A-Z]/g, (letter, index) => { + return index === 0 ? letter.toLowerCase() : '_' + letter.toLowerCase(); + }); + } + + /** + * Throws an error if the client has been destroyed. + * @private + * @throws {Error} If the client has been destroyed. + */ + #throwErrorIfClientDestroyed() { + if (this.#destroyed) { + throw new Error('ParameterClient has been destroyed'); + } + } +} + +module.exports = ParameterClient; diff --git a/test/test-parameter-client.js b/test/test-parameter-client.js new file mode 100644 index 00000000..1e964cb4 --- /dev/null +++ b/test/test-parameter-client.js @@ -0,0 +1,624 @@ +// Copyright (c) 2025 Mahmoud Alghalayini. All rights reserved. +// +// 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 assert = require('assert'); +const rclnodejs = require('../index.js'); +const { ParameterType, Parameter, ParameterDescriptor } = rclnodejs; + +describe('ParameterClient tests', function () { + this.timeout(60 * 1000); + + let targetNode; + let clientNode; + let paramClient; + + beforeEach(async function () { + await rclnodejs.init(); + + targetNode = rclnodejs.createNode('target_node'); + + targetNode.declareParameter( + new Parameter('string_param', ParameterType.PARAMETER_STRING, 'hello'), + new ParameterDescriptor( + 'string_param', + ParameterType.PARAMETER_STRING, + 'A string parameter' + ) + ); + + targetNode.declareParameter( + new Parameter('int_param', ParameterType.PARAMETER_INTEGER, BigInt(42)), + new ParameterDescriptor('int_param', ParameterType.PARAMETER_INTEGER) + ); + + targetNode.declareParameter( + new Parameter('double_param', ParameterType.PARAMETER_DOUBLE, 3.14), + new ParameterDescriptor('double_param', ParameterType.PARAMETER_DOUBLE) + ); + + targetNode.declareParameter( + new Parameter('bool_param', ParameterType.PARAMETER_BOOL, true), + new ParameterDescriptor('bool_param', ParameterType.PARAMETER_BOOL) + ); + + targetNode.declareParameter( + new Parameter('int_array_param', ParameterType.PARAMETER_INTEGER_ARRAY, [ + BigInt(1), + BigInt(2), + BigInt(3), + ]), + new ParameterDescriptor( + 'int_array_param', + ParameterType.PARAMETER_INTEGER_ARRAY + ) + ); + + targetNode.declareParameter( + new Parameter( + 'double_array_param', + ParameterType.PARAMETER_DOUBLE_ARRAY, + [1.1, 2.2, 3.3] + ), + new ParameterDescriptor( + 'double_array_param', + ParameterType.PARAMETER_DOUBLE_ARRAY + ) + ); + + targetNode.declareParameter( + new Parameter( + 'string_array_param', + ParameterType.PARAMETER_STRING_ARRAY, + ['foo', 'bar'] + ), + new ParameterDescriptor( + 'string_array_param', + ParameterType.PARAMETER_STRING_ARRAY + ) + ); + + targetNode.declareParameter( + new Parameter( + 'byte_array_param', + ParameterType.PARAMETER_BYTE_ARRAY, + [100, 200, 255] + ), + new ParameterDescriptor( + 'byte_array_param', + ParameterType.PARAMETER_BYTE_ARRAY + ) + ); + + targetNode.declareParameter( + new Parameter( + 'A.B.nested', + ParameterType.PARAMETER_STRING, + 'nested_value' + ), + new ParameterDescriptor('A.B.nested', ParameterType.PARAMETER_STRING) + ); + + clientNode = rclnodejs.createNode('client_node'); + + paramClient = clientNode.createParameterClient('target_node'); + + rclnodejs.spin(targetNode); + rclnodejs.spin(clientNode); + + await paramClient.waitForService(5000); + }); + + afterEach(function () { + if (paramClient && !paramClient.isDestroyed()) { + paramClient.destroy(); + } + if (clientNode) { + clientNode.destroy(); + } + if (targetNode) { + targetNode.destroy(); + } + rclnodejs.shutdown(); + }); + + describe('Constructor and properties', function () { + it('should create ParameterClient with valid node and name', function () { + assert.ok(paramClient); + assert.strictEqual(paramClient.remoteNodeName, 'target_node'); + assert.strictEqual(paramClient.isDestroyed(), false); + }); + + it('should throw error if node is not provided', function () { + assert.throws( + () => new rclnodejs.ParameterClient(null, 'test_node'), + TypeError, + 'Node is required' + ); + }); + + it('should throw error if remote node name is empty', function () { + assert.throws( + () => new rclnodejs.ParameterClient(clientNode, ''), + TypeError, + 'Remote node name must be a non-empty string' + ); + }); + + it('should throw error if remote node name is invalid', function () { + assert.throws( + () => new rclnodejs.ParameterClient(clientNode, 'invalid@node'), + Error + ); + }); + + it('should normalize node name by removing leading slash', function () { + const pc = new rclnodejs.ParameterClient(clientNode, '/some_node'); + assert.strictEqual(pc.remoteNodeName, 'some_node'); + pc.destroy(); + }); + }); + + describe('getParameter', function () { + it('should get string parameter', async function () { + const param = await paramClient.getParameter('string_param'); + assert.strictEqual(param.name, 'string_param'); + assert.strictEqual(param.type, ParameterType.PARAMETER_STRING); + assert.strictEqual(param.value, 'hello'); + }); + + it('should get integer parameter', async function () { + const param = await paramClient.getParameter('int_param'); + assert.strictEqual(param.name, 'int_param'); + assert.strictEqual(param.type, ParameterType.PARAMETER_INTEGER); + assert.strictEqual(param.value, BigInt(42)); + }); + + it('should get double parameter', async function () { + const param = await paramClient.getParameter('double_param'); + assert.strictEqual(param.name, 'double_param'); + assert.strictEqual(param.type, ParameterType.PARAMETER_DOUBLE); + assert.strictEqual(param.value, 3.14); + }); + + it('should get boolean parameter', async function () { + const param = await paramClient.getParameter('bool_param'); + assert.strictEqual(param.name, 'bool_param'); + assert.strictEqual(param.type, ParameterType.PARAMETER_BOOL); + assert.strictEqual(param.value, true); + }); + + it('should get integer array parameter', async function () { + const param = await paramClient.getParameter('int_array_param'); + assert.strictEqual(param.name, 'int_array_param'); + assert.strictEqual(param.type, ParameterType.PARAMETER_INTEGER_ARRAY); + assert.deepStrictEqual(param.value, [BigInt(1), BigInt(2), BigInt(3)]); + }); + + it('should get double array parameter', async function () { + const param = await paramClient.getParameter('double_array_param'); + assert.strictEqual(param.name, 'double_array_param'); + assert.strictEqual(param.type, ParameterType.PARAMETER_DOUBLE_ARRAY); + assert.deepStrictEqual(param.value, [1.1, 2.2, 3.3]); + }); + + it('should get string array parameter', async function () { + const param = await paramClient.getParameter('string_array_param'); + assert.strictEqual(param.name, 'string_array_param'); + assert.strictEqual(param.type, ParameterType.PARAMETER_STRING_ARRAY); + assert.deepStrictEqual(param.value, ['foo', 'bar']); + }); + + it('should get byte array parameter', async function () { + const param = await paramClient.getParameter('byte_array_param'); + assert.strictEqual(param.name, 'byte_array_param'); + assert.strictEqual(param.type, ParameterType.PARAMETER_BYTE_ARRAY); + assert.deepStrictEqual(param.value, [100, 200, 255]); + }); + + it('should throw error for non-existent parameter', async function () { + try { + await paramClient.getParameter('nonexistent'); + assert.fail('Should have thrown error'); + } catch (error) { + assert.ok(error.message.includes('not found')); + } + }); + + it('should throw error if client is destroyed', async function () { + paramClient.destroy(); + try { + await paramClient.getParameter('string_param'); + assert.fail('Should have thrown error'); + } catch (error) { + assert.ok(error.message.includes('destroyed')); + } + }); + }); + + describe('getParameters', function () { + it('should get multiple parameters', async function () { + const params = await paramClient.getParameters([ + 'string_param', + 'int_param', + 'bool_param', + ]); + + assert.strictEqual(params.length, 3); + assert.strictEqual(params[0].name, 'string_param'); + assert.strictEqual(params[0].value, 'hello'); + assert.strictEqual(params[1].name, 'int_param'); + assert.strictEqual(params[1].value, BigInt(42)); + assert.strictEqual(params[2].name, 'bool_param'); + assert.strictEqual(params[2].value, true); + }); + + it('should return empty array for non-existent parameters', async function () { + const params = await paramClient.getParameters(['nonexistent']); + assert.strictEqual(params.length, 0); + }); + + it('should throw error if names is not an array', async function () { + try { + await paramClient.getParameters('string_param'); + assert.fail('Should have thrown error'); + } catch (error) { + assert.ok(error instanceof TypeError); + assert.ok(error.message.includes('non-empty array')); + } + }); + + it('should throw error if names is empty array', async function () { + try { + await paramClient.getParameters([]); + assert.fail('Should have thrown error'); + } catch (error) { + assert.ok(error instanceof TypeError); + } + }); + }); + + describe('setParameter', function () { + it('should set string parameter', async function () { + const result = await paramClient.setParameter('string_param', 'world'); + assert.ok(result.successful); + assert.strictEqual(result.name, 'string_param'); + + const param = await paramClient.getParameter('string_param'); + assert.strictEqual(param.value, 'world'); + }); + + it('should set integer parameter with automatic BigInt conversion', async function () { + const result = await paramClient.setParameter('int_param', 100); + assert.ok(result.successful); + + const param = await paramClient.getParameter('int_param'); + assert.strictEqual(param.value, BigInt(100)); + }); + + it('should set double parameter', async function () { + const result = await paramClient.setParameter('double_param', 2.71); + assert.ok(result.successful); + + const param = await paramClient.getParameter('double_param'); + assert.strictEqual(param.value, 2.71); + }); + + it('should set boolean parameter', async function () { + const result = await paramClient.setParameter('bool_param', false); + assert.ok(result.successful); + + const param = await paramClient.getParameter('bool_param'); + assert.strictEqual(param.value, false); + }); + + it('should set byte array from Uint8Array', async function () { + const bytes = new Uint8Array([50, 100, 150]); + const result = await paramClient.setParameter('byte_array_param', bytes); + assert.ok(result.successful); + + const param = await paramClient.getParameter('byte_array_param'); + assert.deepStrictEqual(param.value, [50, 100, 150]); + }); + }); + + describe('setParameters', function () { + it('should set multiple parameters', async function () { + const results = await paramClient.setParameters([ + { name: 'string_param', value: 'test' }, + { name: 'int_param', value: 99 }, + { name: 'bool_param', value: false }, + ]); + + assert.strictEqual(results.length, 3); + assert.ok(results.every((r) => r.successful)); + + const params = await paramClient.getParameters([ + 'string_param', + 'int_param', + 'bool_param', + ]); + assert.strictEqual(params[0].value, 'test'); + assert.strictEqual(params[1].value, BigInt(99)); + assert.strictEqual(params[2].value, false); + }); + + it('should throw error if parameters is not an array', async function () { + try { + await paramClient.setParameters({ name: 'test', value: 'value' }); + assert.fail('Should have thrown error'); + } catch (error) { + assert.ok(error instanceof TypeError); + } + }); + + it('should throw error if parameters is empty array', async function () { + try { + await paramClient.setParameters([]); + assert.fail('Should have thrown error'); + } catch (error) { + assert.ok(error instanceof TypeError); + } + }); + }); + + describe('listParameters', function () { + it('should list all parameters', async function () { + const result = await paramClient.listParameters(); + assert.ok(Array.isArray(result.names)); + assert.ok(result.names.length > 0); + + assert.ok(result.names.includes('string_param')); + assert.ok(result.names.includes('int_param')); + assert.ok(result.names.includes('bool_param')); + }); + + it('should list parameters with prefix filter', async function () { + const result = await paramClient.listParameters({ + prefixes: ['A'], + depth: 10, + }); + assert.ok(result.names.includes('A.B.nested')); + assert.ok(Array.isArray(result.prefixes)); + }); + + it('should list parameters with depth', async function () { + const result = await paramClient.listParameters({ + prefixes: ['A'], + depth: 2, + }); + assert.ok(Array.isArray(result.names)); + assert.ok(Array.isArray(result.prefixes)); + }); + }); + + describe('describeParameters', function () { + it('should describe single parameter', async function () { + const descriptors = await paramClient.describeParameters([ + 'string_param', + ]); + assert.strictEqual(descriptors.length, 1); + assert.strictEqual(descriptors[0].name, 'string_param'); + assert.strictEqual(descriptors[0].type, ParameterType.PARAMETER_STRING); + assert.strictEqual(descriptors[0].description, 'A string parameter'); + }); + + it('should describe multiple parameters', async function () { + const descriptors = await paramClient.describeParameters([ + 'string_param', + 'int_param', + ]); + assert.strictEqual(descriptors.length, 2); + assert.strictEqual(descriptors[0].name, 'string_param'); + assert.strictEqual(descriptors[1].name, 'int_param'); + }); + + it('should throw error if names is not an array', async function () { + try { + await paramClient.describeParameters('string_param'); + assert.fail('Should have thrown error'); + } catch (error) { + assert.ok(error instanceof TypeError); + } + }); + }); + + describe('getParameterTypes', function () { + it('should get types for single parameter', async function () { + const types = await paramClient.getParameterTypes(['string_param']); + assert.strictEqual(types.length, 1); + assert.strictEqual(types[0], ParameterType.PARAMETER_STRING); + }); + + it('should get types for multiple parameters', async function () { + const types = await paramClient.getParameterTypes([ + 'string_param', + 'int_param', + 'bool_param', + ]); + assert.strictEqual(types.length, 3); + assert.strictEqual(types[0], ParameterType.PARAMETER_STRING); + assert.strictEqual(types[1], ParameterType.PARAMETER_INTEGER); + assert.strictEqual(types[2], ParameterType.PARAMETER_BOOL); + }); + }); + + describe('Lifecycle management', function () { + it('should destroy parameter client', function () { + assert.strictEqual(paramClient.isDestroyed(), false); + paramClient.destroy(); + assert.strictEqual(paramClient.isDestroyed(), true); + }); + + it('should allow multiple destroy calls', function () { + paramClient.destroy(); + paramClient.destroy(); + assert.strictEqual(paramClient.isDestroyed(), true); + }); + + it('should auto-destroy when parent node is destroyed', function () { + const tempNode = rclnodejs.createNode('temp_node'); + const tempClient = tempNode.createParameterClient('target_node'); + + assert.strictEqual(tempClient.isDestroyed(), false); + tempNode.destroy(); + assert.strictEqual(tempClient.isDestroyed(), true); + }); + + it('should destroy via node.destroyParameterClient', function () { + const tempNode = rclnodejs.createNode('temp_node'); + const tempClient = tempNode.createParameterClient('target_node'); + + assert.strictEqual(tempClient.isDestroyed(), false); + tempNode.destroyParameterClient(tempClient); + assert.strictEqual(tempClient.isDestroyed(), true); + + tempNode.destroy(); + }); + }); + + describe('Timeout and cancellation', function () { + it('should respect custom timeout option', async function () { + // This test verifies timeout is passed through + // Actual timeout behavior is tested in test-async-client.js + const param = await paramClient.getParameter('string_param', { + timeout: 10000, + }); + assert.ok(param); + }); + + it('should support AbortSignal for cancellation', async function () { + const controller = new AbortController(); + + setTimeout(() => controller.abort(), 1); + + try { + await paramClient.getParameter('string_param', { + signal: controller.signal, + }); + assert.fail('Should have been aborted'); + } catch (error) { + assert.ok( + error.name === 'AbortError' || error.message.includes('abort') + ); + } + }); + }); + + describe('Type inference and serialization', function () { + it('should infer integer type from number', async function () { + const result = await paramClient.setParameter('int_param', 50); + assert.ok(result.successful); + + const param = await paramClient.getParameter('int_param'); + assert.strictEqual(param.type, ParameterType.PARAMETER_INTEGER); + assert.strictEqual(param.value, BigInt(50)); + }); + + it('should infer double type from float', async function () { + const result = await paramClient.setParameter('double_param', 1.23); + assert.ok(result.successful); + + const param = await paramClient.getParameter('double_param'); + assert.strictEqual(param.type, ParameterType.PARAMETER_DOUBLE); + assert.strictEqual(param.value, 1.23); + }); + + it('should infer byte array from Uint8Array', async function () { + const bytes = new Uint8Array([1, 2, 3]); + const result = await paramClient.setParameter('byte_array_param', bytes); + assert.ok(result.successful); + + const param = await paramClient.getParameter('byte_array_param'); + assert.strictEqual(param.type, ParameterType.PARAMETER_BYTE_ARRAY); + assert.deepStrictEqual(param.value, [1, 2, 3]); + }); + }); + + describe('Private field and method encapsulation', function () { + it('should not allow access to private fields', function () { + // Private fields should be completely inaccessible + assert.strictEqual(paramClient['#node'], undefined); + assert.strictEqual(paramClient['#remoteNodeName'], undefined); + assert.strictEqual(paramClient['#timeout'], undefined); + assert.strictEqual(paramClient['#clients'], undefined); + assert.strictEqual(paramClient['#destroyed'], undefined); + }); + + it('should not allow calling private methods', function () { + // Private methods should be completely inaccessible + assert.strictEqual(typeof paramClient['#getOrCreateClient'], 'undefined'); + assert.strictEqual( + typeof paramClient['#serializeParameterValue'], + 'undefined' + ); + assert.strictEqual( + typeof paramClient['#deserializeParameterValue'], + 'undefined' + ); + assert.strictEqual(typeof paramClient['#normalizeNodeName'], 'undefined'); + assert.strictEqual(typeof paramClient['#toSnakeCase'], 'undefined'); + assert.strictEqual(typeof paramClient['#checkNotDestroyed'], 'undefined'); + }); + + it('should only expose public API', function () { + // Verify only public methods are accessible + assert.strictEqual(typeof paramClient.getParameter, 'function'); + assert.strictEqual(typeof paramClient.getParameters, 'function'); + assert.strictEqual(typeof paramClient.setParameter, 'function'); + assert.strictEqual(typeof paramClient.setParameters, 'function'); + assert.strictEqual(typeof paramClient.listParameters, 'function'); + assert.strictEqual(typeof paramClient.describeParameters, 'function'); + assert.strictEqual(typeof paramClient.getParameterTypes, 'function'); + assert.strictEqual(typeof paramClient.waitForService, 'function'); + assert.strictEqual(typeof paramClient.isDestroyed, 'function'); + assert.strictEqual(typeof paramClient.destroy, 'function'); + + // Public getter + assert.strictEqual(typeof paramClient.remoteNodeName, 'string'); + }); + + it('should have truly private implementation', function () { + const allProps = Object.getOwnPropertyNames(paramClient); + const protoProps = Object.getOwnPropertyNames( + Object.getPrototypeOf(paramClient) + ); + + allProps.forEach((prop) => { + assert.ok( + !prop.startsWith('_'), + `Found underscore property: ${prop} (should use # instead)` + ); + assert.ok( + !prop.startsWith('#'), + `Found # property exposed: ${prop} (should be truly private)` + ); + }); + + protoProps.forEach((prop) => { + if (prop !== 'constructor') { + assert.ok( + !prop.startsWith('_'), + `Found underscore method: ${prop} (should use # instead)` + ); + assert.ok( + !prop.startsWith('#'), + `Found # method exposed: ${prop} (should be truly private)` + ); + } + }); + }); + }); +}); diff --git a/types/base.d.ts b/types/base.d.ts index 8aaec44c..3ea7822a 100644 --- a/types/base.d.ts +++ b/types/base.d.ts @@ -16,6 +16,7 @@ /// /// /// +/// /// /// /// diff --git a/types/node.d.ts b/types/node.d.ts index ccd7e8a7..24f8f794 100644 --- a/types/node.d.ts +++ b/types/node.d.ts @@ -407,6 +407,18 @@ declare module 'rclnodejs' { callback: ServiceRequestHandler ): ServiceType; + /** + * Create a ParameterClient for accessing parameters on a remote node. + * + * @param remoteNodeName - The name of the remote node whose parameters to access. + * @param options - Options for parameter client. + * @returns An instance of ParameterClient. + */ + createParameterClient( + remoteNodeName: string, + options?: { timeout?: number } + ): ParameterClient; + /** * Create a guard condition. * @@ -454,6 +466,13 @@ declare module 'rclnodejs' { */ destroyService(service: Service): void; + /** + * Destroy a ParameterClient. + * + * @param parameterClient - ParameterClient to be destroyed. + */ + destroyParameterClient(parameterClient: ParameterClient): void; + /** * Destroy a Timer. * diff --git a/types/parameter_client.d.ts b/types/parameter_client.d.ts new file mode 100644 index 00000000..6044e33f --- /dev/null +++ b/types/parameter_client.d.ts @@ -0,0 +1,252 @@ +// Copyright (c) 2025 Mahmoud Alghalayini. All rights reserved. +// +// 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. + +declare module 'rclnodejs' { + /** + * Options for ParameterClient constructor. + */ + export interface ParameterClientOptions { + /** + * Default timeout in milliseconds for service calls. + * @default 5000 + */ + timeout?: number; + } + + /** + * Options for parameter service calls. + */ + export interface ParameterServiceCallOptions { + /** + * Timeout in milliseconds for this specific call. + */ + timeout?: number; + + /** + * AbortSignal to cancel the request. + */ + signal?: AbortSignal; + } + + /** + * Result of a parameter set operation. + */ + export interface ParameterSetResult { + /** + * The name of the parameter. + */ + name: string; + + /** + * Whether the operation was successful. + */ + successful: boolean; + + /** + * Reason message, typically populated on failure. + */ + reason: string; + } + + /** + * Result of a list parameters operation. + */ + export interface ListParametersResult { + /** + * Array of parameter names found. + */ + names: string[]; + + /** + * Array of parameter prefixes found. + */ + prefixes: string[]; + } + + /** + * Options for listing parameters. + */ + export interface ListParametersOptions extends ParameterServiceCallOptions { + /** + * Optional array of parameter name prefixes to filter by. + */ + prefixes?: string[]; + + /** + * Depth of parameter namespace to list. + * @default 0 (unlimited) + */ + depth?: number; + } + + /** + * Parameter to set with name and value. + */ + export interface ParameterToSet { + /** + * The name of the parameter. + */ + name: string; + + /** + * The value to set. Type is automatically inferred. + */ + value: any; + } + + /** + * Class for accessing parameters on remote ROS 2 nodes. + * + * Provides promise-based APIs to get, set, list, and describe parameters + * on other nodes in the ROS 2 system. + */ + export class ParameterClient { + /** + * Create a new ParameterClient for accessing parameters on a remote node. + * + * @param node - The node to use for creating service clients. + * @param remoteNodeName - The name of the remote node whose parameters to access. + * @param options - Optional configuration for the parameter client. + * @throws {TypeError} If node is not provided or remoteNodeName is not a valid string. + * @throws {Error} If remoteNodeName is not a valid ROS node name. + */ + constructor( + node: Node, + remoteNodeName: string, + options?: ParameterClientOptions + ); + + /** + * Get the name of the remote node this client is connected to. + */ + readonly remoteNodeName: string; + + /** + * Get a single parameter from the remote node. + * + * @param name - The name of the parameter to retrieve. + * @param options - Optional timeout and abort signal. + * @returns Promise that resolves with the Parameter object. + * @throws {Error} If the parameter is not found or service call fails. + */ + getParameter( + name: string, + options?: ParameterServiceCallOptions + ): Promise; + + /** + * Get multiple parameters from the remote node. + * + * @param names - Array of parameter names to retrieve. + * @param options - Optional timeout and abort signal. + * @returns Promise that resolves with an array of Parameter objects. + * @throws {Error} If the service call fails. + * @throws {TypeError} If names is not a non-empty array. + */ + getParameters( + names: string[], + options?: ParameterServiceCallOptions + ): Promise; + + /** + * Set a single parameter on the remote node. + * + * @param name - The name of the parameter to set. + * @param value - The value to set. Type is automatically inferred. + * @param options - Optional timeout and abort signal. + * @returns Promise that resolves with the result. + * @throws {Error} If the service call fails. + */ + setParameter( + name: string, + value: any, + options?: ParameterServiceCallOptions + ): Promise; + + /** + * Set multiple parameters on the remote node. + * + * @param parameters - Array of parameter objects with name and value. + * @param options - Optional timeout and abort signal. + * @returns Promise that resolves with an array of results. + * @throws {Error} If the service call fails. + * @throws {TypeError} If parameters is not a non-empty array. + */ + setParameters( + parameters: ParameterToSet[], + options?: ParameterServiceCallOptions + ): Promise; + + /** + * List all parameters available on the remote node. + * + * @param options - Optional filters, depth, timeout and abort signal. + * @returns Promise that resolves with parameter names and prefixes. + * @throws {Error} If the service call fails. + */ + listParameters( + options?: ListParametersOptions + ): Promise; + + /** + * Describe parameters on the remote node. + * + * @param names - Array of parameter names to describe. + * @param options - Optional timeout and abort signal. + * @returns Promise that resolves with an array of parameter descriptors. + * @throws {Error} If the service call fails. + * @throws {TypeError} If names is not a non-empty array. + */ + describeParameters( + names: string[], + options?: ParameterServiceCallOptions + ): Promise; + + /** + * Get the types of parameters on the remote node. + * + * @param names - Array of parameter names. + * @param options - Optional timeout and abort signal. + * @returns Promise that resolves with an array of parameter types. + * @throws {Error} If the service call fails. + * @throws {TypeError} If names is not a non-empty array. + */ + getParameterTypes( + names: string[], + options?: ParameterServiceCallOptions + ): Promise; + + /** + * Wait for the parameter services to be available on the remote node. + * This is useful to verify the remote node is running before making calls. + * + * @param timeout - Optional timeout in milliseconds. + * @returns Promise that resolves to true if services are available. + */ + waitForService(timeout?: number): Promise; + + /** + * Check if the parameter client has been destroyed. + * + * @returns True if destroyed, false otherwise. + */ + isDestroyed(): boolean; + + /** + * Destroy the parameter client and clean up all service clients. + * After calling this method, the client cannot be used anymore. + */ + destroy(): void; + } +}