Source

bosdyn-client/sdk.js

'use strict';

const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const process = require('node:process');
const expandenv = require('expandenv');
const jwt = require('jsonwebtoken');
const moment = require('moment');

const { ArmSurfaceContactClient } = require('./arm_surface_contact');
const { AuthClient } = require('./auth');
const { AutoReturnClient } = require('./auto_return');
const { DEFAULT_MAX_MESSAGE_LENGTH } = require('./channel');
const { DataAcquisitionClient } = require('./data_acquisition');
const { DataAcquisitionStoreClient } = require('./data_acquisition_store');
const { DataBufferClient } = require('./data_buffer');
const { DataServiceClient } = require('./data_service');
const { DirectoryClient } = require('./directory');
const { DirectoryRegistrationClient } = require('./directory_registration');
const { DockingClient } = require('./docking');
const { DoorClient } = require('./door');
const { EstopClient } = require('./estop');
const { FaultClient } = require('./fault');
const { GraphNavClient } = require('./graph_nav');
const { ImageClient } = require('./image');
const { IREnableDisableServiceClient } = require('./ir_enable_disable');
const { LeaseClient } = require('./lease');
const { LicenseClient } = require('./license');
const { LocalGridClient } = require('./local_grid');
const { LogAnnotationClient } = require('./log_annotation');
const { LoggerUtil } = require('./loggerUtil');
const { ManipulationApiClient } = require('./manipulation_api_client');
const { MapProcessingServiceClient } = require('./map_processing');
const { NetworkComputeBridgeClient } = require('./network_compute_bridge_client');
const { PayloadClient } = require('./payload');
const { PayloadRegistrationClient } = require('./payload_registration');
const { PointCloudClient } = require('./point_cloud');
const { PowerClient } = require('./power');
const { AddRequestHeader } = require('./processors');
const { RayCastClient } = require('./ray_cast');
const { GraphNavRecordingServiceClient } = require('./recording');
const { Robot } = require('./robot');
const { RobotCommandClient } = require('./robot_command');
const { RobotIdClient } = require('./robot_id');
const { RobotStateClient } = require('./robot_state');
const { SpotCheckClient } = require('./spot_check');
const { TimeSyncClient } = require('./time_sync');
const { WorldObjectClient } = require('./world_object');

class SdkError extends Error {
  constructor(msg) {
    super(msg);
    this.name = 'SdkError';
  }
}

class UnsetAppTokenError extends SdkError {
  constructor(msg) {
    super(msg);
    this.name = 'UnsetAppTokenError';
  }
}

class UnableToLoadAppTokenError extends SdkError {
  constructor(msg) {
    super(msg);
    this.name = 'UnableToLoadAppTokenError';
  }
}

const BOSDYN_RESOURCE_ROOT = process.env.BOSDYN_RESOURCE_ROOT || path.resolve(expandenv('$USERPROFILE'), '.bosdyn');

const _LOGGER = LoggerUtil.getLogger('SDK');

function generate_client_name(prefix = '') {
  let process_info, user_name;

  try {
    process_info = `${path.basename(__filename)}-${process.pid}`;
  } catch (e) {
    process_info = process.pid;
  }

  const machine_name = os.hostname();

  if (!machine_name) {
    try {
      user_name = os.userInfo().username;
    } catch (e) {
      _LOGGER.warn('[SDK] Could not get username');
      user_name = '<unknown host>';
    }
  }
  return `${prefix}${machine_name || user_name}:${process_info}`;
}

const _DEFAULT_SERVICE_CLIENTS = [
  ArmSurfaceContactClient,
  AuthClient,
  AutoReturnClient,
  DataAcquisitionClient,
  DataAcquisitionStoreClient,
  DataBufferClient,
  DataServiceClient,
  DirectoryClient,
  DirectoryRegistrationClient,
  DockingClient,
  DoorClient,
  EstopClient,
  FaultClient,
  GraphNavClient,
  GraphNavRecordingServiceClient,
  ImageClient,
  IREnableDisableServiceClient,
  LeaseClient,
  LicenseClient,
  LogAnnotationClient,
  LocalGridClient,
  ManipulationApiClient,
  MapProcessingServiceClient,
  NetworkComputeBridgeClient,
  PayloadClient,
  PayloadRegistrationClient,
  PointCloudClient,
  PowerClient,
  RayCastClient,
  RobotCommandClient,
  RobotIdClient,
  RobotStateClient,
  SpotCheckClient,
  TimeSyncClient,
  WorldObjectClient,
];

