rules/operation-builder.js

const parseDuration = require('parse-duration');
const items = require('../items/items');

/**
 * @typedef { import("../items/items").Item } Item
 * @private
 */

/**
 * Operation to execute as part of a rule
 * @hideconstructor
 */
class OperationBuilder {
  constructor (builder, fn) {
    /** @private */
    this._builder = builder;
    /** @private */
    this._fn = fn;
  }

  /** @private */
  _finishErr () {
    if (this._fn) {
      throw new Error('rule already completed');
    }
  }

  /** @private */
  _then (operation, group, name, description, tags, id) {
    this._builder.name = name;
    this._builder.description = description;
    this._builder.tags = tags;
    this._builder.id = id;
    this._builder.setOperation(operation, group);
  }

  /**
     * Build this rule
     *
     * @param {string} [name] of the rule
     * @param {string} [description] of the rule
     * @param {Array<String>} [tags] of the rule
     * @param {string} [id] of the rule
     */
  build (name, description, tags, id) {
    if (!this._fn) {
      throw new Error('Cannot call build without function');
    }
    this._then(this._fn, this.group, name, description, tags, id);
  }

  /**
     * Specify the rule group for this rule
     *
     * @param {string} group the group this rule belongs to.
     * @returns {OperationBuilder} this
     */
  inGroup (group) {
    this.group = group;
    return this;
  }

  /**
    * Specifies that a command should be sent as a result of this rule firing.
    *
    * @param {string} command the command to send
    * @returns {SendCommandOrUpdateOperation} the operation
    */
  send (command) {
    this._finishErr();
    return new SendCommandOrUpdateOperation(this, command);
  }

  /**
     * Specifies that an update should be posted as a result of this rule firing.
     *
     * @param {string} update the update to send
     * @returns {SendCommandOrUpdateOperation} the operation
     */
  postUpdate (update) {
    this._finishErr();
    return new SendCommandOrUpdateOperation(this, update, false);
  }

  /**
     * Specifies the command 'ON' should be sent as a result of this rule firing.
     *
     * @returns {SendCommandOrUpdateOperation} the operation
     */
  sendOn () {
    this._finishErr();
    return new SendCommandOrUpdateOperation(this, 'ON');
  }

  /**
     * Specifies the command 'OFF' should be sent as a result of this rule firing.
     *
     * @returns {SendCommandOrUpdateOperation} the operation
     */
  sendOff () {
    this._finishErr();
    return new SendCommandOrUpdateOperation(this, 'OFF');
  }

  /**
     * Specifies a command should be sent to toggle the state of the target object
     * as a result of this rule firing.
     *
     * @returns {ToggleOperation} the operation
     */
  sendToggle () {
    this._finishErr();
    return new ToggleOperation(this);
  }

  /**
     * Specifies a command should be forwarded to the state of the target object
     * as a result of this rule firing. This relies on the trigger being the result
     * of a command itself.
     *
     * @returns {SendCommandOrUpdateOperation} the operation
     */
  sendIt () {
    this._finishErr();
    return new SendCommandOrUpdateOperation(this, args => args.it.toString(), true, 'it');
  }

  /**
     * Specifies a command state should be posted to the target object
     * as a result of this rule firing. This relies on the trigger being the result
     * of a command itself.
     *
     * @returns {SendCommandOrUpdateOperation} the operation
     */
  postIt () {
    this._finishErr();
    return new SendCommandOrUpdateOperation(this, args => args.it.toString(), false, 'it');
  }

  /**
     * Copies the state from one item to another. Can be used to proxy item state. State is updated, not
     * sent as a command.
     *
     * @returns {CopyStateOperation} the operation config
     */
  copyState () {
    this._finishErr();
    return new CopyStateOperation(this, false);
  }

  /**
     * Sends the state from one item to another. Can be used to proxy item state. State is
     * sent as a command.
     *
     * @returns {CopyStateOperation} the operation config
     */
  copyAndSendState () {
    this._finishErr();
    return new CopyStateOperation(this, true);
  }
}

/**
 * {RuleBuilder} RuleBuilder triggers
 * @memberof OperationBuilder
 */
