rulesx/alerting.js

/**
 * Copyright (c) 2022 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, log, Quantity } = require('openhab');

const AlertManager = require('./alertManager');

const logger = log('org.openhab.automation.js.hotzware_openhab_tools.rulesx.alerting');

/**
 * @typedef {import("openhab/types/items/items").Item} Item
 * @typedef {import("openhab").QuantityClass} QuantityClass
 */

/**
 * Callback for sending an alert.
 *
 * @callback SendAlertCallback
 * @param {string} id the unique identifier for the alert
 * @param {string} message the message to be displayed in the alert
 */

/**
 * Callback for revoking an alert.
 *
 * @callback RevokeAlertCallback
 * @param {string} id the unique identifier of the alert to be revoked
 */

/**
 * Function to mute an alert for a specific contact Item for a certain time.
 *
 * @typedef {function} MuteAlertFunction
 * @param {string} contactItemName the name of the contact Item to mute
 * @param {number} duration the duration in minutes to mute the alert for
 */

/**
 * @typedef {Object} RainAlarmConfig configuration for rain alarm
 * @property {SendAlertCallback} sendAlertCallback callback to send an alert
 * @property {RevokeAlertCallback} revokeAlertCallback callback to revoke an alert
 * @property {string} rainalarmItemName name of the Item to monitor for rain
 * @property {string} [rainalarmActiveState=OPEN] state of the Item that indicates rain
 * @property {string} contactGroupName name of the contact group to monitor
 * @property {string[]} [ignoreItems] list of Item names to ignore
 * @property {string} messagePattern message pattern to use for alerts, use placeholder `%LABEL` for Item label
 * @property {string} [windspeedItemName] name of the wind speed Item
 * @property {Array<{ contactLevel: number, treshold: QuantityClass }>} [contactLevelToWindspeed] wind speed threshold as Quantity for individual contact levels
 * @property {Array<{ contactLevel: number, messagePattern: string }>} [contactLevelToMessagePattern] message pattern overrides for individual contact levels, use placeholder `%LABEL` for Item label
 */

/**
 * Create a rain alarm rule that monitors rain and wind conditions to raise alerts for open windows and doors when it rains.
 *
 * Please note that, if enabled, the wind speed condition is only evaluated when the rain alarm becomes active or when the contact opens.
 * It is not continuously monitored, so if the wind speed changes while the rain alarm is active, it will not trigger a alert.
 *
 * @memberof rulesx
 * @param {RainAlarmConfig} config
 */