/**
 * Return an Sdk with the most common configuration.
 *
 * @param {string} client_name_prefix Prefix to pass to generate_client_name()
 * @param {?Array} [service_clients] List of service client classes to register in addition to the defaults.
 * @param {?string} [cert_resource_glob] Glob expression matching robot certificate(s). Default null to
 * use distributed certificate.
 * @returns {Sdk} sdk
 * @throws {RangeError} Robot cert could not be loaded.
 */
function create_standard_sdk(client_name_prefix, service_clients = null, cert_resource_glob) {
  _LOGGER.debug(`[SDK] Creating standard Sdk, cert glob: "${cert_resource_glob}"`);
  const sdk = new Sdk(client_name_prefix);
  const client_name = generate_client_name(client_name_prefix);
  sdk.load_robot_cert(cert_resource_glob);
  sdk.request_processors.push(new AddRequestHeader(() => client_name));

  let all_service_clients = _DEFAULT_SERVICE_CLIENTS;
  if (service_clients !== null) {
    if (Array.isArray(service_clients)) {
      all_service_clients = all_service_clients.concat(service_clients);
    } else {
      all_service_clients.push(service_clients);
    }
  }

  for (const client of all_service_clients) {
    sdk.register_service_client(client);
  }

  return sdk;
}

/**
 * Repository for settings typically common to a single developer and/or robot fleet.
 * See also Robot for robot-specific settings.
 */
class Sdk {
  /**
   * Create an Sdk.
   * @param {string} [name=null] Name to identify the client when communicating with the robot.
   */
  constructor(name = null) {
    this.cert = null;
    this.client_name = name;
    this.logger = LoggerUtil.getLogger(name || 'bosdyn.Sdk');
    this.request_processors = [];
    this.response_processors = [];
    this.service_client_factories_by_type = {};
    this.service_type_by_name = {};
    this.robots = {};

    this.max_send_message_length = DEFAULT_MAX_MESSAGE_LENGTH;
    this.max_receive_message_length = DEFAULT_MAX_MESSAGE_LENGTH;
  }

  /**
   * Get a Robot initialized with this Sdk, creating it if it does not yet exist.
   * @param {string} address Network-resolvable address of the robot, e.g. '192.168.80.3'
   * @param {string} [name = null] A unique identifier for the robot, e.g. 'My First Robot'.
   * Default null to use the address as the name.
   * @returns {Robot} robot A Robot initialized with the current Sdk settings.
   */
  create_robot(address, name = null) {
    if (address in this.robots) {
      return this.robots[address];
    }
    const robot = new Robot(name || address);
    robot.address = address;

    robot.update_from(this);
    this.robots[address] = robot;

    return robot;
  }

  /**
   * Updates the send and receive max message length values in all the clients/channels created from this point on.
   * @param {number} max_message_length Max message length value to use for sending and receiving messages.
   * @returns {void}
   */
  set_max_message_length(max_message_length) {
    this.max_send_message_length = max_message_length;
    this.max_receive_message_length = max_message_length;
  }

  /**
   * Tell the Sdk how to create a specific type of service client.
   * @param {Object} creation_func Callable that returns a client. Typically just the class.
   * @param {string} [service_type = null] Type of the service. If null (default), will try to get
   * the name from creation_func.
   * @param {string} [service_name = null] Name of the service. If null (default), will try to get
   * the name from creation_func.
   * @returns {void}
   */
  register_service_client(creation_func, service_type = null, service_name = null) {
    service_name = service_name || creation_func.default_service_name;
    service_type = service_type || creation_func.service_type;

    if (service_name !== null) {
      this.service_type_by_name[service_name] = service_type;
    }
    this.service_client_factories_by_type[service_type] = creation_func;
  }

