uid: rules_tools:open_reminder label: Open Reminder description: This will trigger on a change to an Item in a Group. If the change is different from a "good state" (or the same as the "good state" if the match is inverted) and it remains that state for a specified amount of time it will call another script and reschedule to repeat until the Item returns or a do not distrurb period is entered. configDescriptions: - name: groupTriggers type: TEXT context: item filterCriteria: - name: type value: Group label: Group of Triggering Items description: Group whose members should trigger this rule. required: true - name: alertState type: TEXT label: Alert State description: Any state different from this will cause the rule to run. required: true - name: invert type: BOOLEAN label: Invert description: Inverts the match so an state that is the same as Alert State will cause the rule to run. defaultValue: false required: false - name: defaultTimeout type: TEXT label: Default Initial Timeout description: If a triggering Item doesn't have a timeout metadata, the default initial time to wait before calling the script the first time. Use "PTVdWhXmYsZz" format where d=days, h=hours, m=minutes, s=seconds, and z=milliseconds (e.g. "PT2d5s" = two days, five seconds). defaultValue: PT15m required: true - name: timeoutMetadata type: TEXT label: Default Item Initial Timeout Metadata description: Item metadata namespace to check for a custom initial timeout for the triggering Item. Use "PTVdWhXmYsZz" format where d=days, h=hours, m=minutes, s=seconds, and z=milliseconds (e.g. "PT2d5s" = two days, five seconds). defaultValue: rem_time required: false - name: repeatPeriod type: TEXT label: Repeat Period description: How often to repeat calling the alert rule after the initial call while the Item remains in a "not good" state. Use "PTVdWhXmYsZz" format where d=days, h=hours, m=minutes, s=seconds, and z=milliseconds (e.g. "PT2d5s" = two days, five seconds). If left blank no repeat is performed. defaultValue: '' required: false - name: reschedule type: BOOLEAN label: Reschedule description: Reschedule the initial looping timer when the Item changes to the alerting state again or ignore the state change. Put another way, set the timer to run after the first alert event, or after the last alert event. Usually you'll make this true for motion sensor rules. defaultValue: false required: false - name: alertRuleUID type: TEXT context: rule label: Alert Rule description: Rule called after the initial timeout and every repeat period thereafter as long as the Item remains in a "not good" state. - name: dndStart label: Do Not Distrub Start Time description: The start of the time where alerts are not allowed. Alerts are only sent outside of the time between this time and the alerting end time. If both are the same, alerts will be sent any time. type: TEXT required: false context: time defaultValue: 00:00 - name: dndEnd label: Do Not Disturb End Time description: The end of the time where alerts are not allowed. Alerts are only sent outside of the alerting start time and alerting end time. If both are the same, alerts will be send any time. type: TEXT required: false context: time defaultValue: 00:00 triggers: - id: "1" configuration: groupName: "{{groupTriggers}}" type: core.GroupStateChangeTrigger conditions: [] actions: - inputs: {} id: "2" configuration: type: application/javascript;version=ECMAScript-2021 script: > var {loopingTimer, timeUtils} = require('openhab_rules_tools'); var logger = log('rules_tools.'+ruleUID); // Rule Properties var alertState = "{{alertState}}"; var defaultTimeout = "{{defaultTimeout}}"; var timeoutMetadata = "{{timeoutMetadata}}"; var alertRuleUID = "{{alertRuleUID}}"; var repeatPeriod = "{{repeatPeriod}}"; var reschedule = {{reschedule}}; var dndStart = "{{dndStart}}"; var dndEnd = "{{dndEnd}}"; var noAlertPeriod = (dndStart == dndEnd); var isAlertState = (curr) => { return ({{invert}}) ? (curr == alertState) : (curr != alertState); }; var TIMERS_KEY = ruleUID+'_timers'; var TIMER_BUFFER = 600*1000000; // Nano seconds // If you edit this rule, run it manually to clear out the cache or else // errors will occur complaining about the context being closed. if(this.event === undefined) { logger.info('Resetting looping timers'); cache.put(TIMERS_KEY, null); } else { const timers = cache.get(TIMERS_KEY, () => new Map()); const item = event.itemName; const state = event.itemState.toString(); // Temporary work around until PR to fix openhab-js gets merged const isBetweenTimes = function (t, start, end) { const startTime = time.toZDT(start).toLocalTime(); const endTime = time.toZDT(end).toLocalTime(); // const currTime = this.toLocalTime(); const currTime = time.toZDT(t).toLocalTime(); // time range spans midnight if (endTime.isBefore(startTime)) { return currTime.isAfter(startTime) || currTime.isBefore(endTime); } else { return currTime.isAfter(startTime) && currTime.isBefore(endTime); } }; // Looks at the currently scheduled timers and makes the time for a new one // doesn't overlap. // Loop through the timers and check the timeout time against the existing timers. // If it's within half a second of one, move the timer forward 500 msec. // If the timeout was moved at least once, loop through the timers again to make // sure we didn't move it on top of an already checked timer. const avoidOverlapping = (timeout) => { let rval = time.toZDT(timeout); let recheck = true; while(recheck) { recheck = false; logger.debug('There are ' + timers.size + ' timers already scheduled'); timers.forEach((value, key) => { if(!value.hasTerminated() && rval.isClose(time.toZDT(value.timer.getExecutionTime()), time.Duration.ofMillis(250))) { logger.debug('Found timer ' + value.name + ' that has not terminated and is too close to timeout'); rval = rval.plusNanos(TIMER_BUFFER); recheck = true; } }); } return rval; }; // Moves the alert time to the end of the DND period if the timeout is // in the DND period. Then it makes sure it's at least TIMER_BUFFER nanosecs // from any existing timer const generateAlertTime = (timeout, dnds, dnde) => { let rval = time.toZDT(timeout); if(isBetweenTimes(rval, dnds, dnde)) { // temporary work around until PR gets merged to openhab-js logger.debug('Timer is scheduled during DND, moving to end of DND'); rval = dndEnd; if(time.toZDT(dndEnd).isBefore(time.toZDT())) { rval = timeUtils.toTomorrow(dndEnd); } } return avoidOverlapping(rval); }; // This returns the function called by the LoopingTimer. LoopingTimer will // reschedule the timer based on what the function passed to it returns. If // a duration is returned it will be reschuled for that time into the future. // If null is returned the looping stops. const sendAlertGenerator = (alertItem, repeat, dnds, dnde) => { return () => { const currState = items.getItem(alertItem).state; logger.debug('Timer expired for ' + alertItem + ' with dnd between ' + dnds + ' and ' + dnde); // Calculate the next repeat time let repeatTime = (repeat) ? generateAlertTime(repeat, dnds, dnde) : null; // Send alert if(isAlertState(currState)) { logger.debug('Item is still in an alert state.') rules.runRule(alertRuleUID, { 'alertItem': alertItem, 'currState': currState }, true); } // Cancel the repeats else { logger.debug('Item has returned to the non-alert state'); repeatTime = null; } // If there is no repeat delete the timer from the map if(!repeatTime) { logger.debug('No repeat time'); timers.delete(alertItem); } logger.debug('Rescheduling for ' + repeatTime); return repeatTime; }; }; logger.debug('Running door alert rule with: \n' + ' item: ' + item + '\n' + ' state: ' + state + '\n' + ' alertState: ' + alertState + '\n' + ' invert: ' + true + '\n' + ' defaultTimeout: ' + defaultTimeout + '\n' + ' repeatPeriod: ' + repeatPeriod + '\n' + ' dndStart: ' + dndStart + '\n' + ' dndEnd: ' + dndEnd + '\n' + ' noAlertPeriod: ' + noAlertPeriod + '\n' + ' alertRuleUID: ' + alertRuleUID); // Returned to alertState and timer exists, cancel the looping timer if(!isAlertState(state) && timers.has(item)) { logger.debug(item + ' has returned to a non-alerting state of ' + state + ', canceling the timer if it exists.'); timers.get(item).cancel(); timers.delete(item); } // Item changed to a not alertState, create a looping timer if one doesn't already exist else if(isAlertState(state)) { logger.debug(item + ' is in the alert state of ' + state); // There shouldn't be a Timer if the Item just changed to the alertState, log to show // something went wrong. if(timers.has(item)) { logger.warn(item + ' state is now ' + state + ' but an alert timer already exists! This should not have happened!'); } // Schedule a looping timer to start at the initial timeout (from metadata) and repeat // on repeatPeriod else { const metadata = items.getItem(item).getMetadataValue(timeoutMetadata); let timeout = generateAlertTime((metadata) ? metadata : defaultTimeout, dndStart, dndEnd); timers.set(item, new loopingTimer.LoopingTimer()); // add name as argument when openhab_rules_tools is updated to support that logger.debug('Starting timer for ' + item + ' with ' + timeout); timers.get(item).loop(sendAlertGenerator(item, repeatPeriod, dndStart, dndEnd), timeout); } } } type: script.ScriptAction