rules/rules.js

/**
 * Rules namespace.
 * This namespace allows creation of openHAB rules.
 *
 * @namespace rules
 */

// typedefs need to be global for TypeScript to fully work
/**
 * @typedef {object} EventObject When a rule is triggered, the script is provided the event instance that triggered it. The specific data depends on the event type. The `EventObject` provides several information about that trigger.
 *
 * Note:
 * `Group****Trigger`s use the equivalent `Item****Trigger` as trigger for each member.
 * Time triggers do not provide any event instance, therefore no property is populated.
 *
 * @property {string} oldState only for {@link triggers.ItemStateChangeTrigger} & {@link triggers.GroupStateChangeTrigger}: Previous state of Item or Group that triggered event
 * @property {string} newState only for {@link triggers.ItemStateChangeTrigger} & {@link triggers.GroupStateChangeTrigger}: New state of Item or Group that triggered event
 * @property {string} receivedState only for {@link triggers.ItemStateUpdateTrigger} & {@link triggers.GroupStateUpdateTrigger}: State that triggered event
 * @property {string} receivedCommand only for {@link triggers.ItemCommandTrigger}, {@link triggers.GroupCommandTrigger}, {@link triggers.PWMTrigger} & {@link triggers.PIDTrigger} : Command that triggered event
 * @property {string} itemName for all triggers except {@link triggers.PWMTrigger}: name of Item that triggered event
 * @property {string} receivedEvent only for {@link triggers.ChannelEventTrigger}: Channel event that triggered event
 * @property {string} channelUID only for {@link triggers.ChannelEventTrigger}: UID of channel that triggered event
 * @property {string} oldStatus only for {@link triggers.ThingStatusChangeTrigger}: Previous state of Thing that triggered event
 * @property {string} newStatus only for {@link triggers.ThingStatusChangeTrigger}: New state of Thing that triggered event
 * @property {string} status only for {@link triggers.ThingStatusUpdateTrigger}: State of Thing that triggered event
 * @property {string} eventType for all triggers except {@link triggers.PWMTrigger}, {@link triggers.PIDTrigger}, time triggers: Type of event that triggered event (change, command, time, triggered, update)
 * @property {string} triggerType for all triggers except {@link triggers.PWMTrigger}, {@link triggers.PIDTrigger}, time triggers: Type of trigger that triggered event (for `TimeOfDayTrigger`: `GenericCronTrigger`)
 * @property {*} payload for most triggers
 */

/**
 * @callback RuleCallback When a rule is run, a callback is executed.
 * @param {EventObject} event
 */

/**
 * @typedef {object} RuleConfig configuration for {@link rules.JSRule}
 * @property {string} name name of the rule (used in UI)
 * @property {string} [description] description of the rule (used in UI)
 * @property {HostTrigger|HostTrigger[]} triggers which will fire the rule
 * @property {RuleCallback} execute callback to run when the rule fires
 * @property {string} [id] UID of the rule, if not provided, one is generated
 * @property {string[]} [tags] tags for the rule (used in UI)
 * @property {string} [ruleGroup] name of rule group to use
 * @property {boolean} [overwrite=false] whether to overwrite an existing rule with the same UID
 */

const GENERATED_RULE_ITEM_TAG = 'GENERATED_RULE_ITEM';

const items = require('../items');
const utils = require('../utils');
const log = require('../log')('rules');
const osgi = require('../osgi');
const triggers = require('../triggers');
const { automationManager, ruleRegistry } = require('@runtime/RuleSupport');

const RuleManager = osgi.getService('org.openhab.core.automation.RuleManager');

/**
  * Generates an Item name given the rule configuration.
  *
  * @private
  * @param {object} ruleConfig The rule config
  */
const _itemNameForRule = function (ruleConfig) {
  return 'vRuleItemFor' + items.safeItemName(ruleConfig.name);
};

/**
  * Links an Item to a rule. When the Item is switched on or off, so will the rule be.
  *
  * @private
  * @param {HostRule} rule The rule to link to the Item.
  * @param {items.Item} item the Item to link to the rule.
  */