function createRainAlarmRule (config) {
  if (!config.rainalarmActiveState) config.rainalarmActiveState = 'OPEN';
  if (!config.ignoreItems) config.ignoreItems = [];

  const alertManager = new AlertManager(`rainAlarm-${config.rainalarmItemName}-${config.contactGroupName}`, config.sendAlertCallback, config.revokeAlertCallback);

  rules.JSRule({
    name: `Rain Alarm for ${config.contactGroupName}`,
    description: 'Monitors rain and wind conditions to raise alerts for open windows & doors when it rains.',
    triggers: [
      triggers.ItemStateChangeTrigger(config.rainalarmItemName),
      triggers.GroupStateChangeTrigger(config.contactGroupName)
    ],
    execute: (event) => {
      function evaluateWindspeedAndSendAlert (windspeed, item) {
        const messagePattern = config.contactLevelToMessagePattern?.find(pair => pair.contactLevel === (item.numericState))?.messagePattern ?? config.messagePattern;
        const message = messagePattern.replace('%LABEL', item.label || item.name);

        const contactLevel = item.numericState ?? 1; // default to 1 if numericState is not available as 1 represents OPEN
        const windspeedThreshold = config.contactLevelToWindspeed.find(pair => pair.contactLevel === contactLevel)?.treshold ?? null;
        if (windspeed && windspeedThreshold) {
          // if wind speed thresholds are configured, check against them
          if (windspeed.greaterThanOrEqual(windspeedThreshold)) {
            logger.info(`Rain alarm: Wind speed ${windspeed} m/s is above threshold ${windspeedThreshold} for contact level ${contactLevel} of item ${item.name} of ${config.contactGroupName}, sending alert.`);
            alertManager.issueAlert(item.name, message);
          }
        } else {
          // if no wind speed thresholds are configured, send an alert
          logger.info(`Rain alarm: No wind speed configuration found for item ${item.name} of ${config.contactGroupName}, sending alert.`);
          alertManager.issueAlert(item.name, message);
        }
      }

      // Handle state change of the rain alarm item or manual trigger
      if (event.itemName === config.rainalarmItemName) {
        if (event.newState === config.rainalarmActiveState) {
          // if rain alarm is active: check for open windows and doors
          logger.info(`Rain alarm for ${config.contactGroupName} is now active, checking for open windows & doors.`);
          const openItems = items.getItem(config.contactGroupName).members
            .filter(item => !config.ignoreItems.includes(item.name))
            .filter(item => item.state === 'OPEN' || (item.numericState != null && item.numericState > 0));
          if (openItems.length > 0) {
            const windspeed = config.windspeedItemName ? items.getItem(config.windspeedItemName).quantityState : null;
            openItems.forEach(item => evaluateWindspeedAndSendAlert(windspeed, item));
          } else {
            logger.debug(`Rain alarm: No open windows or doors found in ${config.contactGroupName}.`);
          }
        } else {
          // if rain alarm is not active: revoke all alerts
          const count = alertManager.revokeAllAlerts();
          if (count > 0) {
            logger.info(`Rain alarm for ${config.contactGroupName} has become inactive, revoked ${count} alerts.`);
          }
        }
      } else if (event.itemName) {
        if (config.ignoreItems?.includes(event.itemName)) return;
        const rainAlarmState = items.getItem(config.rainalarmItemName).state;
        if (rainAlarmState !== config.rainalarmActiveState) {
          logger.debug(`Rain alarm for ${config.contactGroupName} is not active, ignoring state change of item ${event.itemName}.`);
          return; // if rain alarm is not active, ignore state changes of contact items
        }
        if (event.newState === 'CLOSED' || parseFloat(event.newState) === 0) {
          if (alertManager.revokeAlert(event.itemName)) {
            logger.info(`Rain alarm: Item ${event.itemName} in ${config.contactGroupName} has closed, revoked alert.`);
          }
        } else {
          const windspeed = config.windspeedItemName ? items.getItem(config.windspeedItemName).quantityState : null;
          const item = items.getItem(event.itemName);
          evaluateWindspeedAndSendAlert(windspeed, item);
        }
      }
    },
    id: `rainalarm-for-${config.contactGroupName}`,
    tags: ['@hotzware/openhab-tools', 'createRainAlarmRule', 'Alerting']
  });
}

/**
 * Callback for evaluating a temperature condition.
 *
 * @callback TemperatureConditionCallback
 * @param {QuantityClass} temperature the current temperature
 * @return {boolean} true if the temperature is in alarm range, false otherwise
 */

/**
 * Callback for getting the alerting delay depending on the temperature and the contact level.
 *
 * @callback TemperatureDelayCallback
 * @param {QuantityClass} temperature the current temperature
 * @param {number} contactLevel the contact level of the Item
 * @return {number} delay in minutes
 */

/**
 * Callback for getting the alert message pattern depending on the temperature and the contact level.
 * Use `%LABEL` as placeholder for the Item label.
 *
 * @callback TemperatureMessagePatternCallback
 * @param {QuantityClass} temperature the current temperature
 * @param {number} contactLevel the contact level of the Item
 * @return {string} message pattern
 */

/**
 * Callback for evaluating conditions per Item for the temperature alarm.
 *
 * @callback TemperaturePerItemConditionCallback
 * @param {QuantityClass} temperature the current temperature
 * @param {Item} item the Item to evaluate conditions for
 * @return {boolean} true if the conditions are met for the Item, false otherwise
 */

/**
 * @typedef {Object} TemperatureAlarmConfig configuration for heat and frost alarms
 * @property {string} name the name of the alarm, e.g. "Heat Alarm"
 * @property {SendAlertCallback} sendAlertCallback callback to send an alert
 * @property {RevokeAlertCallback} revokeAlertCallback callback to revoke an alert
 * @property {boolean} [repeat=false] whether to repeat the alert after the delay
 * @property {string} temperatureItemName name of the Item that to monitor the temperature
 * @property {string} contactGroupName name of the contact group to monitor
 * @property {string[]} [ignoreItems] list of Item names to ignore
 * @property {TemperatureConditionCallback} alarmConditionCallback callback to decide whether the alarm is active depending on the temperature
 * @property {TemperatureDelayCallback} delayCallback callback to get the delay in minutes for alerting depending on the temperature
 * @property {TemperatureMessagePatternCallback} messagePatternCallback callback to get the message pattern depending on the temperature
 * @property {TemperaturePerItemConditionCallback} [perItemConditionCallback] optional callback to evaluate conditions per Item
 */

