Source: lib/parameter_client.js

// 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;