const _linkItemToRule = function (rule, item) {
  JSRule({
    name: 'vProxyRuleFor' + rule.getName(),
    description: 'Generated Rule to toggle real rule for ' + rule.getName(),
    triggers: [
      triggers.ItemStateUpdateTrigger(item.name)
    ],
    execute: function (data) {
      try {
        const itemState = data.receivedState;
        log.debug('Rule toggle Item state received as ' + itemState);
        RuleManager.setEnabled(rule.getUID(), itemState !== 'OFF');
        log.info((itemState === 'OFF' ? 'Disabled' : 'Enabled') + ' rule ' + rule.getName() + ' [' + rule.getUID() + ']');
      } catch (e) {
        log.error('Failed to toggle rule ' + rule.getName() + ': ' + e);
      }
    }
  });
};

/**
  * Gets the groups that a rule-toggling Item should be a member of. Will create the group Item if necessary.
  *
  * @private
  * @param {RuleConfig} ruleConfig The rule config describing the rule
  * @returns {string} the group name to put the Item in
  */
const _getGroupForItem = function (ruleConfig) {
  if (ruleConfig.ruleGroup) {
    const groupName = 'gRules' + items.safeItemName(ruleConfig.ruleGroup);
    log.debug('Creating rule group ' + ruleConfig.ruleGroup);
    items.replaceItem({
      name: groupName,
      type: 'Group',
      groups: ['gRules'],
      label: ruleConfig.ruleGroup,
      tags: [GENERATED_RULE_ITEM_TAG]
    });
    return groupName;
  }

  return 'gRules';
};

/**
  * Check whether a rule exists.
  * Only works for rules created in the same file.
  *
  * @private
  * @param {string} uid the UID of the rule
  * @returns {boolean} whether the rule exists
  */
const _ruleExists = function (uid) {
  return !(RuleManager.getStatusInfo(uid) == null);
};

/**
  * Remove a rule when it exists. The rule will be immediately removed.
  * Only works for rules created in the same file.
  *
  * @memberof rules
  * @param {string} uid the UID of the rule
  * @returns {boolean} whether the rule was actually removed
  */
const removeRule = function (uid) {
  if (_ruleExists(uid)) {
    log.info('Removing rule: {}', ruleRegistry.get(uid).name ? ruleRegistry.get(uid).name : uid);
    ruleRegistry.remove(uid);
    return !_ruleExists(uid);
  } else {
    return false;
  }
};

/**
  * Runs the rule with the given UID. Throws errors when the rule doesn't exist
  * or is unable to run (e.g. it's disabled).
  *
  * @memberof rules
  * @param {string} uid the UID of the rule to run
  * @param {object} [args={}] args optional dict of data to pass to the called rule
  * @param {boolean} [cond=true] when true, the called rule will only run if it's conditions are met
  * @throws Will throw an error if the rule does not exist or is not initialized.
  */
const runRule = function (uid, args = {}, cond = true) {
  const status = RuleManager.getStatus(uid);
  if (!status) {
    throw Error('There is no rule with UID ' + uid);
  }
  if (status.toString() === 'UNINITIALIZED') {
    throw Error('Rule ' + uid + ' is UNINITIALIZED');
  }

  RuleManager.runNow(uid, cond, args);
};

/**
  * Tests to see if the rule with the given UID is enabled or disabled. Throws
  * and error if the rule doesn't exist.
  *
  * @memberof rules
  * @param {string} uid
  * @returns {boolean} whether or not the rule is enabled
  * @throws {Error} an error when the rule is not found.
  */
const isEnabled = function (uid) {
  if (!_ruleExists(uid)) {
    throw Error('There is no rule with UID ' + uid);
  }
  return RuleManager.isEnabled(uid);
};

/**
  * Enables or disables the rule with the given UID. Throws an error if the rule doesn't exist.
  *
  * @memberof rules
  * @param {string} uid UID of the rule
  * @param {boolean} isEnabled when true, the rule is enabled, otherwise the rule is disabled
  * @throws {Error} an error when the rule is not found.
  */
const setEnabled = function (uid, isEnabled) {
  if (!_ruleExists(uid)) {
    throw Error('There is no rule with UID ' + uid);
  }
  RuleManager.setEnabled(uid, isEnabled);
};

