UNPKG

rclnodejs

Version:
507 lines (440 loc) 16.9 kB
// 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 { TypeValidationError, ParameterNotFoundError, OperationError, } = require('./errors.js'); const validator = require('./validator.js'); const { normalizeNodeName } = require('./utils.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 TypeValidationError('node', node, 'Node instance'); } if (!remoteNodeName || typeof remoteNodeName !== 'string') { throw new TypeValidationError( 'remoteNodeName', remoteNodeName, 'non-empty string' ); } this.#node = node; this.#remoteNodeName = 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<Parameter>} - 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 ParameterNotFoundError(name, 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<Parameter[]>} - 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 TypeValidationError('names', names, '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<object>} - 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<Array<{name: string, successful: boolean, reason: string}>>} - 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 TypeValidationError( 'parameters', parameters, '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<Array<object>>} - 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 TypeValidationError('names', names, '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<Array<number>>} - 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 TypeValidationError('names', names, '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<boolean>} - 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; } } /** * 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 OperationError('ParameterClient has been destroyed', { code: 'CLIENT_DESTROYED', entityType: 'parameter_client', entityName: this.#remoteNodeName, }); } } } module.exports = ParameterClient;