Source: DroneCommand.js

const DroneCommandArgument = require('./DroneCommandArgument');
const Enum = require('./util/Enum');
const { characteristicSendUuids } = require('./CharacteristicEnums');

/**
 * Buffer types
 *
 * @property {number} ACK - Acknowledgment of previously received data
 * @property {number} DATA - Normal data (no ack requested)
 * @property {number} NON_ACK - Same as DATA
 * @property {number} HIGH_PRIO - Not sure about this one could be LLD
 * @property {number} LOW_LATENCY_DATA - Treated as normal data on the network, but are given higher priority internally
 * @property {number} DATA_WITH_ACK - Data requesting an ack. The receiver must send an ack for this data unit!
 *
 * @type {Enum}
 */
const bufferType = new Enum({
  ACK: 0x02,
  DATA: 0x02,
  NON_ACK: 0x02,
  HIGH_PRIO: 0x02,
  LOW_LATENCY_DATA: 0x03,
  DATA_WITH_ACK: 0x04,
});

const bufferCharTranslationMap = new Enum({
  ACK: 'ACK_COMMAND',
  DATA: 'SEND_NO_ACK',
  NON_ACK: 'SEND_NO_ACK',
  HIGH_PRIO: 'SEND_HIGH_PRIORITY',
  LOW_LATENCY_DATA: 'SEND_NO_ACK',
  DATA_WITH_ACK: 'SEND_WITH_ACK',
});

/**
 * Drone command
 *
 * Used for building commands to be sent to the drone. It
 * is also used for the sensor readings.
 *
 * Arguments are automatically mapped on the object. This
 * means that it is easy to set command arguments. Default
 * arguments values are 0 or their enum equivalent by default.
 *
 * @example
 * const parser = new CommandParser();
 * const backFlip = parser.getCommand('minidrone', 'Animations', 'Flip', {direction: 'back'});
 * const frontFlip = backFlip.clone();
 *
 * backFlip.direction = 'front';
 *
 * drone.runCommand(backFlip);
 */
class DroneCommand {
  /**
   * Creates a new DroneCommand instance
   * @param {object} project - Project node from the xml spec
   * @param {object} class_ - Class node from the xml spec
   * @param {object} command - Command node from the xml spec
   */
  constructor(project, class_, command) {
    this._project = project;
    this._projectId = Number(project.$.id);
    this._projectName = String(project.$.name);

    this._class = class_;
    this._classId = Number(class_.$.id);
    this._className = String(class_.$.name);

    this._command = command;
    this._commandId = Number(command.$.id);
    this._commandName = String(command.$.name);

    this._deprecated = command.$.deprecated === 'true';
    this._description = String(command._).trim();
    this._arguments = (command.arg || []).map(x => new DroneCommandArgument(x));

    // NON_ACK, ACK or HIGH_PRIO. Defaults to ACK
    this._buffer = command.$.buffer || 'DATA_WITH_ACK';
    this._timeout = command.$.timeout || 'POP';

    this._mapArguments();
  }

  /**
   * The project id
   * @returns {number} - project id
   */
  get projectId() {
    return this._projectId;
  }

  /**
   * The project name (minidrone, common, etc)
   * @returns {string} - project name
   */
  get projectName() {
    return this._projectName;
  }

  /**
   * The class id
   * @returns {number} - class id
   */
  get classId() {
    return this._classId;
  }

  /**
   * The class name
   * @returns {string} - class name
   */
  get className() {
    return this._className;
  }

  /**
   * The command id
   * @returns {number} - command id
   */
  get commandId() {
    return this._commandId;
  }

  /**
   * The command name
   * @returns {string} - command name
   */
  get commandName() {
    return this._commandName;
  }

  /**
   * Array containing the drone arguments
   * @returns {DroneCommandArgument[]} - arguments
   */
  get arguments() {
    return this._arguments;
  }

  /**
   * Returns if the command has any arguments
   * @returns {boolean} - command has any arguments
   */
  hasArguments() {
    return this.arguments.length > 0;
  }

  /**
   * Get the argument names. These names are also mapped to the instance
   * @returns {string[]} - argument names
   */
  get argumentNames() {
    return this.arguments.map(x => x.name);
  }

  /**
   * Get the command description
   * @returns {string} - command description
   */
  get description() {
    return this._description;
  }

  /**
   * Get if the command has been deprecated
   * @returns {boolean} - deprecated
   */
  get deprecated() {
    return this._deprecated;
  }

