I’m not sure I ever had opportunity to test it with negative values so there very well could be a bug there.
I;m not exactly sure what I’m seeing in the log.
The config seems to check out. It definitely registered the -18 override in metadata for that Item for the threshold. But it seems off that it’s still processing the 11. It shouldn’t see 11 at all for this Item.
Then it checks if we’ve exceeded the threshold and it says “true” so it’s weird that it would then decide it’s not an alerting state.
I’m going to have to do some analysis that I don’t have time for right now. It might be a couple days before I get back.
Edit1:
I looked into this a bit and there is a bug in the log statement that tells me the result of the comparison with the invert applied. It is logging exactly the opposite from what it should. So indeed it looks like the comparison is failing somehow. But I’ve not yet ruled out a failure in converting the quantityTypes to. a proper value. Still looking. In the mean time, in the script Action you can change the second half of the isAlerting function to the following:
let rval = record.compare(currState, record.threshold);
rval = (record.invert) ? !rval : rval;
console.debug('Result is ' + rval);
return rval;
That fixes the log statement.
This part is suspicuious. Where is this blank coming from? Still looking…
This is coming from hysteresis. This might be the problem… It’s showing as NaN in your logs.
Edit 2
I’ve made some changes and fixed a couple of unrelated bugs discovered while looking at the code and reported on github.
Please take to code below and replace everything from the line that says // ~~~~~~~~~~~Functions
to the end of the file with it. The rule configuration is captured in the variable above this line.
Changes:
stateToValue
: isNaN
returns false
for empty String, handle that case separately
isAlertingState
: the ternary operator in the log statement wasn’t working, reworked so the log statement is complete and accurate
refreshRecord
: added some logging statements so we can see which values are being converted by stateToValue
procEvent
: state
has already been converted to a value, don’t convert it again
procEvent
: only initialize the record with values if the record doesn’t exist, don’t override alerted
procEvent
: let the case where there is no end alerting rule configured be handled by the call to notAlerting
// ~~~~~~~~~~~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);
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;
}
/**
* 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', () => new rateLimit.RateLimit());
const throttle = () => {
const gk = cache.private.get('gatekeeper', () => new 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 = new loopingTimer.LoopingTimer();
record.initAlertTimer.loop(() => {
console.debug('Calling init alert rule for ' + record.name);
callRule(state, record.alertRule, 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 = new loopingTimer.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.info('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.info('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.info('Applying hysteresis, delta = ' + delta + ' hystRange = ' + hyst);
return delta > hyst;
}
else {
console.info('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.info(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 = new loopingTimer.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.keys(records).forEach(record => {
if(record.alertTimer !== null && record.alertTimer !== undefined){
console.info(record.name + ' has an alert timer scheduled, cancelling now.');
record.alertTimer = null;
record.alertTimer.cancel();
}
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) {
error = true;
console.error(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 repeate 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 'ItemStateChangedEvent':
console.loggerName = loggerBase+'.'+event.itemName;
console.debug('Processing an Item event');
procEvent(event.itemName, stateToValue(event.itemState));
break;
default:
console.debug('Rule triggered without an Item event, checking the rule config');
cache.private.clear();
init();
}
}