rulesx/sceneEngine.js

/**
 * Copyright (c) 2021 Florian Hotze
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Public License 2.0 which is available at
 * http://www.eclipse.org/legal/epl-2.0
 *
 * SPDX-License-Identifier: EPL-2.0
 */
const { items, rules, triggers } = require('openhab');

/**
 * Scene Engine
 *
 * Call scenes using an Item as controller and update the Item's state to the matching scene number on scene members' change.
 * To create a new scene engine, use {@link rulesx.createSceneEngine}.
 * @memberof rulesx
 */
class SceneEngine {
  /**
   * Constructor to create an instance. Do not call directly, instead call {@link getSceneEngine}.
   *
   * @hideconstructor
   * @param {object} sceneDefinition scenes definition
   * @param {string} sceneDefinition.controller name of Item that calls the scenes
   * @param {object[]} sceneDefinition.scenes Array of scenes
   * @param {number} sceneDefinition.scenes[].value integer identifying the scene
   * @param {object[]} sceneDefinition.scenes[].targets Array of scene members
   * @param {string} sceneDefinition.scenes[].targets[].item name of Item
   * @param {string} sceneDefinition.scenes[].targets[].value target state of Item
   * @param {boolean} [sceneDefinition.scenes[].targets[].required=true] whether the Item's state must match the target state when the engine gets the current scene on change of a member
   * @param {function} [sceneDefinition.scenes[].targets[].conditionFn] the Item is only commanded and required for scene checks if the evaluation of this function returns true
   */
  constructor (sceneDefinition) {
    if (typeof sceneDefinition.controller !== 'string') {
      throw Error('controller is not supplied or is not string!');
    }
    if (typeof sceneDefinition.scenes !== 'object') {
      throw Error('scenes is not an Array!');
    }
    this.controller = sceneDefinition.controller;
    this.scenes = sceneDefinition.scenes;
  }

  /**
   * Gets all required triggers for the scene rule.
   * For the controller a command trigger, for scene members change triggers.
   * Scene members that are not required are excluded from the triggers.
   *
   * @private
   * @returns {Array} rule triggers
   */
  getTriggers () {
    const ruleTriggers = [];
    const updateTriggers = [];
    console.debug(`Adding ItemCommandTrigger for [${this.controller}].`);
    ruleTriggers.push(triggers.ItemCommandTrigger(this.controller));
    // For each selectorState.
    for (let j = 0; j < this.scenes.length; j++) {
      const currentScene = this.scenes[j];
      // For for each sceneTarget, the member items that are required (default is required).
      for (let k = 0; k < currentScene.targets.length; k++) {
        const target = currentScene.targets[k];
        if (target.required !== false && updateTriggers.indexOf(target.item) === -1) {
          updateTriggers.push(target.item);
        }
      }
    }
    for (let i = 0; i < updateTriggers.length; i++) {
      console.debug(`Adding ItemStateChangeTrigger for [${updateTriggers[i]}].`);
      ruleTriggers.push(triggers.ItemStateChangeTrigger(updateTriggers[i]));
    }
    return ruleTriggers;
  }

  /**
   * Calls the scene. Sets the scene members to the given target state.
   *
   * @private
   * @param {string|number} sceneNumber value of controller / number of scene to call
   */
  callScene (sceneNumber) {
    sceneNumber = (typeof sceneNumber === 'number') ? sceneNumber : parseInt(sceneNumber);
    // Get the correct scene value.
    for (let j = 0; j < this.scenes.length; j++) {
      // Get the correct scene targets.
      if (this.scenes[j].value === sceneNumber) {
        console.info(`Call scene: Found value [${this.scenes[j].value}] of controller [${this.controller}].`);
        const targets = this.scenes[j].targets;
        // Send commands to member items.
        for (let curTarget = 0; curTarget < targets.length; curTarget++) {
          if (typeof targets[curTarget].conditionFn === 'function') {
            const result = targets[curTarget].conditionFn();
            if (result === true) {
              console.info(`Call scene: Commanding ${targets[curTarget].item} to ${targets[curTarget].value} as condition is met (conditionFn returned ${result}).`);
              items.getItem(targets[curTarget].item).sendCommand(targets[curTarget].value);
            } else {
              console.info(`Call scene: Not commanding ${targets[curTarget].item} to ${targets[curTarget].value} as condition is not met (conditionFn returned ${result}).`);
            }
          } else {
            console.info(`Call scene: Commanding ${targets[curTarget].item} to ${targets[curTarget].value}.`);
            items.getItem(targets[curTarget].item).sendCommand(targets[curTarget].value);
          }
        }
      }
    }
  }