  /**
   * Get the send characteristic uuid based on the buffer type
   * @returns {string} - uuid as a string
   */
  get sendCharacteristicUuid() {
    const t = bufferCharTranslationMap[this.bufferType] || 'SEND_WITH_ACK';

    return 'fa' + characteristicSendUuids[t];
  }

  /**
   * Checks if the command has a certain argument
   * @param {string} key - Argument name
   * @returns {boolean} - If the argument exists
   */
  hasArgument(key) {
    return this.arguments.findIndex(x => x.name === key) !== -1;
  }

  /**
   * Clones the instance
   * @returns {DroneCommand} - Cloned instance
   */
  clone() {
    const command = new this.constructor(this._project, this._class, this._command);

    for (let i = 0; i < this.arguments.length; i++) {
      command.arguments[i].value = this.arguments[i].value;
    }

    return command;
  }

  /**
   * Converts the command to it's buffer representation
   * @returns {Buffer} - Command buffer
   * @throws TypeError
   */
  toBuffer() {
    const bufferLength = 6 + this.arguments.reduce((acc, val) => val.getValueSize() + acc, 0);
    const buffer = new Buffer(bufferLength);

    buffer.fill(0);

    buffer.writeUInt16LE(this.bufferFlag, 0);

    // Skip command counter (offset 1) because it's set in DroneConnection::runCommand

    buffer.writeUInt16LE(this.projectId, 2);
    buffer.writeUInt16LE(this.classId, 3);
    buffer.writeUInt16LE(this.commandId, 4); // two bytes

    let bufferOffset = 6;

    for (const arg of this.arguments) {
      const valueSize = arg.getValueSize();

      switch (arg.type) {
        case 'u8':
        case 'u16':
        case 'u32':
        case 'u64':
          buffer.writeUIntLE(Math.floor(arg.value), bufferOffset, valueSize);
          break;
        case 'i8':
        case 'i16':
        case 'i32':
        case 'i64':
        case 'enum':
          buffer.writeIntLE(Math.floor(arg.value), bufferOffset, valueSize);
          break;
        case 'string':
          buffer.write(arg.value, bufferOffset, valueSize, 'ascii');
          break;
        case 'float':
          buffer.writeFloatLE(arg.value, bufferOffset);
          break;
        case 'double':
          buffer.writeDoubleLE(arg.value, bufferOffset);
          break;
        default:
          throw new TypeError(`Can't encode buffer: unknown data type "${arg.type}" for argument "${arg.name}" in ${this.getToken()}`);
      }

      bufferOffset += valueSize;
    }

    return buffer;
  }

  /**
   * Maps the arguments to the class
   * @returns {void}
   * @private
   */
  _mapArguments() {
    for (const arg of this.arguments) {
      const init = {
        enumerable: false,
        get: () => arg,
        set: v => {
          arg.value = v;
        },
      };

      Object.defineProperty(this, arg.name, init);
    }
  }

  /**
   * Returns a string representation of a DroneCommand
   * @param {boolean} debug - If extra debug information should be shown
   * @returns {string} - String representation if the instance
   * @example
   * const str = command.toString();
   *
   * str === 'minidrone PilotingSettingsState PreferredPilotingModeChanged mode="medium"(1)';
   * @example
   * const str = command.toString(true);
   *
   * str === 'minidrone PilotingSettingsState PreferredPilotingModeChanged (enum)mode="medium"(1)';
   */
  toString(debug = false) {
    const argStr = this.arguments.map(x => x.toString(debug)).join(' ').trim();

    return `${this.getToken()} ${argStr}`.trim();
  }

  /**
   * Get the command buffer type
   * @returns {string} - Buffer type
   */
  get bufferType() {
    return this._buffer.toUpperCase();
  }

  /**
   * Get the command buffer flag based on it's type
   * @returns {number} - Buffer flag
   */
  get bufferFlag() {
    return bufferType[this.bufferType];
  }

  /**
   * Indicates the required action to be taken in case the command times out
   * The value of this attribute can be either POP, RETRY or FLUSH, defaulting to POP
   * @returns {string} - Action name
   */
  get timeoutAction() {
    return this._timeout;
  }

  /**
   * Get the token representation of the command. This
   * is useful for registering sensors for example
   * @returns {string} - Command token
   * @example
   * const backFlip = parser.getCommand('minidrone', 'Animations', 'Flip', {direction: 'back'});
   *
   * backFlip.getToken() === 'minidrone-Animations-Flip';
   */
  getToken() {
    return [this.projectName, this.className, this.commandName].join('-');
  }
}

module.exports = DroneCommand;