class OperationConfig {
  constructor (operationBuilder) {
    this.operationBuilder = operationBuilder;
  }

  /**
     * Specify the rule group for this rule
     *
     * @param {string} group the group this rule belongs to.
     * @returns {OperationBuilder} this
     */
  inGroup (group) {
    this.group = group;
    return this;
  }

  /**
     * Build this rule
     *
     * @param {string} [name] of the rule
     * @param {string} [description] of the rule
     * @param {Array<String>} [tags] of the rule
     * @param {string} [id] of the rule
     */
  build (name, description, tags, id) {
    this.operationBuilder._then(this, this.group, name, description, tags, id);
  }
}
/**
 * Copies state from one item to another item
 *
 * @memberof OperationBuilder
 * @extends OperationConfig
 * @hideconstructor
 */
class CopyStateOperation extends OperationConfig {
  /**
     * Creates a new operation. Don't use constructor directly.
     *
     * @param {boolean} send whether to send (or post update) the state
     * @hideconstructor
     */
  constructor (operationBuilder, send) {
    super(operationBuilder);
    this.send = send;
  }

  /**
     * Sets the item to copy the state from
     *
     * @param {string} itemName the item to copy state from
     * @returns {CopyStateOperation} this
     */
  fromItem (itemName) {
    this.from_item = itemName;
    return this;
  }

  /**
     * Sets the item to copy the state to
     *
     * @param {string} itemName the item to copy state to
     * @returns {CopyStateOperation} this
     */
  toItem (itemName) {
    this.to_item = itemName;
    return this;
  }

  /**
     * Appends another operation to execute when the rule fires
     * @returns {CopyStateOperation} this
     */
  and () {
    const next = new OperationBuilder(this.operationBuilder.builder, this.fn);
    this.next = next;
    return next;
  }

  /**
     * Runs the operation. Don't call directly.
     *
     * @private
     * @param {object} args rule firing args
     */
  _run (args) {
    if (typeof this.from_item === 'undefined' || this.from_item === null) {
      throw Error('From item not set');
    }

    if (typeof this.to_item === 'undefined' || this.to_item === null) {
      throw Error('To item not set');
    }

    const from = items.getItem(this.from_item);
    if (typeof from === 'undefined' || from === null) {
      throw Error(`Cannot find (from) item ${this.from_item}`);
    }

    const to = items.getItem(this.to_item);
    if (typeof to === 'undefined' || to === null) {
      throw Error(`Cannot find (to) item ${this.to_item}`);
    }

    if (this.send) {
      to.sendCommand(from.state);
    } else {
      to.postUpdate(from.state);
    }
    if (this.next) {
      this.next.execute(args);
    }
  }

  /**
     * Checks that the operation configuration is complete. Don't call directly.
     *
     * @private
     * @returns true only if the operation is ready to run
     */
  _complete () {
    return this.from_item && this.to_item;
  }

  /**
     * Describes the operation.
     *
     * @private
     * @returns a description of the operation
     */
  describe () {
    return `copy state from ${this.from_item} to ${this.to_item}`;
  }
}

/**
 * Sends a command or update to an item
 *
 * @memberof OperationBuilder
 * @extends OperationConfig
 * @hideconstructor
 */
class SendCommandOrUpdateOperation extends OperationConfig {
  constructor (operationBuilder, dataOrSupplier, isCommand = true, optionalDesc) {
    super(operationBuilder);
    /** @private */
    this.isCommand = isCommand;
    if (typeof dataOrSupplier === 'function') {
      this.dataFn = dataOrSupplier;
      this.dataDesc = optionalDesc || '[something]';
    } else {
      this.dataFn = () => dataOrSupplier;
      this.dataDesc = optionalDesc || dataOrSupplier;
    }
  }

  /**
     * Send command to multiple items
     *
     * @param {Item[] | string[]} itemsOrNames the items to send a command to
     * @returns {SendCommandOrUpdateOperation} this
     */
  toItems (itemsOrNames) {
    this.toItemNames = itemsOrNames.map(i => (typeof i === 'string') ? i : i.name);
    return this;
  }

