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 Item-related triggers: name of Item that triggered event
 * @property {string} groupName for all `Group****Trigger`s: name of the group whose member 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} thingUID for all Thing-related triggers: UID of Thing that triggered event
 * @property {string} cronExpression for {@link triggers.GenericCronTrigger}: cron expression of the trigger
 * @property {string} time for {@link triggers.TimeOfDayTrigger}: time of day value of the trigger
 * @property {boolean} timeOnly for {@link triggers.DateTimeTrigger}: whether the trigger only considers the time part of the DateTime Item
 * @property {number} offset for {@link triggers.DateTimeTrigger}: offset in seconds added to the time of the DateTime Item
 * @property {string} eventType for all triggers except {@link triggers.PWMTrigger}, {@link triggers.PIDTrigger}: Type of event that triggered event (change, command, time, triggered, update, time)
 * @property {string} triggerType for all triggers except {@link triggers.PWMTrigger}, {@link triggers.PIDTrigger}: Type of trigger that triggered event
 * @property {string} eventClass for all triggers: Java class name of the triggering event
 * @property {string} module (user-defined or auto-generated) name of trigger
 * @property {*} raw original contents of the event including data passed from a calling rule
 * @property {*} payload if provided by event: payload of event in Java data types
 */

/**
 * @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
 * @property {string} [switchItemName] (optional and only for {@link SwitchableJSRule}) name of the switch Item, which will get created automatically if it is not existent
 */

const GENERATED_RULE_ITEM_TAG = 'GENERATED_RULE_ITEM';

const items = require('../items/items');
const { randomUUID, jsArrayToJavaSet } = require('../utils');
const log = require('../log')('rules');
const { getService } = require('../osgi');
const triggers = require('../triggers');
const time = require('../time');

const { automationManager, ruleRegistry } = require('@runtime/RuleSupport');

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

/**
  * 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.
  */
function _linkItemToRule (rule, item) {
  if (item.type !== 'Switch') {
    throw new Error('The linked Item for SwitchableJSRule must be a Switch 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
  */
function _getGroupForItem (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
  */
function _ruleExists (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
  */
function removeRule (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 {Error} throws an error if the rule does not exist or is not initialized.
  */
function runRule (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.
  */
function isEnabled (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.
  */
function setEnabled (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`
  */
function JSRule (ruleConfig) {
  const ruleUID = ruleConfig.id?.replace(/\W/g, '-') || ruleConfig.name.replace(/\W/g, '-') + '-' + 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'));

  function doExecute (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(jsArrayToJavaSet(ruleConfig.tags));
  }

  if (ruleConfig.triggers) {
    if (!Array.isArray(ruleConfig.triggers)) ruleConfig.triggers = [ruleConfig.triggers];
    rule.setTriggers(ruleConfig.triggers);
  } else {
    log.info(`Rule "${ruleConfig.name ? ruleConfig.name : ruleUID}" has no triggers and will only run when manually triggered.`);
  }
  // Register rule here
  rule = automationManager.addRule(rule);

  // Add config to the action so that MainUI can show the script
  const actionConfiguration = rule.actions.get(0).getConfiguration();
  actionConfiguration.put('type', 'application/javascript');
  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 Switch Item that can be used to toggle the rule's enabled state.
 * The rule will be created and immediately available.
 * The Switch Item will be created automatically unless you pass a {@link RuleConfig}`switchItemName` and an Item with that name already exists.
 *
 * @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.
 */
function SwitchableJSRule (ruleConfig) {
  if (!ruleConfig.name) {
    throw Error('No name specified for rule!');
  }

  // First create a toggling Item
  const itemName = ruleConfig.switchItemName || 'vRuleItemFor' + items.safeItemName(ruleConfig.name);
  if (!items.existsItem(itemName)) {
    log.info(`Creating Item: ${itemName}`);
    items.addItem({
      name: itemName,
      type: 'Switch',
      groups: [_getGroupForItem(ruleConfig)],
      label: ruleConfig.description,
      tags: [GENERATED_RULE_ITEM_TAG]
    });
  }
  const item = items.getItem(itemName);

  // 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.persistence.persistedState(time.ZonedDateTime.now()).state;
    } catch (e) {
      log.warn(`Failed to get historic state of ${item.name} for rule ${ruleConfig.name}: ${e}`);
    }

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

  RuleManager.setEnabled(rule.getUID(), item.state !== 'OFF');
}

/**
 * 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
 */
function _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}
 */
function _getTriggeredData (input) {
  const event = input.get('event');
  const data = {};

  // Add input to data to passthrough any properties not captured below
  data.raw = input;

  // Dynamically added properties, 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();

  // Group Item triggers
  if (input.containsKey('triggeringGroup')) data.groupName = input.get('triggeringGroup').getName();

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

  // Properties added if event is available

  if (event) {
    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);
    }

    // The source code of the trigger handlers provide an insight into the respective events,
    // see https://github.com/openhab/openhab-core/tree/main/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/internal/module/handler
    switch (data.eventClass) {
      case 'org.openhab.core.automation.events.ExecutionEvent':
        data.eventType = event.toString().split(' ').pop();
        break;
      case 'org.openhab.core.automation.events.TimerEvent':
        data.eventType = 'time';
        if (data.payload.cronExpression) {
          data.triggerType = 'GenericCronTrigger';
          data.cronExpression = data.payload.cronExpression.toString();
        } else if (data.payload.time) {
          data.triggerType = 'TimeOfDayTrigger';
          data.time = data.payload.time.toString();
        } else if (data.payload.itemName) {
          data.triggerType = 'DateTimeTrigger';
          data.itemName = data.payload.itemName.toString();
          data.timeOnly = data.payload.timeOnly; // boolean
          data.offset = data.payload.offset; // integer
        }
        break;
      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;
      // **StateEvents replaced by **StateUpdatedEvents in https://github.com/openhab/openhab-core/pull/3141
      case 'org.openhab.core.items.events.ItemStateUpdatedEvent':
      case 'org.openhab.core.items.events.GroupStateUpdatedEvent':
      case 'org.openhab.core.items.events.GroupItemStateEvent':
      case 'org.openhab.core.items.events.ItemStateEvent':
        data.itemName = event.getItemName();
        data.eventType = 'update';
        data.triggerType = 'ItemStateUpdateTrigger';
        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';
        break;
    }
  }

  _addFromHashMap(input, 'module', data);

  return data;
}

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