/**
  * Creates a rule. The rule will be created and immediately available.
  *
  * @example
  * import { rules, triggers } = require('openhab');
  *
  * rules.JSRule({
  *  name: "my_new_rule",
  *  description: "this rule swizzles the swallows",
  *  triggers: triggers.GenericCronTrigger("0 30 16 * * ? *"),
  *  execute: (event) => { // do stuff }
  * });
  *
  * @memberof rules
  * @param {RuleConfig} ruleConfig The rule config describing the rule
  * @returns {HostRule} the created rule
  * @throws {Error} an error if the rule with the passed in uid already exists and {@link RuleConfig.overwrite} is not `true`
  */
const JSRule = function (ruleConfig) {
  const ruleUID = ruleConfig.id || ruleConfig.name.replace(/[^\w]/g, '-') + '-' + utils.randomUUID();
  if (ruleConfig.overwrite === true) {
    removeRule(ruleUID);
  }
  if (_ruleExists(ruleUID)) {
    throw Error(`Failed to add rule: ${ruleConfig.name ? ruleConfig.name : ruleUID}, a rule with same UID [${ruleUID}] already exists!`);
  }
  log.info('Adding rule: {}', ruleConfig.name ? ruleConfig.name : ruleUID);

  const SimpleRule = Java.extend(Java.type('org.openhab.core.automation.module.script.rulesupport.shared.simple.SimpleRule'));

  const doExecute = function (module, input) {
    try {
      return ruleConfig.execute(_getTriggeredData(input));
    } catch (error) {
      // logging error is required for meaningful error log message
      // when throwing error: error is caught by core framework and no meaningful message is logged
      let msg;
      if (error.stack) {
        msg = `Failed to execute rule ${ruleUID}: ${error}: ${error.stack}`;
      } else {
        msg = `Failed to execute rule ${ruleUID}: ${error}`;
      }
      console.error(msg);
      throw Error(msg);
    }
  };

  let rule = new SimpleRule({
    execute: doExecute,
    getUID: () => ruleUID
  });

  rule.setTemplateUID(ruleUID); // Not sure if we need this at all

  if (ruleConfig.description) {
    rule.setDescription(ruleConfig.description);
  }
  if (ruleConfig.name) {
    rule.setName(ruleConfig.name);
  }
  if (ruleConfig.tags) {
    rule.setTags(utils.jsArrayToJavaSet(ruleConfig.tags));
  }

  // Register rule here
  if (ruleConfig.triggers) {
    if (!Array.isArray(ruleConfig.triggers)) ruleConfig.triggers = [ruleConfig.triggers];
    rule.setTriggers(ruleConfig.triggers);
    rule = automationManager.addRule(rule);
  } else {
    throw new Error(`Triggers are missing for rule "${ruleConfig.name ? ruleConfig.name : ruleUID}"!`);
  }

  // Add config to the action so that MainUI can show the script
  const actionConfiguration = rule.actions.get(0).getConfiguration();
  actionConfiguration.put('type', 'application/javascript;version=ECMAScript-2021'); // TODO: Update MIME type at some time
  actionConfiguration.put('script', '// Code to run when the rule fires:\n// Note that Rule Builder is currently not supported!\n\n' + ruleConfig.execute.toString());

  return rule;
};

/**
  * Creates a rule, with an associated SwitchItem that can be used to toggle the rule's enabled state.
  * The rule will be created and immediately available.
  *
  * @memberof rules
  * @param {RuleConfig} ruleConfig The rule config describing the rule
  * @returns {HostRule} the created rule
  * @throws {Error} an error is a rule with the given UID already exists.
  */
const SwitchableJSRule = function (ruleConfig) {
  if (!ruleConfig.name) {
    throw Error('No name specified for rule!');
  }

  // First create a toggling Item
  const itemName = _itemNameForRule(ruleConfig);
  const item = items.replaceItem({
    name: itemName,
    type: 'Switch',
    groups: [_getGroupForItem(ruleConfig)],
    label: ruleConfig.description,
    tags: [GENERATED_RULE_ITEM_TAG]
  });

  // create the real rule
  const rule = JSRule(ruleConfig);

  // hook up a rule to link the item to the actual rule
  _linkItemToRule(rule, item);

  if (item.isUninitialized) {
    // possibly load item's prior state
    let historicState = null;
    try {
      historicState = item.history.latestState();
    } catch (e) {
      log.warn(`Failed to get historic state of ${itemName} for rule ${ruleConfig.name}: ${e}`);
    }

    if (historicState !== null) {
      item.postUpdate(historicState);
    } else {
      item.sendCommand('ON');
    }
  }
};