  /**
     * Send command to an item
     *
     * @param {Item | string} itemOrName the item to send a command to
     * @returns {SendCommandOrUpdateOperation} this
     */
  toItem (itemOrName) {
    this.toItemNames = [(typeof itemOrName === 'string') ? itemOrName : itemOrName.name];
    return this;
  }

  /**
     * Send another command
     * @param {*} next
     * @returns {SendCommandOrUpdateOperation} this
     */
  and (next) {
    this.next = next;
    return this;
  }

  /** @private */
  _run (args) {
    for (const toItemName of this.toItemNames) {
      const item = items.getItem(toItemName);
      const data = this.dataFn(args);

      if (this.isCommand) {
        item.sendCommand(data);
      } else {
        item.postUpdate(data);
      }
    }
    this.next && this.next.execute(args);
  }

  /** @private */
  _complete () {
    return (typeof this.toItemNames) !== 'undefined';
  }

  /** @private */
  describe (compact) {
    if (compact) {
      return this.dataDesc + (this.isCommand ? '⌘' : '↻') + this.toItemNames + (this.next ? this.next.describe() : '');
    } else {
      return (this.isCommand ? 'send command' : 'post update') + ` ${this.dataDesc} to ${this.toItemNames}` + (this.next ? ` and ${this.next.describe()}` : '');
    }
  }
}

/**
 * Toggles the state of an item
 *
 * @memberof OperationBuilder
 * @extends OperationConfig
 * @hideconstructor
 */
class ToggleOperation extends OperationConfig {
  constructor (operationBuilder) {
    super(operationBuilder);
    /** @private */
    this.next = null;
    /** @type {function} */
    this.toItem = function (itemName) {
      this.itemName = itemName;
      return this;
    };
    /** @type {function} */
    this.and = function (next) {
      this.next = next;
      return this;
    };
    /** @private */
    this._run = () => this.doToggle() && (this.next && this.next.execute());
    /** @private */
    this._complete = () => true;
    /** @private */
    this.describe = () => `toggle ${this.itemName}` + (this.next ? ` and ${this.next.describe()}` : '');
  }

  /**
     * Toggle the state of an item
     *
     * @returns {SendCommandOrUpdateOperation} this
     */
  doToggle () {
    items.getItem(this.itemName).sendToggleCommand();
  }
}

/**
 * Timing Item state
 *
 * @memberof OperationBuilder
 * @extends OperationConfig
 * @hideconstructor
 */
class TimingItemStateOperation extends OperationConfig {
  constructor (operationBuilder, itemChangedTriggerConfig, duration) {
    super(operationBuilder);
    if (typeof itemChangedTriggerConfig.to_value === 'undefined') {
      throw Error('Must specify item state value to wait for!');
    }

    /** @private */
    this.item_changed_trigger_config = itemChangedTriggerConfig;
    /** @private */
    this.duration_ms = (typeof duration === 'number' ? duration : parseDuration.parse(duration));

    /** @private */
    this._complete = itemChangedTriggerConfig._complete;
    /** @private */
    this.describe = () => itemChangedTriggerConfig.describe() + ' for ' + duration;
  }

  /** @private */
  _toOHTriggers () {
    // each time we're triggered, set a callback.
    // If the item changes to something else, cancel the callback.
    // If the callback executes, run the operation

    // register for all changes as we need to know when it changes away
    switch (this.op_type) {
      case 'changed':
        return [triggers.ChangedEventTrigger(this.item_name)]; // eslint-disable-line no-undef
      default:
        throw Error('Unknown operation type: ' + this.op_type);
    }
  }

  /** @private */
  _executeHook (next) {
    if (items.get(this.item_changed_trigger_config.item_name).toString() === this.item_changed_trigger_config.to_value) {
      this._startWait(next);
    } else {
      this._cancelWait();
    }
  }

  /** @private */
  _startWait (next) {
    this.current_wait = setTimeout(next, this.duration_ms);
  }

  _cancelWait () {
    if (this.current_wait) {
      clearTimeout(this.current_wait);
    }
  }
}

module.exports = {
  SendCommandOrUpdateOperation,
  TimingItemStateOperation,
  ToggleOperation,
  CopyStateOperation,
  OperationBuilder
};