  /**
   * When a scene member changes, check whether a scene and which scene matches all required targets.
   *
   * @private
   */
  checkScene () {
    let selectorValueMatching = 0; // The selector value of the matching scene.
    let sceneFound = false;
    // Check each scene. The first one matching is used.
    for (let curState = 0; curState < this.scenes.length && sceneFound === false; curState++) {
      let statesMatchingValue = true;
      // Checks whether scene's targets are matching. As soon as one is not matching it's target value, the next selector state is checked.
      for (let curTarget = 0; curTarget < this.scenes[curState].targets.length && statesMatchingValue === true; curTarget++) {
        const target = this.scenes[curState].targets[curTarget];
        if (!(target.required === false)) {
          const itemState = items.getItem(target.item).state.toString();
          console.debug(`Check scene (value [${this.scenes[curState].value}] of controller [${this.controller}]): Checking scene member [${target.item}] with state [${itemState}].`);
          let result = true;
          if (typeof target.conditionFn === 'function') {
            if (target.conditionFn() !== true) {
              console.debug(`Check scene (value [${this.scenes[curState].value}] of controller [${this.controller}]): Scene member [${target.item}] with state [${itemState}] is not required to match as conditionFn did not return true.`);
              result = false;
            }
          }
          if (result === true) {
          // Check whether the current Item state does not match the target state.
            if (!(
              (itemState === target.value) ||
             (itemState === '0' && target.value.toString().toUpperCase() === 'OFF') ||
             (itemState === '100' && target.value.toString().toUpperCase() === 'ON') ||
             (itemState === '0' && target.value.toString().toUpperCase() === 'UP') ||
             (itemState === '100' && target.value.toString().toUpperCase() === 'DOWN')
            )) {
              statesMatchingValue = false;
              console.debug(`Check scene (value [${this.scenes[curState].value}] of controller [${this.controller}]): Scene member [${target.item}] with state [${itemState}] does not match [${target.value}] or is not required to match.`);
            }
          }
        }
      }
      // When all members match the target value
      if (statesMatchingValue === true) {
        console.info(`Check scene: Found matching value [${this.scenes[curState].value}] of controller [${this.controller}].`);
        // Store the current value that is matching all required targets.
        selectorValueMatching = this.scenes[curState].value;
        sceneFound = true;
      }
      // Update controller.
      items.getItem(this.controller).postUpdate(selectorValueMatching.toString());
    }
  }

  /**
   * Returns the scene engine rule.
   * Do NOT call directly, instead use {@link getSceneEngine}.
   *
   * @private
   */
  getRule () {
    rules.JSRule({
      name: `SceneEngine for controller ${this.controller}`,
      description: 'Rule to run the SceneEngine.',
      triggers: this.getTriggers(),
      execute: event => {
        if (event.triggerType === 'ItemCommandTrigger') {
          console.info(`Call scene: Event [${event.triggerType}] of [${event.itemName}].`);
          this.callScene(event.receivedCommand);
        } else if (event.triggerType === 'ItemStateChangeTrigger') {
          console.info(`Check scene: Event [${event.triggerType}] of [${event.itemName}].`);
          this.checkScene();
        }
      },
      id: `sceneEngine-for-${this.controller}`,
      tags: ['@hotzware/openhab-tools', 'sceneEngine']
    });
  }
}

/**
 * Provides the {@link rulesx.SceneEngine}.
 *
 * @memberof rulesx
 * @param {object} sceneDefinition scenes definition
 * @param {string} sceneDefinition.controller name of Item that calls the scenes
 * @param {object[]} sceneDefinition.scenes Array of scenes
 * @param {number} sceneDefinition.scenes[].value integer identifying the scene
 * @param {object[]} sceneDefinition.scenes[].targets Array of scene members
 * @param {string} sceneDefinition.scenes[].targets[].item name of Item
 * @param {string} sceneDefinition.scenes[].targets[].value target state of Item
 * @param {boolean} [sceneDefinition.scenes[].targets[].required=true] whether the Item's state must match the target state when the engine gets the current scene on change of a member
 * @param {function} [sceneDefinition.scenes[].targets[].conditionFn] the Item is only commanded and required for scene checks if the evaluation of this function returns true
 */
const createSceneEngine = (sceneDefinition) => {
  // @ts-ignore
  new SceneEngine(sceneDefinition).getRule();
};

module.exports = {
  createSceneEngine
};