  /**
    * Load the SSL certificate for the robot.
    * @param {string} [resource_path_glob = null] Optional path to certificate resource(s).
    If null, will load the certificate in the 'resources' package.
    Otherwise, should be a glob expression to match certificates.
    Defaults to null.
    * @returns {void}
    */
  load_robot_cert(resource_path_glob = null) {
    this.cert = null;
    if (resource_path_glob === null) {
      const pathToResource = path.join(__dirname, 'resources', 'robot.pem');
      this.cert = fs.readFileSync(pathToResource, 'utf-8');
    } else {
      const cert_paths = [];
      fs.readdirSync(resource_path_glob).forEach(file => {
        const link = `${resource_path_glob}${resource_path_glob.endsWith('/') ? '' : '/'}${file}`;
        file = fs.statSync(link);
        if (file.isFile()) {
          cert_paths.push(link);
        }
      });
      if (cert_paths.length === 0) throw RangeError(`No files matched ${resource_path_glob}`);
      this.cert = '';
      for (const cert_path of cert_paths) {
        this.cert += fs.readFileSync(cert_paths[cert_path], 'utf-8');
      }
    }
  }

  /**
   * Remove all cached Robot instances.
   * Subsequent calls to create_robot() will return newly created Robots.
   * Existing robot instances will continue to work, but their time sync and token refresh
   * threads will be stopped.
   */
  clear_robots() {
    for (const robot of Object.values(this.robots)) {
      robot._shutdown();
    }
    this.robots = {};
  }

  /**
   * App tokens are no longer in use. Authorization is now handled via licenses.
   * @param {string} resource_path The path where the token is saved.
   * @deprecated since v2.0.1
   */
  // eslint-disable-next-line
  load_app_token(resource_path) {
    process.emitWarning('Calling deprecated function !', {
      code: 'Deprecated',
      detail: 'App tokens are no longer in use. Authorization is now handled via licenses.',
    });
  }
}

/**
* Decodes a JWT token without verification.
* @param {string} token A string representing a token.
* @returns {Object} val Object containing information about the token.
Empty object if failed to load token.
* @throw {UnableToLoadAppTokenError} If the token cannot be read.
* @deprecated since v3.0.1
*/
function decode_token(token) {
  process.emitWarning('Calling deprecated function !', {
    code: 'Deprecated',
    detail: 'Decoding tokens is no longer supported in the sdk. Use jsonwebtoken directly instead.',
  });
  const val = jwt.decode(token, { complete: true });
  if (token === undefined) {
    throw new UnableToLoadAppTokenError(`Problem decoding token, (maybe incorrectly formatted token) ${token}`);
  } else {
    return val;
  }
}

/**
 * Log the time remaining until app token expires.
 * @param {string} token A jwt token
 * @throw {UnableToLoadAppTokenError} If the token expiration information cannot be retrieved.
 * @deprecated since v3.0.1
 */
function log_token_time_remaining(token) {
  process.emitWarning('Calling deprecated function !', {
    code: 'Deprecated',
    detail: 'Decoding tokens is no longer supported in the sdk. Use jsonwebtoken directly instead.',
  });
  const token_values = decode_token(token);
  if (!('exp' in token_values.payload)) throw Error('Unknown token expiration');

  const expire_time = new Date(token_values.payload.exp * 1_000);
  const time_to_expiration = expire_time - new Date();
  if (time_to_expiration < 0) {
    _LOGGER.error(
      '[SDK] Your application token has expired. Please contact support@bostondynamics.com ' +
        'to request a robot license as application tokens have been deprecated.',
    );
  } else if (time_to_expiration <= 2_592_000_000) {
    _LOGGER.warn(
      `[SDK] Application token expires ${moment(new Date(expire_time), ['YYYY-MM-DD']).toNow()} on ${moment(
        new Date(expire_time),
        ['YYYY-MM-DD'],
      ).format(
        'YYYY-MM-DD HH:mm',
      )}. \nPlease contact support@bostondynamics.com to request a lease as application tokens have been deprecated.`,
    );
  } else {
    _LOGGER.debug(
      `[SDK] Application token expires on ${moment(new Date(expire_time), ['YYYY-MM-DD']).format('YYYY-MM-DD')}`,
    );
  }
}

module.exports = {
  generate_client_name,
  create_standard_sdk,
  decode_token,
  log_token_time_remaining,
  BOSDYN_RESOURCE_ROOT,
  Sdk,
  SdkError,
  UnsetAppTokenError,
  UnableToLoadAppTokenError,
};