I’ve managed to port a few of my rules over to OH 3 using MainUI and JavaScript and this article will have a couple of examples and some lessons learned. I’ve only experimented thus far using JavaScript. Some of this may apply to Rules DSL, Jython, and Groovy too when rules are created through MainUI of the REST API. Some of this may also apply to rules created through text files. But all of those are out of scope for this article, which only focuses on using ECMAScript in rules created through MainUI.
Lifecycle of a rule
The first time a MainUI created rule that rule is given a context. Each subsequent time the rule is executed, it’s given the same context.
In a script action or a script condition the context can be obtained using the keyword this
. So if you want to add a variable to the context it’s as simple as assigning a value to this.variablename
.
this.SOME_CONSTANT = 12345;
If you want to add a function you do it the same way.
this.myFunc = function() {
// do something
}
If you change the rule the context will become reset back to the default (e.g. all your timers will go away).
But, those lines of code run every time the rule triggers, so how do I save a value across multiple runs of a rule?
Saving a variable
In JavaScript, if you attempt to reference a variable that has not been defined the value will be undefined
. Therefore, before assigning the variable, check to see if it’s already been defined and only if it is undefined
should it be assigned.
this.myTimer = (this.myTimer === undefined) ? null : this.myTimer;
Given the above code, myTimer will be initialized to null if it’s not already defined. If it is defined, it retains what ever value it was set to prior to the rule running. So you can set a timer in one run of the rule and check to see if it’s still active in the next run.
Important note: the context is not shared across rules nor is it shared across script actions and script conditions for the same rule. Each gets their own so you can’t share variables between them in this way.
Loading a library
To load a library into a rule’s context one uses the load
function. You need to pass this function the full path to the .js file you want to load. When it loads the library file it’s just like that code was written as part of your rule in the first place.
So how do we get the full path to the library? There is a property openhab.conf
that gets set with the location of your openHAB’s conf folder (e.g. /etc/openhab
). By convention, your personally written JavaScript libraries go in openhab.conf + "/automation/lib/javascript/personal"
. So if you have a library file named utils.js
, you would load that into your rule using:
this.OPENHAB_CONF = (this.OPENHAB_CONF === undefined) ? java.lang.System.getProperty("openhab.conf") : this.OPENHAB_CONF;
load(OPENHAB_CONF + "/automation/lib/javascript/personal/utils.js")
Again, by testing to see if it’s already defined we can avoid reloading it every time the rule runs, saving a little bit of extra work. However, that means that if it changes it won’t be reloaded.
Libraries are different
Everything that gets defined in the script action or script condition that loads the library gets saved to the context whether or not you define it using this.
. When a library is loaded, its as if all that code were just typed into the script action or script condition so everything it defines also gets added to the context. This can lead to problems when the library defines variables with the same name as a variable defined in the script action or script condition, the library will overwrite that variable. The library might also define a bunch of stuff that only it cares about. How can we avoid this?
When defining a library, put it in a function and pass that function the context.
(function(context) {
// your library stuff goes here
})(this)
When wrapped in this way, only stuff that you explicitly add to context
will exist for the script action or script condition that loaded it. This lets you limit how much pollution and potential conflicts that will occur between multiple libraries, particularly libraries written by more than one author.
When openHAB executes a rule it injects a whole bunch of stuff into the context. Important stuff like events
(used to postUpdate and sendCommand), ir
(the image registry to get access to an Item by name), actions
(used to access binding actions), etc. But this stuff doesn’t automatically exist in the loaded library now because we’ve isolated it from the context. Therefore, in your library functions, you need to access that stuff using context.
, for example, context.ir.getItem("MyGroup")
.
A library few examples
NOTE: there is some overlap with the Helper Libraries in the below code. This is because:
- as of this writing the helper libraries for JavaScript are broken on OH 3
- I want all the libraries I publish to openhab-rules-tools to be stand alone with no external dependencies (dependencies among themselves is OK for now)
timeUtils.js
I will be maintaining this library at https://github.com/rkoshak/openhab-rules-tools/tree/main/time_utils where you can download and copy this to $OPENHAB_CONF/automation/lib/javascript/community
and load and use in your scripts. As of this writing the library looks like:
(function(context) {
'use strict';
var log = Java.type("org.slf4j.LoggerFactory").getLogger("org.openhab.model.script.Rules.time_utils");
// Import the Java Classes
var ZonedDateTime = (context.ZonedDateTime === undefined) ? Java.type("java.time.ZonedDateTime") : context.ZonedDateTime;
var LocalDateTime = (context.LocalDateTime === undefined) ? Java.type("java.time.LocalDateTime") : context.LocalDateTime;
var ZoneId = (context.ZoneId === undefined) ? Java.type("java.time.ZoneId") : context.ZoneId;
var ChronoUnit = (context.ChronoUnit === undefined) ? Java.type("java.time.temporal.ChronoUnit") : context.ChronoUnit;
var Duration = (context.Duration === undefined) ? Java.type("java.time.Duration") : context.Duration;
var DateTimeType = (context.DateTimeType === undefined) ? Java.type("org.openhab.core.types.DateTimeType") : context.DateTimeType;
var DecimalType = (context.DecimalType === undefined) ? Java.type("org.openhab.core.types.DecimalType") : context.DecimalType;
var PercentType = (context.PercentType === undefined) ? Java.type("org.openhab.core.types.PercentType") : context.PercentType;
var QuantityType = (context.QuantityType === undefined) ? Java.type("org.openhab.core.types.QuantityType") : context.QuantityType;
/**
* Parses a duration string returning a Duration object. Supports:
* - d days
* - h hours
* - m minutes
* - s seconds
* - z milliseconds
* The unit is followed by an integer (decimals are not supported).
* Examples:
* - 5d 2h 7s
* - 5m
* - 1h23m
*
* @param {string} timeStr
* @return {java.time.Duration} the string parsed to a Duration
*/
context.parseDuration = function(timeStr) {
var regex = new RegExp(/[\d]+[d|h|m|s|z]/gi);
var numMatches = 0;
var part = null;
var params = { "d": 0, "h": 0, "m":0, "s":0, "z":0 };
while(null != (part=regex.exec(timeStr))) {
log.debug("Match = " + part[0]);
numMatches++;
var scale = part[0].slice(-1).toLowerCase();
var value = Number(part[0].slice(0, part[0].length-1));
params[scale] = value;
}
if(numMatches === 0){
log.warn("Could not parse any time information from '" + timeStr +"'. Examples of valid string: '8h', '2d8h5s200z', '3d 7m'.");
return null;
}
else {
log.debug("Days = " + params["d"] + " hours = " + params["h"] + " minutes = " + params["m"] + " seconds = " + params["s"] + " msec = " + params["z"]);
return Duration.ofDays(params["d"]).plusHours(params["h"]).plusMinutes(params["m"]).plusSeconds(params["s"]).plusMillis(params["z"]);
}
}
/**
* Adds the passed in Duration to now and returns the resultant ZonedDatetime.
* @param {string | java.time.Duration} dur the duration to add to now, if a string see parseDuration above
* @return {java.time.ZonedDateTime} instant that is dur away from now
*/
context.durationToDateTime = function(dur) {
if(dur instanceof Duration) {
return ZonedDateTime.now().plus(dur);
}
else if(typeof dur === 'string' || dur instanceof String){
return durationToDateTime(parseDuration(dur));
}
}
/**
* @return {Boolean} Returns true if the passed in string conforms to ISO 8601.
*/
context.isISO8601 = function(dtStr) {
var regex = new RegExp(/^(-?(?:[1-9][0-9]*)?[0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9])T(2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9])(\.[0-9]+)?(Z|[+-](?:2[0-3]|[01][0-9]):[0-5][0-9])?$/);
return regex.test(dtStr);
}
/**
* Converts a number of supported types to a ZonedDateTime including:
* - ISO80601 formatted String
* - Duration String (see parseDuration above) which is added to now
* - Number types which are treated as milliseconds to add to now
* - openHAB Number Types which are treated as milliseconds to add to now
* - openHAB DateTimeType
* @param {string|int|long|java.time.Duration|java.lang.Number|org.openhab.core.types.DateTimeType|org.openhab.core.types.DecimalType|org.openhab.core.types.QuantityType|java.time.Duration|java.time.ZonedDateTime} when the representation of time converted to ZonedDateTime
* @return {java.time.ZonedDateTime} when converted to a ZonedDateTime
*/
context.toDateTime = function(when) {
var dt = null;
if(when instanceof ZonedDateTime) {
log.debug("Already ZonedDateTime " + when.toString());
dt = when;
}
else if(typeof when === 'string' || when instanceof String){
if(isISO8601(when)){
log.debug("Converting ISO80601 local date time " + when);
dt = ZonedDateTime.of(LocalDateTime.parse(when), ZoneId.systemDefault());
}
else {
log.debug("Converting duration " + when);
dt = durationToDateTime(when);
}
}
else if(typeof when === 'number' || typeof when === "bigint") {
log.debug("Converting number " + when);
dt = ZonedDateTime.now().plus(when, ChronoUnit.MILLIS);
}
else if(when instanceof DateTimeType){
log.debug("Converting DateTimeType " + when.toString());
dt = when.getZonedDateTime();
}
else if(when instanceof DecimalType || when instanceof PercentType || when instanceof QuantityType || when instanceof Number){
log.debug("Converting openHAB number type " + when.toString());
dt = ZonedDateTime.now().plus(when.longValue(), ChronoUnit.MILLIS);
}
else {
log.warn("In toDateTime, cannot convert when, unknown or unsupported type: " + when);
}
return dt;
}
/**
* Moves the passed in ZonedDateTime to today.
* @return {java.time.ZonedDateTime} when converted to a ZonedDateTime and moved to today's date
*/
context.toToday = function(when) {
var now = ZonedDateTime.now();
var dt = toDateTime(when);
return dt.withYear(now.getYear()).withMonth(now.getMonthValue()).withDayOfMonth(now.getDayOfMonth());
}
})(this);
Things to notice.
- Only those methods added to
context
will be available to the script that loads this library. - Everything defined inside the wrapping
function
is available. This is why the functions can call each other without usingcontext
. - The openHAB types should be in the
context
already but they are not. I need to experiement some more to see why the default profile is not being loaded.
timerMgr.js
This is a class I wrote to handle the use case that appears over and over again where one needs to create and manage one timer per Item across a large set of Items. The whole purpose of libraries is to avoid needing to rewrite code like that over and over again. As with timeUtils, this is made available and will be maintained at https://github.com/rkoshak/openhab-rules-tools/tree/main/timer_mgr.
/**
* Constructor, initializes the logger, imports the needed stuff and creates an empty timers dict.
*/
var TimerMgr = function() {
'use strict';
var OPENHAB_CONF = java.lang.System.getProperty("openhab.conf");
this.log = Java.type("org.slf4j.LoggerFactory").getLogger("org.openhab.model.script.Rules.TimerMgr");
this.log.debug("Building timerMgr instance.");
this.timers = {};
this.log.debug("Loading timeUtils");
load(OPENHAB_CONF+'/automation/lib/javascript/community/timeUtils.js');
this.ScriptExecution = Java.type("org.openhab.core.model.script.actions.ScriptExecution");
this.log.debug("Timer Mgr is ready to operate");
}
/**
* Private function that gets called when the timer expires. It does some cleanup and executes
* the passed in timer lambda, if there was one.
* @param {*} the unique name for the timer, can be anything supported by a dict but will usually be a string
*/
TimerMgr.prototype._notFlapping = function(key) {
this.log.debug("Timer expired for " + key);
if (key in this.timers && "notFlapping" in this.timers[key]) {
this.log.debug("Calling expired function " + this.timers[key]["notFlapping"]);
this.timers[key]["notFlapping"]();
}
if (key in this.timers){
this.log.debug("Deleting the expired timer");
delete this.timers[key];
}
},
/**
* Private function that does nothing. Used when the user didn't pass in a function to call
* when the timer expires.
*/
TimerMgr.prototype._noop = function() { },
/**
* Call when one wants to create a timer or check to see if a timer is already created.
* Depending on the arguments, a new timer may be created, an existing timer rescheduled,
* and if the timer already exists, a flapping function called. This lets one do something
* when the timer already exists or when the timer expires.
*
* @param {*} key the unique ID for the timer, usually an Item name
* @param {*} when any representation of time supported by timeUtils.toDateTime
* @param {*} func function called when the timer expires
* @param {*} reschedule defaults to false, when true if the timer already exists it wilol be rescheduled
* @param {*} flappingFunc optional function to call when the timer already exists.
*/
TimerMgr.prototype.check = function(key, when, func, reschedule, flappingFunc) {
this.log.debug("Timer manager check called");
if (reschedule === undefined) reschedule = false;
var timeout = toDateTime(when);
this.log.debug("Timer to be set for " + timeout.toString());
// Timer exists
if (key in this.timers){
if (reschedule){
this.log.debug("Rescheduling timer " + key + " for " + timeout.toString());
this.timers[key]["timer"].reschedule(timeout);
}
else {
this.log.debug("Cancelling timer " + key);
this.cancel(key);
}
if (flappingFunc !== undefined){
this.log.debug("Running flapping function for " + key);
flappingFunc();
}
}
// Timer doesn't already exist, create one
else {
this.log.debug("Creating timer for " + key);
var timer = this.ScriptExecution.createTimerWithArgument(timeout, this, function(context) { context._notFlapping(key); });
this.timers[key] = { "timer": timer,
"flapping": flappingFunc,
"notFlapping": (func !== undefined) ? func : this._noop }
this.log.debug("Timer created for " + key);
}
},
/**
* @param {*} key unique name for the timer
* @return true if the timer exitst, false otherwise
*/
TimerMgr.prototype.hasTimer = function(key) {
return key in this.timers;
},
/**
* Cancels the timer by the passed in name if it exists
* @param {*} key unique name for the timer
*/
TimerMgr.prototype.cancel = function(key) {
if (key in this.timers) {
this.timers[key]["timer"].cancel();
delete this.timers[key];
}
},
/**
* Cancels all the timers.
*/
TimerMgr.prototype.cancelAll = function() {
for (key in this.timers) {
if (!this.timers[key]["timer"].hasTerminated()) {
this.log.debug("Timer has not terminated, cancelling timer " + key);
this.cancel(key);
}
delete this.timers[key];
this.log.debug("Timer entry has been deleted for " + key);
}
}
In this case TimerMgr is a class. As such all the definitions are kept inside the class definition with the only symbol exposed to the context being TimerMgr itself. Therefore wrapping the above in the function would be redundant.
Things to notice:
- See how
timerUtils.js
is loaded here showing how libraries can import other libraries. - Because you are inside a class,
this
now refers to the context inside the class, not thecontext
from the script that loaded it. - However, if you have statically defined methods, the
this
is the calling rule, not the class. You can pass it a different context by usingapply
, e.g.callback.apply(this, [list, of, arguments]);
.
utils.js
This is a personal library I’m writing with some random functions I reuse across various rules.
(function(context) {
'use strict';
/**
* Sends the message to email and notifications based on time of day.
* @param {string} message string to send out as an alert message
*/
context.sendAlert = function(message) {
var log = (context.logger === undefined) ? Java.type("org.slf4j.LoggerFactory").getLogger("org.openhab.model.script.Rules.sendAlert") : context.logger;
log.warn("ALERT: " + message);
var night = context.items["TimeOfDay"] == "NIGHT" || context.items["TimeOfDay"] == "BED";
if(!night){
// TODO add push notification
context.actions.get("mail", "mail:smtp:gmail").sendMail("rlkoshak@gmail.com", "openHAB 3 Alert", message);
}
else {
// TODO Only send emails at night
context.actions.get("mail", "mail:smtp:gmail").sendMail("rlkoshak@gmail.com", "openHAB 3 Night Alert", message);
}
}
/**
* Sends the message to email.
* @param {string} message string to send out as an info message
*/
context.sendInfo = function(message) {
var log = (context.logger === undefined) ? Java.type("org.slf4j.LoggerFactory").getLogger("org.openhab.model.script.Rules.sendInfo") : context.logger;
log.warn("INFO: " + message);
context.actions.get("mail", "mail:smtp:gmail").sendMail("rlkoshak@gmail.com", "openHAB 3 Info", message);
}
/**
* Returns the metadata on the passed in item name with the given namespace.
* @param {string} itemName name of the item to search the metadata on
* @param {string} namespace namespace of the metadata to return
* @return {Metadata} the value and configuration or null if the metadata doesn't exist
*/
context.getMetadata = function(itemName, namespace) {
var FrameworkUtil = Java.type("org.osgi.framework.FrameworkUtil");
var _bundle = FrameworkUtil.getBundle(scriptExtension.class);
var bundle_context = _bundle.getBundleContext()
var MetadataRegistry_Ref = bundle_context.getServiceReference("org.openhab.core.items.MetadataRegistry");
var MetadataRegistry = bundle_context.getService(MetadataRegistry_Ref);
//var Metadata = Java.type("org.openhab.core.items.Metadata");
var MetadataKey = Java.type("org.openhab.core.items.MetadataKey");
return MetadataRegistry.get(new MetadataKey(namespace, itemName));
}
/**
* Returns the value of the indicated namespace
* @param {string} itemName name of the item to search the etadata on
* @param {string} namespace namespace to get the value from
* @return {string} The value of the given namespace on the given Item, or null if it doesn't exist
*/
context.getMetadataValue = function(itemName, namespace) {
var md = getMetadata(itemName, namespace);
return (md === null) ? null : md.value;
}
/**
* Returns the configuration of the given key in the given namespace
* @param {string} itemName name of the item to search for metadata on
* @param {string} namespace namespace of the metadata
* @param {string} key name of the value from config to return
* @return {string} the value assocaited with the key, null otherwise
*/
context.getMetadataKeyValue = function(itemName, namespace, key){
var md = getMetadata(itemName, namespace);
if(md === null){
return null;
}
return (md.configuration[key] === undefined) ? null : md.configuration[key];
}
/**
* Returns the value of the name metadata, or itemName if name metadata doesn't exist on the item
* @param {string} itemName name of the Item to pull the human friendly name metdata from
*/
context.getName = function(itemName) {
var name = getMetadataValue(itemName, "name");
return (name === null) ? itemName : name;
}
/**
* Filters the members of the passed in group and generates a comma separated list of
* the item names (based on metadata if available).
* @param {string} groupName name of the group to generate the list of names from
* @param {function} filterFunc filtering function that takes one Item as an argument
*/
context.getNames = function(groupName, filterFunc) {
var Collectors = Java.type("java.util.stream.Collectors");
return context.ir.getItem(groupName)
.members
.stream()
.filter(filterFunc)
.map(function(i) {
return context.getName(i.name);
})
.collect(Collectors.joining(", "));
}
})(this)
Things to notice:
- this shows how to access binding actions from a library (see sendAlert and sendInfo)
- I define a
name
metadata on many of my Items so I can send alerts and reports using a human friendly name instead of the Item name -
getNames
shows one way to filter and map/reduce a list of Items into a comma separated string listing the Item’s names. This is using Java’s Stream API. There might be a more JavaScript way to do this but that would require converting the Java List one gets when callingmembers
on the Group to a JavaScript array (can be done withvar jsarray = Java.from(context.ir.getItem(grouName).members);
).
A few rule examples that load and use libraries
debounce
I’m running out of space in this post so see https://github.com/rkoshak/openhab-rules-tools/blob/main/debounce/javascript/debounce.yml which shows loading and using TimerMgr
from above. This rule implements a debounce on Item states based on Item metadata.
ephem_tof
See https://github.com/rkoshak/openhab-rules-tools/blob/main/ephem_tod/javascript/ephemTimeOfDay.yml which shows loading and using TimerMgr
and timeUtils
. This rule implements the Time of Day design pattern using Ephemeris so one can define a different set of times of days based on the type of day it is (e.g. have a different set for weekends and for weekdays).
Offline Alert Report
triggers:
- id: "1"
configuration:
time: 08:00
type: timer.TimeOfDayTrigger
conditions:
- inputs: {}
id: "2"
configuration:
itemName: ServiceStatuses
state: ON
operator: "!="
type: core.ItemStateCondition
actions:
- inputs: {}
id: "3"
configuration:
type: application/javascript
script: >-
var logger =
Java.type("org.slf4j.LoggerFactory").getLogger("org.openhab.model.script.Rules.OfflineReport");
scriptExtension.importPreset("default");
this.OPENHAB_CONF = (this.OPENHAB_CONF === undefined) ? java.lang.System.getProperty("openhab.conf") : this.OPENHAB_CONF;
load(OPENHAB_CONF + "/automation/lib/javascript/personal/utils.js")
var nullItems = getNames("ServiceStatuses", function(i) { return i.state.class == UnDefType.class; });
logger.info(nullItems);
var offItems = getNames("ServiceStatuses", function(i) { return i.state == OFF; });
logger.info(offItems);
var msg = "";
if(nullItems.length > 0) {
msg = "The following sensors are in an unknown state: " + nullItems;
}
if(offItems.length > 0) {
if(msg.length > 0) msg += "\n";
msg += "The following sensors are known to be offline: " + offItems;
}
sendInfo(msg);
type: script.ScriptAction
This rule sends me an infoAlert (using utils.js) every morning with a list of all my home automation relevant services that are in an unknown state or are offline. The rule only runs if there is one or more members of ServiceStatuses
that are in this state.