uid: rules_tools:threshold_alert label: Threshold Alert description: Triggers on a change to a member of a Group. If the new state meets a user defined criteria, an alert rule is called. When exiting an alert state, an end alert rule is called. Running this rule manually will validate the configuration. configDescriptions: - name: group label: Triggering Group description: Group whose members should trigger this rule type: TEXT context: item filterCriteria: - name: type value: Group required: true - name: thresholdState label: Threshold State description: The state compared to to determine if the Item is alerting or not. This supports units. type: TEXT required: true - name: operator label: Comparison Operator description: Comparison used with Threshold State to determine if the current state is alerting. type: TEXT required: true options: - label: '= equals' value: '==' - label: '!= not equal to' value: '!=' - label: '< less than' value: '<' - label: '<= less than or equal' value: '<=' - label: '> greater than' value: '>' - label: '>= greater than or equal' value: '>=' limitToOptions: true - name: invert label: Invert Comparison description: Inverts the comparison to mean the opposite (e.g. if true and operator == will mean !=) type: BOOLEAN defaultValue: false required: false - name: reschedule label: Reschedule description: If an alert timer already exists from a previous run of the rule, when true reschedule it. Otherwise the alert is ignored and the alert rule is called based on the previous event. type: BOOLEAN defaultValue: false required: false - name: hysteresis label: Hysteresis description: Optional value above/below the threshold the state must achieve before it is considered to nolonger alerting. type: TEXT defaultValue: '' required: false - name: defaultAlertDelay label: Alert Delay description: How long to wait before calling the rule when the Item meets the threshold comparison, ISO8061 duration format. Can be overridden by Item metadata. type: TEXT defaultValue: '' required: false - name: defaultRemPeriod label: Reminder Period description: How long to wait between repeated calls to the threshold rule, ISO8601 duration format. Can be overridden by Item metadata. type: TEXT defaultValue: '' required: false - name: namespace label: Metadata Namespace description: The Item metadata namespace where per Item alert delay and reminder periods can be defined type: TEXT defaultValue: thresholdAlert required: false - name: alertRule label: Alert Rule description: The rule called when the Item meets the threshold criteria for at least as long as alert delay. This rule will be called repeatedly if reminder period is defined. type: TEXT context: rule required: true - name: endAlertRule label: End Alert Rule description: The rule called when the Item was previously alerted but no longer meets the threshold criteria. This is optional, will only be called once, and can be the same as the alert rule. type: TEXT context: rule defaultValue: '' required: false - name: initAlertRule label: Initial Alert Rule description: The rule called when the Item immediately enters the alert state regardless of what the alert delay is set to. type: TEXT context: rule defaultValue: '' required: false - name: dndStart label: Do Not Disturb Start Time description: Start of an optional do not disturb period. Calls to either the alert rule or the end alert rule will be suppressed during this period with the latest call made at the end of the period. type: TEXT context: time defaultValue: '' required: false - name: dndEnd label: Do Not Distrub End Time description: End of the do not disturb period. If this time is before the start time, the period is assumed to span midnight. type: TEXT context: time defaultValue: '' required: false - name: gkDelay label: Gatekeeper Delay description: Sometimes a delay is needed between calls to the rules to avoid multithreaded exceptions. The delay is in milliseconds. type: INTEGER defaultValue: 0 required: false - name: rateLimit label: Rate Limit description: Only allow the alert rule to be called this often. Events that occur before that time are dropped. Use ISO8601 duration formatting. If used with a Gatekeeper Delay it should be significantly longer. type: TEXT defaultValue: '' required: false triggers: - id: "2" configuration: groupName: "{{group}}" type: core.GroupStateChangeTrigger conditions: [] actions: - inputs: {} id: "1" configuration: type: application/javascript script: >- // Version 0.14 var {helpers, LoopingTimer, Gatekeeper, timeUtils, RateLimit} = require('openhab_rules_tools'); var {DecimalType, QuantityType, PercentType} = require('@runtime'); var loggerBase = 'org.openhab.automation.rules_tools.Threshold Alert.'+ruleUID; console.loggerName = loggerBase; //osgi.getService('org.apache.karaf.log.core.LogService').setLevel(console.loggerName, 'DEBUG'); helpers.validateLibraries('4.1.0', '2.0.3'); console.debug('Starting threshold alert'); // Properties var group = '{{group}}'; var thresholdStr = '{{thresholdState}}'; var operator = '{{operator}}'; var comparison = (currState, threshold) => { return currState {{operator}} threshold; }; var invert = {{invert}}; var defaultAlertDelay = '{{defaultAlertDelay}}'; var defaultRemPeriod = '{{defaultRemPeriod}}'; var namespace = '{{namespace}}'; var alertRuleUID = '{{alertRule}}'; var endAlertUID = '{{endAlertRule}}'; var dndStart = '{{dndStart}}'; var dndEnd = '{{dndEnd}}'; var gkDelay = {{gkDelay}}; var hystRange = '{{hysteresis}}'; var rateLimitPeriod = '{{rateLimit}}'; var reschedule = {{reschedule}}; var initAlertRuleUID = '{{initAlertRule}}'; // ~~~~~~~~~~~Functions /** * Converts an Item's state to a value we can compare in this rule. * * @param {State|string} an Item's state * @return {String|float|Quantity} the state converted to a usable type */ var stateToValue = (state) => { console.debug('Processing state ' + state + ' of type ' + typeof state); if(typeof state === 'string') { console.debug('state is a string: ' + state); if(state.includes(' ')) { try { console.debug('state is a Quantity: ' + state); return Quantity(state) } catch(e) { // do nothing, leave it as a String console.debug('Not a Quantity but has a space, leaving as a string: ' + state); return state; } } else if(state == '') { console.debug('state is the empty string, no conversion possible'); return state; } else if(!isNaN(state)) { console.debug('state is a number: ' + state) return Number.parseFloat(state); } else if(state == 'UnDefType' || state == 'NULL' || state == 'UNDEF') { console.debug('state is an undef type, normalizing to UnDefType'); return 'UnDefType'; } console.debug('Leaving state as a string'); return state; } else if(state instanceof DecimalType || state instanceof PercentType) { console.debug('state is a DecimalType or PercentType: ' + state); return state.floatValue(); } else if(state instanceof QuantityType) { console.debug('state is a QuantityType, converting to Quantity: ' + state); return Quantity(state); } else { console.debug('Not numeric, leaving as a string: ' + state); return state.toString(); } } /** * Determines if the Item is in an alerting state based on the configured comparison.apply * * @param {string|float|Quantity} current state of the Item * @param {Object} record the Item's record of properties and timers * @param {function(a,b)} operator the comparison operator to use * @return {boolean} true if current is an alerting state */ var isAlertingState = (currState, record) => { let calc = currState + ' ' + record.operator + ' ' + record.threshold; if(record.invert) calc = '!(' + calc + ')'; console.debug('Checking if we are in the alerting state: ' + calc); let rval = record.compare(currState, record.threshold); rval = (record.invert) ? !rval : rval; console.debug('Result is ' + rval); return rval; } /** * Checks the proposed alerting time and adjusts it to occur at the end of the DND * if the proposed time falls in the DND time. * * @param {anything supported by time.toZDT()} timeout proposed time to send the alert * @param {String} dndStart time the DND starts * @param {String} dndEnd time the DND ends * @return {time.ZonedDateTime} adjusted time to send the alert or null if there is a problem */ var generateAlertTime = (timeout, dndStart, dndEnd) => { if(timeout === '' || (!(timeout instanceof time.ZonedDateTime) && !validateDuration(timeout))){ console.debug('Timeout ' + timeout + ' is not valid, using null'); return null; } let rval = time.toZDT(timeout.toString()); let start = time.toZDT(dndStart); let end = time.toZDT(dndEnd); if(rval.isBetweenTimes(dndStart, dndEnd)) { console.debug('Alert is scheduled during do not distrub time, moving to ' + dndEnd); rval = dndEnd; if(time.toZDT(rval).isBefore(time.toZDT())) { console.debug('Moving alert time to tomorrow'); rval = timeUtils.toTomorrow(dndEnd); } } return rval.toString(); } /** * Calls the rule with the alert info, using the gatekeeper to prevent overloading the * rule. The rule is called to inforce conditions. * * @param {string|float|Quantity} state Item state that is generating the alert * @param {string} ruleID rule to call * @param {boolean} isAlerting indicates if the Item is alerting or not * @param {boolean} isInitialAlert indicates if the Item is just detected as alerting but not yet alerted * @param {Object} record all the information related to the Item the rule is being called on behalf of */ var callRule = (state, ruleID, isAlerting, isInitialAlert, record) => { if(ruleID == '') { console.debug('No rule ID passed, ignoring'); return; } console.debug('Calling ' + ruleID + ' with alertItem=' + record.name + ', alertState=' + state + ', isAlerting=' + isAlerting + ', and initial alert ' + isInitialAlert); var rl = cache.private.get('rl', () => RateLimit()); const throttle = () => { const gk = cache.private.get('gatekeeper', () => Gatekeeper()); const records = cache.private.get('records'); gk.addCommand(record.gatekeeper, () => { try { // If the Item hasn't triggered this rule yet and therefore doesn't have a record, skip it const allAlerting = items[group].members.filter(item => records[item.name] !== undefined && isAlertingState(stateToValue(item.state), records[item.name])); const alertingNames = allAlerting.map(item => item.label); const nullItems = items[group].members.filter(item => item.isUninitialized); const nullItemNames = nullItems.map(item => item.label); rules.runRule(ruleID, {'alertItem': record.name, 'alertState': ''+state, 'isAlerting': isAlerting, 'isInitialAlert': isInitialAlert, 'threshItems': allAlerting, 'threshItemLabels': alertingNames, 'nullItems': nullItems, 'nullItemLabels': nullItemNames}, true); } catch(e) { console.error('Error running rule ' + ruleID + '\n' + e); } console.debug('Rule ' + ruleID + ' has been called for ' + record.name); }); } // Only rate limit the alert, always make the end alert call (isAlerting && record.rateLimit !== '') ? rl.run(throttle, record.rateLimit) : throttle(); } /** * Creates the function that gets called by the loopingTimer for a given Item. The generated * function returns how long to wait for the next call (adjusted for DND) or null when it's * time to exit. * * @param {Object} Object containing alertTimer, endAlertTimer, alerted, alertState * @return {function} function that takes no arguments called by the looping timer */ var sendAlertGenerator = (record) => { return () => { const item = items[record.name]; const currState = stateToValue(items[record.name].rawState); refreshRecord(record); // We can still get Multithreaded exceptions when calling another rule, this should reduce the occurance of that console.debug('Alert timer expired for ' + record.name + ' with dnd between ' + record.dndStart + ' and ' + record.dndEnd + ' and reminder period ' + record.remPeriod); let repeatTime = generateAlertTime(record.remPeriod, record.dndStart, record.dndEnd); // returns null if remPeriod is '', which cancels the loop // Call alert rule if still alerting if(isAlertingState(currState, record)) { console.debug(record.name + ' is still in an alerting state.'); callRule(currState, record.alertRule, true, false, record); if(!record.alerted) record.alertState = currState; record.alerted = true; if(repeatTime === null) record.alertTimer = null; // clean up if no longer looping console.debug('Waiting until ' + repeatTime + ' to send reminder for ' + record.name); return repeatTime; } // no longer alerting, cancel the repeat, send an alert if configured else { console.debug(record.name + ' is no longer in an alerting state but timer was not cancelled, this should not happen'); record.alertTimer = null; return null; } } } /** * Called when the Item event indicates that it's in an alerting state. If there isn't * already a looping timer running, create one to initially run at alertTime (adjusted * for DND) and repeat according to remPeriod. If dndStart is after dndEnd, the DND is * assumed to span midnight. * * @param {State|String} state state of the Item that generated the event * @param {Object} record contains alertTimer, endAlertTimer, and alerted flag */ var alerting = (state, record) => { console.debug(record.name + ' is in the alert state of ' + state); // Cancel the endAlertTimer if there is one if(record.endAlertTimer !== null) { console.debug('Cancelling endAlertTimer for ' + record.name); record.endAlertTimer.cancel(); record.endAlertTimer = null; } // Set a timer for how long the Item needs to be in the alerting state before alerting. // If one is already set, ignore it let timeout = generateAlertTime(record.alertDelay, record.dndStart, record.dndEnd); if(timeout === null) timeout = 'PT0S'; // run now // First time the Item entered the alert state if(record.alertTimer === null && !record.isAlerting) { // Schedule the initAlertTimer first and it will run first console.debug('Calling the initial alert rule for ' + record.name); record.initAlertTimer = LoopingTimer(); record.initAlertTimer.loop(() => { console.debug('Calling init alert rule for ' + record.name); callRule(state, record.initAlertRule, false, true, record); record.initAlertTimer = null; }, generateAlertTime(time.toZDT(), record.dndStart, record.dndEnd)); // Create the alert timer console.debug('Creating looping alert timer for ' + record.name + ' at ' + timeout); record.alertTimer = LoopingTimer(); record.alertTimer.loop(sendAlertGenerator(record), timeout); } // Reschedule the alert timer else if(record.alertTimer !== null && record.reschedule) { console.debug('Rescheduling the timer for ' + record.name + ' at ' + timeout); record.alertTimer.timer.reschedule(time.toZDT(timeout)); } // Do nothing else { console.debug(record.name + ' already has an alert timer or has already alerted, ignoring event.'); } } /** * Applies the hysteresis and returns true if the curr value is different enough from the * alert value or if the calculation cannot be done because the three arguments are not * compatible. * @param {string|float|Quantity} curr current state * @param {string|float|Quantity} alert the state that was alerted * @param {string|float|hyst} hyst the hysteresis range * @return {boolean} true if curr is different from alert by hyst or more, or if the arguments are incompatable. */ var applyHyst = (curr, alert, hyst) => { console.debug('Applying hysteresis with: ' + curr + ', ' + alert + ', ' + hyst); // Quantity if(curr.unit !== undefined && alert.unit && hyst.unit !== undefined) { try { const delta = (curr.lessThan(alert)) ? alert.subtract(curr) : curr.subtract(alert); console.debug('Applying hysteresis, delta = ' + delta + ' hystRange = ' + hyst); return delta.greaterThan(hyst); } catch(e) { console.error('Attempting to apply hysteresis with Quantities of incompatable units. Not applying hysteresis'); return true; } } // Number else if(typeof curr !== 'string' && typeof alert !== 'string' && typeof hyst !== 'string') { const delta = Math.abs(curr - alert); console.debug('Applying hysteresis, delta = ' + delta + ' hystRange = ' + hyst); return delta > hyst; } else { console.debug('Not all values are compatible, skipping hysteresis'); return true; } } /** * Called when the Item event indicates that it is not in an alerting state. * Clean up the record a * @param {string|float|Quantity} state state that generated the event * @param {Object} record contains alertTimer, endAlertTimer, and alerted flag */ var notAlerting = (state, record) => { console.debug(record.name + "'s new state is " + state + ' which is no longer in the alerting state, previously alerted = ' + record.alerted); // Skip if we don't pass hysteresis if(record.alerted && !applyHyst(state, record.threshold, record.hysteresis)) { console.debug(record.name + ' did not pass hysteresis, remaining in alerting state'); return; } // Cancel the initAlertTimer if(record.initAlertTimer !== null) { console.debug('Cancelling alert timer for ' + record.name); record.initAlertTimer.cancel(); record.initAlertTimer = null; } // Cancel the alertTimer if(record.alertTimer !== null) { console.debug('Cancelling alertTimer for ' + record.name); record.alertTimer.cancel(); record.alertTimer = null; } // Send alert if required if(record.endRule && record.alerted) { console.debug('Sending alert that ' + record.name + ' is no longer in the alerting state'); // Schedule a timer if in DND, otherwise run now record.endAlertTimer = LoopingTimer(); record.endAlertTimer.loop(() => { console.debug('Calling end alert rule for ' + record.name); callRule(stateToValue(items[record.name].rawState), record.endRule, false, false, record); record.alerted = false; record.alertState = null; record.endAlertTimer = null; }, generateAlertTime(time.toZDT(), record.dndStart, record.dndEnd)); } else if(!record.endRule && record.alerted) { console.debug('No end alert rule is configured, exiting alerting for ' + record.name); record.alerted = false; record.alertState = null; } else if(!record.alerted) { console.debug('Exiting alerting state but alert was never sent, not sending an end alert for ' + record.name); } else { console.warn('We should not have reached this!'); } } /** * Returns a function that executes the passed in comparison on two operands. If * both are numbers a numerical comparison is executed. Otherwise the operands are * converted to strings and a string comparison is done. * * @param {string} operator the comparison the that the returned function will execute * @return {function(a, b)} function tjat executes a number comparison if both operands are numbers, a string comparison otherwise */ var generateStandardComparison = (operator) => { return (a, b) => { let op = null; switch(operator) { case '==': op = (a, b) => a == b; break; case '!=': op = (a, b) => a != b; break; case '<' : op = (a, b) => a < b; break; case '<=': op = (a, b) => a <= b; break; case '>' : op = (a, b) => a > b; break; case '>=': op = (a, b) => a >=b; break; } if(isNaN(a) || isNaN(b)) { return op(''+a, ''+b); // convert to strings if both operands are not numbers } else { return op(a, b); } } } /** * Creates the custom comparison operator. If both operands are Quantities and they * have compatible units, the Quantity comparison will be returned. Otherwise a * standard comparison will be used. * * @param {string} operator the comparison operator * @return {function(a, b)} function that executes the right comparison operation based on the types of the operands */ var generateComparison = (operator) => { // Assume quantity, revert to standard if not or incompatible units return (a, b) => { let op = null; switch(operator) { case '==': op = (a, b) => a.equal(b); break; case '!=': op = (a, b) => !a.equal(b); break; case '<' : op = (a, b) => a.lessThan(b); break; case '<=': op = (a, b) => a.lessThanOrEqual(b); break; case '>' : op = (a, b) => a.greaterThan(b); break; case '>=': op = (a, b) => a.greaterThanOrEqual(b); break; } try { if(a.unit !== undefined && b.unit !== undefined) return op(a, b); else return generateStandardComparison(operator)(a, b); } catch(e) { // Both are Quantities but they have incompatible units return generateStandardComparison(operator)(a, b); } } } /** * Populate/refresh the record with any changes to parameters that may have been * made to the Item's metadata since the rule was first run. * * @param {Object} record contains all the settings and timners for an Item */ var refreshRecord = (record) => { // Metadata may have changed, update record with latest values console.debug('Populating record from Item metadata or rule defauls'); const md = (items[record.name].getMetadata()[namespace] !== undefined) ? items[record.name].getMetadata()[namespace].configuration : {}; console.debug('Converting threshold to value'); record.threshold = stateToValue(thresholdStr); if(md['threshold'] !== undefined) record.threshold = stateToValue(md['threshold']); if(md['thresholdItem'] !== undefined) record.threshold = stateToValue(items[md['thresholdItem']].rawState); record.operator = (md['operator'] !== undefined) ? md['operator'] : operator; record.invert = (md['invert'] !== undefined) ? md['invert'] === true : invert; record.reschedule = (md['reschedule'] !== undefined) ? md['reschedule'] === true : reschedule; record.compare = generateComparison(record.operator); record.alertDelay = (md['alertDelay'] !== undefined) ? md['alertDelay'] : defaultAlertDelay; record.remPeriod = (md['remPeriod'] !== undefined) ? md['remPeriod'] : defaultRemPeriod; record.alertRule = (md['alertRuleID'] !== undefined) ? md['alertRuleID'] : alertRuleUID; record.endRule = (md['endRuleID'] !== undefined) ? md['endRuleID'] : endAlertUID; record.initAlertRule = (md['initAlertRuleID'] !== undefined) ? md['initAlertRuleID'] : initAlertRuleUID record.gatekeeper = (md['gatekeeperDelay'] !== undefined) ? md['gatekeeperDelay'] : gkDelay; console.debug('Converting hysteresis to value'); record.hysteresis = stateToValue((md['hysteresis'] !== undefined) ? md['hysteresis'] : hystRange); record.rateLimt = (md['rateLimit'] !== undefined) ? md['rateLimit'] : rateLimitPeriod; record.dndStart = (md['dndStart'] !== undefined) ? md['dndStart'] : dndStart; record.dndEnd = (md['dndEnd'] !== undefined) ? md['dndEnd'] : dndEnd; console.debug('Processing event for Item ' + record.name + ' with properties: \n' + ' Threshold - ' + record.threshold + '\n' + ' Operator - ' + record.operator + '\n' + ' Invert - ' + record.invert + '\n' + ' Reschedule - ' + record.reschedule + '\n' + ' Alert Delay - ' + record.alertDelay + '\n' + ' Reminder Period - ' + record.remPeriod + '\n' + ' Alert Rule ID - ' + record.alertRule + '\n' + ' End Alert Rule ID - ' + record.endRule + '\n' + ' Init Alert Rule ID - ' + record.initAlertRule + '\n' + ' Gatekeeper Delay - ' + record.gatekeeper + '\n' + ' Hystersis - ' + record.hysteresis + '\n' + ' Rate Limt - ' + record.rateLimt + '\n' + ' DnD Start - ' + record.dndStart + '\n' + ' DnD End - ' + record.dndEnd); } /** * Process an Item event, checking to see if it is in an alerting state and calling * the alerting rule with repeats if configured. If it is no longer in an alerting * state and an alert was sent, call the end alerting rule if configured. * * @param {string} name the name of the Item * @param {string|float|Quantity} state the Item's current state */ var procEvent = (name, state) => { // const value = stateToValue(state); console.debug('Processing state ' + state + ' from ' + name); const records = cache.private.get('records', () => { return {}; }); // Initialze the record in timers if one doesn't already exist or it's incomplete // if(records[name] === undefined // || records[name].alertTimer === undefined // || records[name].endAlertTimer === undefined // || records[name].initAlertTimer == undefined // || records[name].alerted === undefined) { // console.debug('Initializing record for ' + name); // records[name] = {name: name, // alertTimer: (records[name] === undefined) ? null: records[name].alertTimer, // endAlertTimer: (records[name] === undefined) ? null: records[name].endAlertTimer, // initAlertTimer: (records[name] === undefined) ? null: records[name].initAlertTimer, // alerted: false}; // } // Initialize the record in timers if one doesn't already exist if(records[name] === undefined) { console.debug('Initializing record for ' + name); records[name] = { name: name, alertTimer: null, endAlertTimer: null, initAlertTimer: null, alerted: false }; } const record = records[name]; refreshRecord(record); // Alerting state, set up an alert timer if(isAlertingState(state, record)) { alerting(state, record); } // Not alerting, cancel the timer and alert if configured else { notAlerting(state, record); } } /** * @param {string} op candidate operator * @return {boolen} true if op is one of ==, !=, <, <=, >, >= */ var validateOperator = (op) => { return ['==', '!=', '<', '<=', '>', '>='].includes(op); } /** * @param {string} dur candidate ISO8601 duration * @return {boolean} true if dur can be parsed into a duration */ var validateDuration = (dur) => { try { if(dur !== '') time.Duration.parse(dur); return true } catch(e) { console.error('Failed to parse duration ' + dur + ': ' + e); return false; } } /** * @param {string} t candidate time * @return {boolean} true if t can be converted into a valid datetime */ var validateTime = (t) => { try { const militaryHrRegex = /^(0?[0-9]|1[0-9]|2[0-3])(:[0-5][0-9]){1,2}$/; const meridianHrRegex = /^(0?[0-9]|1[0-2])(:[0-5][0-9]){1,2} ?[a|p|A|P]\.?[m|M]\.?$/; if(militaryHrRegex.test(t) || meridianHrRegex.test(t)) { time.toZDT(t); return true; } else { return false; } } catch(e) { console.error('Failed to parse time ' + t + ': ' + e); return false; } } /** * @param {string} id candidate rule id * @return {boolean} true if the rule exists and is enabled */ var validateRule = (id) => { try { if(!rules.isEnabled(id)) { console.error('Rule ' + id + ' is disabled'); return false; } return true; } catch(e) { console.error('Rule ' + id + ' does not exist'); return false; } } /** * @param {string} h candidate hysteresis * @return {boolean} true if h is either a number or Quantity */ var validateHysteresis = (h) => { const parsed = stateToValue(h); return !isNaN(parsed) || parsed.unit !== undefined; } /** * Analyzes the rule properties and Items to verify that the config will work. */ var init = () => { console.debug( 'Rule config defaults:\n' + ' Group - ' + group + '\n' + ' Threhsold - ' + thresholdStr + '\n' + ' Operator - ' + operator + '\n' + ' Invert Operator - ' + invert + '\n' + ' Reschedule - ' + reschedule + '\n' + ' Default Alert Delay - ' + defaultAlertDelay + '\n' + ' Default Reminder Duration - ' + defaultRemPeriod + '\n' + ' DND Start - ' + dndStart + '\n' + ' DND End - ' + dndEnd + '\n' + ' Alert Rule - ' + alertRuleUID + '\n' + ' End Alert Rule - ' + endAlertUID + '\n' + ' Alert Group - ' + group + '\n' + ' Alert Items - ' + items[group].members.map(i => i.name).join(', ') + '\n' + ' Gatekeeper Delay - ' + gkDelay + '\n' + ' Rate Limit - ' + rateLimitPeriod); var error = false; var warning = false; const allItems = items[group].members; console.info('Cancelling any running timers'); const records = cache.private.get('records'); if(records !== null) { Object.values(records).forEach(record => { if(record.alertTimer !== null && record.alertTimer !== undefined){ console.info(record.name + ' has an alert timer scheduled, cancelling now.'); record.alertTimer.cancel(); record.alertTimer = null; } if(record.endAlertTimer !== null && record.endAlertTimer !== undefined) { console.info(record.name + ' has an end alert timer scheduled, cancelling now.'); record.endAlertTimer.cancel(); record.endAlertTimer = null; } }); } // Inform if the threshold is UnDefType if(thresholdStr === 'UnDefType') { console.info('Threshold is UnDefType, this will cause Item states of NULL and UNDEF to be converted to UnDefType for comparisons.'); } // Error if the Group has QuantityTypes but thresholdStr is not const quantityTypes = allItems.filter(item => item.quantityState !== null); if(quantityTypes.length > 0 && thresholdStr !== 'UnDefType') { try { if(!thresholdStr.includes(' ')) { warn = true; console.warn(group + ' contains Quantity states by thresholdStr ' + thresholdStr + ' does not have units, comparison will revert to number or string comparison.'); } else { Quantity(thresholdStr); } } catch(e) { warning = true; console.warn(group + ' contains Quantity states but thresholdStr ' + thresholdStr + ' cannot be converted to a Quantity, comparison will default to a String comparsion'); } } // Error if the Group has more than one type of Item const itemTypes = allItems.filter(item => item.rawItem.class.name === allItems[0].rawItem.class.name); if(itemTypes.length != allItems.length) { warn = true; console.warn(group + ' has a mix of Item types'); } // Warn if thresholdStr is empty string, there are cases where that could be OK but most of // the time it isn't. if(thresholdStr == '') { warning = true; console.warn('Alert State is an empty String, is that intended?'); } // Verify the operator is valid if(!validateOperator(operator)) { error = true; console.error('Invalid operator ' + operator); } // Inform that there is no default alert delay configured, there will be no pause before alerting if(defaultAlertDelay == '') { console.info('Items without ' + namespace + ' alertDelay metadata will alert immediately'); } else if(!validateDuration(defaultAlertDelay)){ error = true; console.error('The default alert delay ' + defaultAlertDelay + ' is not a parsable ISO8601 duration string'); } // Inform that there is no default reminder period configured, there will be no repreated alerts if(defaultRemPeriod == '') { console.info('Items without ' + namespace + ' remPeriod metadata will not repeat alerts'); } else if(!validateDuration(defaultRemPeriod)){ error = true; console.error('The default reminder period ' + defaultRemPeriod + ' is not a parsable ISO8601 duration string'); } // Inform if both of the DND times are not set // Error if one but not the other is defined // Error if either cannot be parsed into a ZDT if(dndStart == '' && dndEnd == '') { console.info('DND Start and End are empty, no DND period will be applied'); } else if(dndStart == '' && dndEnd != '') { error = true; console.error('DND Start is defined but DND End is not.'); } else if(dndStart != '' && dndEnd == '') { error = true; console.error('DND Start is not defined but DND End is.') } else if(dndStart != '' && !validateTime(dndStart)) { error = true; console.error('DND Start ' + dndStart + ' is not parsable into a time'); } else if(dndEnd != '' && !validateTime(dndEnd)) { error = true; console.error('DND End ' + dndEnd + ' is not parsable into a time'); } // Error is no alert rule is configured or the rule is disabled or does not exist if(alertRuleUID == '') { error = true; console.error('No alert rule is configured!'); } else if(!validateRule(alertRuleUID)) { error = true; console.error('Alert rule ' + alertRuleUID + ' is disabled or does not exist'); } // Inform if the end alert rule is not configured // Error if it is configured but it is disabled or does not exist if(endAlertUID == '') { console.info('No end alert rule configured, no rule will be called when alerting ends'); } else if(!validateRule(endAlertUID)) { error = true; console.error('End alert rule ' + endAlertUID + ' is diabled or does not exist'); } // Inform if the initial alert rule is not configured // Error if it is configured but it is disabled or does not exist if(initAlertRuleUID == '') { console.info('No initial alert rule configured, no rule will be called when the alert state is first detected'); } else if(!validateRule(initAlertRuleUID)) { error = true; console.error('Initial alert rule ' + initAlertRuleUID + ' is disable or does not exist'); } // Validate hysteresis if(hystRange !== '' && !validateHysteresis(hystRange)) { error = true; console.error('Hysteresis ' + hystRange + ' is not a number nor Quantity'); } // Warn if there are QuantityTypes but hystRange does not have units if(hystRange !== '' && quantityTypes.length > 0) { if(!hystRange.includes(' ')) { warning = true; console.warn(group + ' has QuantityTypes but hystRange ' + hystRange + ' does not have units.'); } else { try { Quantity(hystRange); } catch(e) { warning = true; console.warn(group + ' has QuantityTypes by hystRange ' + hystRange + ' cannot be parsed into a Quantity.'); } } } // Validate rateLimit Period and warn if less than gkDelay if(rateLimitPeriod !== '' && !validateDuration(rateLimitPeriod)) { error = true; console.error('A rate limit was provided but ' + rateLimitPeriod + ' is not a parsable ISO8601 duration string.'); } if(rateLimitPeriod !== '' && gkDelay) { if(time.Duration.parse(rateLimitPeriod).lessThan(time.Duration.ofMillis(gkDelay))) { warning = true; console.warn('Both rate limit and gatekeeper delay are defined but gatekeeper delay is greater than the rate limit. The rate limit should be significantly larger.'); } } // Inform if none of the Items have namespace metadata const noMetadata = allItems.filter(item => item.getMetadata()[namespace] === undefined); if(noMetadata.length > 0) { console.info('These Items do not have ' + namespace + ' metadata and will use the default properties defined in the rule: ' + noMetadata.map(i => i.name).join(', ')); } // Validate Item metadata allItems.filter(item => item.getMetadata()[namespace]).forEach(item => { const md = item.getMetadata()[namespace].configuration; console.debug('Metadata for Item ' + item.name + '\n' + Object.keys(md).map(prop => prop + ': ' + md[prop]).join('\n')); // Anything is OK for threshold if(md['thresholdItem'] !== undefined) { const threshItem = md['thresholdItem']; if(md['threshold'] !== undefined) { error = true; console.error('Item ' + item.name + ' has both thershold and thresholdItem defined in ' + namespace + ' metadata'); } if(items[threshItem] === undefined || items[threshItem] === null) { error = true; console.error('Item ' + item.name + ' defines thresholdItem ' + threshItem + ' which does not exist'); } else if(item.quantityState !== null && items[threshItem].quantityState === null){ error = true; console.error('Item ' + item.name + ' is a Quantity but thresholdItem ' + threshItem + ' is not'); } else if(item.quantityState === null && items[threshItem].quantityState !== null) { error = true; console.error('Item ' + item.name + ' is not a Quantity but thresholdItem ' + threshItem + ' is'); } else if(item.numericState !== null && items[threshItem].numericState === null) { error = true; console.error('Item ' + item.name + ' is a number but thresholdItem ' + threshItem + ' is not'); } else if(item.numericState === null && items[threshItem].numericState !== null) { error = true; console.error('Item ' + item.name + ' is not a number but thresholdItem ' + threshItem + ' is'); } } if(md['operator'] !== undefined && !validateOperator(md['operator'])) { error = true; console.error('Item ' + item.name + ' has an invalid operator ' + md['operator'] + ' in ' + namespace + ' metadata'); } if(md['invert'] !== undefined && (md['invert'] !== true && md['invert'] !== false)) { error = true; console.error('Item ' + item.name + ' has an unparsible invert ' + md['invert'] + ' in ' + namespace + ' metadata'); } if(md['reschedule'] !== undefined && (md['reschedule'] !== true && md['reschedule'] !== false)) { error = true; console.error('Item ' + item.name + ' has an unparsible reschedule ' + md['reschedule'] + ' in ' + namespace + ' metadata'); } if(md['alertDelay'] !== undefined && !validateDuration(md['alertDelay'])) { error = true; console.error('Item ' + item.name + ' has an unparsable alertDelay ' + md['alertDelay'] + ' in ' + namespace + ' metadata'); } if(md['remPeriod'] !== undefined && !validateDuration(md['remPeriod'])) { error = true; console.error('Item ' + item.name + ' has an unparsable remPeriod ' + md['remPeriod'] + ' in ' + namespace + ' metadata'); } if(md['alertRuleID'] !== undefined && !validateRule(md['alertRuleID'])) { error = true; console.error('Item ' + item.name + ' has an invalid alertRuleID ' + md['alertRuleID'] + ' in ' + namespace + ' metadata'); } if(md['endRuleID'] !== undefined && !validateRule(md['endRuleID'])) { error = true; console.error('Item ' + item.name + ' has an invalid endRuleID ' + md['endRuleID'] + ' in ' + namespace + ' metadata'); } if(md['initAlertRuleID'] !== undefined && !validateRule(md['initAlertRuleID'])) { error = true; console.error('Item ' + item.name + ' has an invalid initAlertRuleID ' + md['initAlertRuleID'] + ' in ' + namespace + ' metadata'); } if(md['gatekeeperDelay'] !== undefined && isNaN(md.gatekeeperDelay)) { error = true; console.error('Item ' + item.name + ' has a non-numerical gatekeeperDelay ' + md['gatekeeperDelay'] + ' in ' + namespace + ' metadata'); } if(md['gatekeeperDelay'] !== undefined && md['gatekeeperDelay'] < 0) { warning = true; console.warn('Item ' + item.name + ' has a negative gatekeeperDelay ' + md['gatekeeperDelay'] + ' in ' + namespace + ' metadata'); } if(md['hysteresis'] !== undefined && !validateHysteresis(md['hysteresis'])) { error = true; console.error('Item ' + item.name + ' has a non-numerical nor Quantity hystersis ' + md['hysteresis'] + ' in' + namespace + ' metadata'); } else if(md['hysteresis'] && item.quantityState !== null && stateToValue(md['hysteresis']).unit === undefined) { warn = true; console.warn('Item ' + item.name + ' has a Quantity but hystereis ' + md['hysteresis'] + ' is not a Quantity'); } else if(md['hysteresis'] && item.quantityState !== null && stateToValue(md['hysteresis']).unit !== undefined) { try { item.quantityState.equals(stateToValue(md['hysteresis'])); } catch(e) { error = true; console.error('Item ' + item.name + ' has a hysteresis ' + md['hysteresis'] + ' with units incompatible with ' + item.quantityState.unit); } } if(md['rateLimit'] !== undefined && !validateDuration(md['rateLimit'])) { error = true; console.error('Item ' + item.name + ' has an unparsable rateLimit ' + md['rateLimit'] + ' in ' + namespace + ' metadata'); } if(md['dndStart'] !== undefined && !validateTime(md['dndStart'])) { error = true; console.error('Item ' + item.name + ' has an unparsable dndStart time ' + md['dndStart'] + ' in ' + namespace + ' metadata'); } if(md['dndEnd'] !== undefined && !validateTime(md['dndEnd'])) { error = true; console.error('Item ' + item.name + ' has an unparsable dndEnd time ' + md['dndEnd'] + ' in ' + namespace + ' metadata'); } if((md['dndStart'] === undefined && md['dndEnd'] !== undefined) || (md['dndStart'] !== undefined && md['dndEnd'] === undefined)) { error = true; console.error('Item ' + item.name + ' one but not both of dndStart and dndEnd defined on ' + namespace + ' metadata'); } // Warn if metadata is present but none of the known configuration parameters are if(!['threshold', 'thresholdItem', 'operator', 'invert', 'alertDelay', 'remPeriod', 'alertRuleID', 'endRuleID', 'gatekeeperDelay', 'hysteresis', 'rateLimit', 'dndStart', 'dndEnd'] .some(name => md[name] !== undefined)) { warning = true; console.warn('Item ' + item.name + ' has ' + namespace + ' metadata but does not have any known configuration property used by this rule'); } }); if(error) console.error('Errors were found in the configuration, see above for details.') else if(warning) console.warn('Warnings were found in the configuration, see above for details. Warnings do not necessarily mean there is a problem.') else console.info('Threshold Alert configs check out as OK') } //~~~~~~~~~~~~~ Body // If triggered by anything other than an Item event, check the config // Otherwise process the event to see if alerting is required if(this.event === undefined) { console.debug('Rule triggered without an event, checking config.'); init(); } else { switch(event.type) { case 'ItemStateEvent': case 'ItemStateUpdatedEvent': case 'ItemStateChangedEvent': console.loggerName = loggerBase+'.'+event.itemName; console.debug('Processing an Item event'); procEvent(event.itemName, stateToValue(event.itemState)); break; default: console.info('Rule triggered without an Item event, ' + event.type + ' checking the rule config'); //cache.private.clear(); init(); } } type: script.ScriptAction