/**
 * Create a rule for a temperature-based alarm that monitors the temperature and raises alerts for open windows and doors when the temperatur condition callback returns true.
 *
 * @memberof rulesx
 * @param {TemperatureAlarmConfig} config
 * @returns {MuteAlertFunction} a method to mute alerts for a specific contact Item for a certain time
 */
function createTemperatureAlarmRule (config) {
  if (config.repeat === undefined) config.repeat = false;
  if (!config.ignoreItems) config.ignoreItems = [];

  const alertManager = new AlertManager(`temperatureAlarm-${config.name}-${config.contactGroupName}`, config.sendAlertCallback, config.revokeAlertCallback);

  rules.JSRule({
    name: `${config.name} for ${config.contactGroupName}`,
    description: 'Monitors temperature and raises alerts for open windows & doors when the temperature is in alarm range.',
    triggers: [
      triggers.ItemStateChangeTrigger(config.temperatureItemName),
      triggers.GroupStateChangeTrigger(config.contactGroupName)
    ],
    execute: (event) => {
      function handleAlert (temperature, item) {
        const contactLevel = item.numericState ?? 1; // default to 1 if numericState is not available as 1 represents OPEN
        const delay = config.delayCallback(temperature, contactLevel);
        const messagePattern = config.messagePatternCallback(temperature, contactLevel);
        const message = messagePattern.replace('%LABEL', item.label || item.name);

        if (typeof config.perItemConditionCallback === 'function' && !config.perItemConditionCallback(temperature, item)) {
          logger.debug(`${config.name}: Conditions for item ${item.name} of ${config.contactGroupName} are not met, cancelling alert.`);
          return;
        }

        if (delay === 0) {
          if (alertManager.issueAlert(item.name, message)) {
            logger.info(`${config.name}: No delay for ${item.name} of ${config.contactGroupName}, sent alert immediately.`);
          }
        } else {
          if (alertManager.scheduleAlert(item.name, message, delay, config.repeat, AlertManager.RESCHEDULE_MODE.RESCHEDULE_IF_DELAY_CHANGED)) {
            logger.info(`${config.name}: Scheduled ${config.repeat ? 'repeating ' : ''}alert for ${item.name} of ${config.contactGroupName} with delay of ${delay} minutes.`);
          }
        }
      }

      if (event.itemName === config.temperatureItemName) {
        const temperature = Quantity(event.newState);
        if (config.alarmConditionCallback(temperature)) {
          // if alarm is active: check for open windows and doors
          logger.info(`${config.name} for ${config.contactGroupName} is now active, checking for open windows & doors.`);
          const openItems = items.getItem(config.contactGroupName).members
            .filter(item => !config.ignoreItems.includes(item.name))
            .filter(item => item.state === 'OPEN' || (item.numericState != null && item.numericState > 0));
          if (openItems.length > 0) {
            openItems.forEach(item => handleAlert(temperature, item));
          } else {
            logger.debug(`${config.name}: No open windows or doors found in ${config.contactGroupName}.`);
          }
        } else {
          // if alarm is not active: revoke all alerts
          const count = alertManager.revokeAllAlerts();
          if (count > 0) {
            logger.info(`${config.name} for ${config.contactGroupName} has become inactive, revoked ${count} alerts.`);
          }
        }
      } else if (event.itemName) {
        if (config.ignoreItems?.includes(event.itemName)) return;
        const temperature = items.getItem(config.temperatureItemName).quantityState;
        if (!config.alarmConditionCallback(temperature)) {
          logger.debug(`${config.name} for ${config.contactGroupName} is not active, ignoring state change of item ${event.itemName}.`);
          return; // if alarm is not active, ignore state changes of contact items
        }
        if (event.newState === 'CLOSED' || parseFloat(event.newState) === 0) {
          if (alertManager.revokeAlert(event.itemName)) {
            logger.info(`${config.name}: Item ${event.itemName} in ${config.contactGroupName} has closed, revoked alert.`);
          }
        } else {
          const item = items.getItem(event.itemName);
          handleAlert(temperature, item);
        }
      }
    },
    id: `temperatureAlarm-${config.name}-for-${config.contactGroupName}`,
    tags: ['@hotzware/openhab-tools', 'createTemperatureAlarmRule', 'Alerting']
  });

  return (contactItemName, delay) => {
    alertManager.muteAlert(contactItemName, delay);
  };
}

module.exports = {
  createRainAlarmRule,
  createTemperatureAlarmRule
};