/**
 * Adds a key's value from a Java HashMap to a JavaScript object (as string) if the HashMap has that key.
 * This function uses the mutable nature of JS objects and does not return anything.
 *
 * @private
 * @param {*} hashMap Java HashMap
 * @param {string} key key from the HashMap to add to the JS object
 * @param {object} object JavaScript object
 */
const _addFromHashMap = (hashMap, key, object) => {
  if (hashMap.containsKey(key)) object[key] = hashMap[key].toString();
};

/**
 * Get rule trigger data from raw Java input and generate JavaScript object.
 *
 * @private
 * @param {*} input raw Java input from openHAB core
 * @returns {rules.EventObject}
 */
const _getTriggeredData = function (input) {
  const event = input.get('event');
  const data = {};

  // Properties of data are dynamically added, depending on their availability

  // Item triggers
  if (input.containsKey('command')) data.receivedCommand = input.get('command').toString();
  _addFromHashMap(input, 'oldState', data);
  _addFromHashMap(input, 'newState', data);
  if (input.containsKey('state')) data.receivedState = input.get('state').toString();

  // Thing triggers
  _addFromHashMap(input, 'oldStatus', data);
  _addFromHashMap(input, 'newStatus', data);
  _addFromHashMap(input, 'status', data);

  // Only with event data (for Item, Thing & Channel triggers)
  if (event) {
    switch (Java.typeName(event.getClass())) {
      case 'org.openhab.core.items.events.GroupItemCommandEvent':
      case 'org.openhab.core.items.events.ItemCommandEvent':
        data.itemName = event.getItemName();
        data.eventType = 'command';
        data.triggerType = 'ItemCommandTrigger';
        break;
      case 'org.openhab.core.items.events.GroupItemStateChangedEvent':
      case 'org.openhab.core.items.events.ItemStateChangedEvent':
        data.itemName = event.getItemName();
        data.eventType = 'change';
        data.triggerType = 'ItemStateChangeTrigger';
        break;
      case 'org.openhab.core.items.events.GroupItemStateEvent':
      case 'org.openhab.core.items.events.ItemStateEvent':
        data.itemName = event.getItemName();
        data.eventType = 'update';
        data.triggerType = 'ItemStateUpdateTrigger';
        Object.defineProperty(
          data,
          'state',
          {
            get: function () {
              console.warn('"state" has been deprecated and will be removed in a future release. Please use "receivedState" instead.');
              return input.get('state').toString();
            }
          }
        );
        break;
      case 'org.openhab.core.thing.events.ThingStatusInfoChangedEvent':
        data.thingUID = event.getThingUID().toString();
        data.eventType = 'change';
        data.triggerType = 'ThingStatusChangeTrigger';
        break;
      case 'org.openhab.core.thing.events.ThingStatusInfoEvent':
        data.thingUID = event.getThingUID().toString();
        data.eventType = 'update';
        data.triggerType = 'ThingStatusUpdateTrigger';
        break;
      case 'org.openhab.core.thing.events.ChannelTriggeredEvent':
        data.channelUID = event.getChannel().toString();
        data.receivedEvent = event.getEvent();
        data.eventType = 'triggered';
        data.triggerType = 'ChannelEventTrigger';
        Object.defineProperty(
          data,
          'receivedTrigger',
          {
            get: function () {
              console.warn('"receivedTrigger" has been deprecated and will be removed in a future release. Please use "receivedEvent" instead.');
              return event.getEvent();
            }
          }
        );
        break;
    }
    data.eventClass = Java.typeName(event.getClass());
    try {
      if (event.getPayload()) {
        data.payload = JSON.parse(event.getPayload());
        log.debug('Extracted event payload {}', data.payload);
      }
    } catch (e) {
      log.warn('Failed to extract payload: {}', e.message);
    }
  }

  // Always
  _addFromHashMap(input, 'module', data);

  // If the ScriptEngine gets an empty input, the trigger is either time based or the rule is run manually!!
  if (input.size() === 0) {
    data.eventType = '';
    data.triggerType = '';
  }

  return data;
};

module.exports = {
  removeRule,
  runRule,
  isEnabled,
  setEnabled,
  JSRule,
  SwitchableJSRule
};