Some JS Scripting UI Rules Examples

It looks like openhab jsscripting does not include nodejs runtime(*). What does this mean? All js packages from npm (such as fetch) that rely on modules listed in Index | Node.js v21.7.1 Documentation will not work. For some of the nodejs runtime modules there exists versions meant for browsers (not relying on nodejs), such as the “stream” javascript npm package.

There a lot of packages that will work just fine but I can imagine all functionality related to OS APIs (file system, networking, OS threads, starting new processes etc) will not be available.

Do we have to fallback to the functionality of java in these cases?

Cc @rlkoshak

(*) graalvm javascript seems to include the nodejs runtime if started on a particular way. From Redirecting… :

The Node.js runtime cannot be embedded into a JVM but has to be started as a separate process.

However, separate process would not work with the current single process design of openHAB. At least this is my understanding…

To my understanding openhab is not starting the separate process currently.

Yes, of course. All of Java is available to you with a Java.type('name.of.class') to import it.

However, beware of anything that messes with threading as GraalVM throws an exception when trying to mess with threads, such as Thread.sleep().

So I should not use the require statement to import the node-module? How would I import node-fetch module using Java.type command?

I don’t think that’s what @ssalonen is saying. What he is saying is that not all Node modulkes can be imported without having an instance of something (node I guess) running along side of OH.

You wouldn’t. You would use the Java HTTP classes instead of node-fetch as a fall back.

as @rlkoshak said… I do not think you can get node-fetch to work with openHAB since it eventually relies on http builtin module provided by nodejs runtime which just isn’t there with openHAB jsscripting environment.

If the http utilities offered by openhab-js is not enough (see actions - Documentation and JavaScript Scripting - Automation | openHAB), you need to revert to java libraries meant for HTTP. In openHAB, jetty is used (ref). You hopefully can just “import” the jetty functions using Java.type function – I haven’t tested this.

As you can see it gets quite complex unfortunately and you need both javascript and java skills to make it happen.

Adding a few more point examples. I have these as a Scripts for future reference.

Create a rule from a rule

The rule will disappear when OH restarts unless this code is run again. You cannot set the UID when using builder as far as I can tell. Deleting the rule does not appear to work.

// Delete doesn't work
const { ruleRegistry } = require('@runtime/RuleSupport');
let RuleManager = osgi.getService("org.openhab.core.automation.RuleManager");
console.info('Creating a rule');

// rule builder
var ruleBuilder = function() {
  rules.when().item('aTestSwitch').receivedCommand().then(event => {
    console.log(event);
  }).build("Test rule from rule", "Test rule from rule");
}

var ruleJSRule = function() {
  rules.JSRule({
    name: 'Test rule from rule2',
    description: 'Test rule from rule2',
    id: ruleUID+'_test_jsrule',
    triggers: [triggers.ItemCommandTrigger('aTestSwitch')],
    execute: event => {
      console.log(event);
    }
  });
}

//ruleBuilder();
ruleJSRule();
// See JS Scripting docs.

console.info("Triggering created rule");
items.getItem('aTestSwitch').sendCommand('ON');

console.info('Deleting the rule');

var allRules = utils.javaSetToJsArray(ruleRegistry.getAll());
var uids = allRules.filter(r => r.getName() == "Test rule from rule2");

if(uids.length > 0) {
  console.info('Found some rules', uids.length, uids[0].getUID());  
  const rval = ruleRegistry.remove(uids[0].getUID());
  if(!rval) console.error("failed to remove rule!", rval);
}

var status = RuleManager.getStatus(uids[0].getUID());

if(status) {
  console.info(status);
}
else {
  console.info("Rule no longer exists");
}

Disable/Enable another rule

const { ruleRegistry } = require('@runtime/RuleSupport');
let RuleManager = osgi.getService("org.openhab.core.automation.RuleManager");

var logger = log('examples');

// Get all the rules
var allRules = utils.javaSetToJsArray(ruleRegistry.getAll());
logger.info('There are {} rules', allRules.length);
for each (let r in allRules) {
  logger.info('Rule name = {} Rule UID = {}', r.getName(), r.getUID());
}

logger.info("This rule's UID = {}", ruleUID);

// Disable this rule
logger.info('Enabled status before: {}', RuleManager.isEnabled(ruleUID));
RuleManager.setEnabled(ruleUID, false);
logger.info('Enabled status after: {}', RuleManager.isEnabled(ruleUID));

Ephemeris

let logger = log('ephemeris example');
logger.info('About to test Ephemeris');
logger.info((actions.Ephemeris.isWeekend()) ? "It's the weekend" : 'Get back to work!');

executeCommandLine

var results = actions.Exec.executeCommandLine(time.Duration.ofSeconds(1), 'echo', 'hello');
console.info(results);

Working with QuantityTypes

var QT = Java.type('org.openhab.core.library.types.QuantityType');

let cloudiness = items.getItem('vCloudiness');
console.info('Cloudiness.rawState.class = ' + cloudiness.rawState.class.toString());
console.info('Cloudiness.state = ' + cloudiness.state); // string
console.info('Cloudiness.rawState = ' + cloudiness.rawState); // QuantityType
console.info('Cloudiness as number = ' + cloudiness.rawState.floatValue()); // float
console.info('Compare to 50%: ' + (cloudiness.rawState.compareTo(QT.valueOf('50 %')) < 0));

Run another rule

let RuleManager = osgi.getService("org.openhab.core.automation.RuleManager");

RuleManager.runNow('tocall');
var map = { 'test_data': 'Passed data to called rule' }
RuleManager.runNow('tocall', true, map);

In tocall the passed in test_data is available as test_data.

Here’s tocall

var logger = Java.type("org.slf4j.LoggerFactory").getLogger("org.openhab.model.script.Run From");
logger.info("Map is: " + context.getAttribute("test_data"));

Complex coordination with timing

JS Scripting doesn’t support Thread::sleep, nor does it support having more than one thread running at the same time. This means you cannot have the script running at the same time as a timer nor can you have two timers running at the same time from the same script. Here is one way to work around that which I’ve used in my “unit tests” I’ve created for one of my libraries.

This is testing the Gatekeeper class.

Note it also shows examples for creating Timers.

var {gatekeeper, testUtils} = require('openhab_rules_tools');
var logger = log('rules_tools.Gatekeeper Tests');

var testFunGen = (test, num) => {
  logger.debug('generating function for {} run{}', test, num);
  return () => {
    logger.debug('{}: Test {} ran', test, num);
    cache.put(test+'_run'+num, time.ZonedDateTime.now());
  };
}

logger.info('Starting Gatekeeper tests');

var reset = (test) => {
  logger.debug('resetting {}');
  cache.put(test, null);
  logger.debug('resetting {}', test+'_start');
  cache.put(test+'_start', null);
  for(var i = 1; i <= 4; i++ ) {
    logger.debug('resetting {}', test+'_run'+1)
    cache.put(test+'_run'+i, null);
  }
}

// Test 1: Scheduling
var gk1 = new gatekeeper.Gatekeeper();
var TEST1 = ruleUID+'_test1';
reset(TEST1);
cache.put(TEST1+'_start', time.ZonedDateTime.now());
gk1.addCommand('1s', testFunGen(TEST1, 1));
gk1.addCommand('2s', testFunGen(TEST1, 2));
gk1.addCommand('3s', testFunGen(TEST1, 3));
gk1.addCommand(500, testFunGen(TEST1, 4));

actions.ScriptExecution.createTimer(time.ZonedDateTime.now().plus(6500, time.ChronoUnit.MILLIS), () => {
  var success = true;
  const start = cache.get(TEST1+'_start');
  const run1 = cache.get(TEST1+'_run1');
  const run2 = cache.get(TEST1+'_run2');
  const run3 = cache.get(TEST1+'_run3');
  const run4 = cache.get(TEST1+'_run4');
  if(start === null) {
    logger.error('{} Failed to get starting timestamp', TEST1);
    success = false;
  }
  if(success && run1 === null) {
    logger.error('{} run1 failed to run!', TEST1);
    success = false;
  }
  if(success && run2 === null) {
    logger.error('{} run2 failed to run!', TEST1);
    success = false;
  }
  if(success && run3 === null) {
    logger.error('{} run3 failed to run!', TEST1);
    success = false;
  }
  if(success && run4 === null) {
    logger.error('{} run4 failed to run!', TEST1);
    success = false;
  }
  
  if(success) {
    logger.info('\n{}\n{}\n{}\n{}\n{}', start.toString(), run1.toString(), run2.toString(), run3.toString(), run4.toString());
    const dur1 = time.Duration.between(run1, run2).seconds();
    const dur2 = time.Duration.between(run2, run3).seconds();
    const dur3 = time.Duration.between(run3, run4).seconds();
  
    if(start.isAfter(run1)) {
      logger.error('{} failed, run1 ran before start!', TEST1);
      success = false;
    }
    if(success && dur1 != 1) {
      logger.error('{} failed, time between run1 and run2 is {} seconds.', dur1);
      success = false;
    }
    if(success && dur2 != 2) {
      logger.error('{} failed, time between run2 and run3 is {} seconds', dur2);
      success = false;
    }
    if(success && dur3 != 3) {
      logger.error('{} failed, time between run3 and run4 is {} seconds', dur3);
    }
    if(success) {
      logger.info('Gatekeeper test 1 success!');
    }
  }
});

// Test 2: cancelAll
var gk2 = new gatekeeper.Gatekeeper();
var TEST2 = ruleUID+'_test2'
reset(TEST2);
gk2.addCommand('1s 500z', testFunGen(TEST2, 1));
gk2.addCommand('2s', testFunGen(TEST2, 2));
gk2.addCommand('3s', testFunGen(TEST2, 3));
gk2.addCommand(500, testFunGen(TEST2, 4));

actions.ScriptExecution.createTimer(time.ZonedDateTime.now().plus(2750, time.ChronoUnit.MILLIS), () => {
  var success = true;
  const run1 = cache.get(TEST2+'_run1');
  const run2 = cache.get(TEST2+'_run2');
  const run3 = cache.get(TEST2+'_run3');
  const run4 = cache.get(TEST2+'_run4');
  
  if(!run1) {
    logger.error('{} failed, run1 did not run', TEST2);
    success = false;
  }
  if(success && !run2) {
    logger.error('{} failed, run2 did not run', TEST2);
    success = false;
  }
  if(success && run3) {
    logger.error('{} failed, run3 ran too soon', TEST2);
    success = false;
  }
  if(success && run4) {
    logger.error('{} failed, run4 ran too soon', TEST2);
    success = false;
  }
  if(success) {
    gk2.cancelAll();
    actions.ScriptExecution.createTimer(time.ZonedDateTime.now().plus(4000, time.ChronoUnit.MILLIS), () => {
      var success = true;
      const run3 = cache.get(TEST2+'_run3');
      const run4 = cache.get(TEST2+'_run4');
      
      if(run3) {
        logger.error('{} failed, run3 ran after being cancelled');
        success = false;
      }
      if(success && run4) {
        logger.error('{} failed, run4 ran after being cancelled');
      }
      if(success){
        logger.info('Gatekeeper test 2 success!')
      }
    });
  }
});
3 Likes

Hi Rich,

From Dad motion or Service Offline Alerting I see you use: var timers = cache.get(ruleUID+'_tm', () => new timerMgr.TimerMgr());. Do the timers get stored in the cache because you use the TimerMgr as a class? I can’t see anywhere a cache.put() so I wondered how these timers end up in the cache in the first place…

Thanks

There is an implicit put when you call get with a default. So that line returns the value associated with ruleUID+'_tm', or if that’s null, create a new TimerMgr and put that as the value for that key.

That’s how it works in practice anyway.

Thanks. Got it :slight_smile:

Hi,
I was looking for a rule to have a notification when a window was forgotten opened, and I found your scripts and libraries. After one day I found how to install but I cannot understand why, into your rule, I cannot reschedule the timer.

alerting.sendAlert(name + ' has been open for ' + when);
              // Reschedule if it's night
              var tod = items.getItem('TimeOfDay').state;
              if(alerting.isNight()) {
                logger.info('Rescheduling timer for ' + name + " because it's night");
                timers.check(itemName, when, reminderGenerator(itemName, name, when));

`
and another strange thing, If I modify the rule code, I have a timer created when the window goes on CLOSED too.

If you’re going to use the library you need to be sure to read how it works which means, for now at least, reading the comments in the code. You will see that check takes up to five arguments. The last two control what happens when the timer already exists.

:person_shrugging: Modify how? How is the rule triggered?

I have modified the rule to don’t check the TimeOfDay because I still want to have a reminder forever until I close the window(s).
This is my rule:

configuration: {}
triggers:
  - id: "1"
    configuration:
      groupName: gFinestre
      state: OPEN
    type: core.GroupStateChangeTrigger
  - id: "4"
    configuration:
      groupName: gFinestre
      state: CLOSED
    type: core.GroupStateChangeTrigger
conditions:
  - inputs: {}
    id: "2"
    label: The door's state didn't change to an UnDefType
    configuration:
      type: application/javascript;version=ECMAScript-2021
      script: |
        !items.getItem(event.itemName).isUninitialized;
    type: script.ScriptCondition
actions:
  - inputs: {}
    id: "3"
    configuration:
      type: application/javascript;version=ECMAScript-2021
      script: >
        var {alerting} = require('personal');

        var {timerMgr} = require('openhab_rules_tools');

        var logger = log('Open Door Reminder');


        var timers = cache.get(ruleUID+'_tm', () => new timerMgr.TimerMgr());


        var reminderGenerator = function(itemName, name, when, timers){
          return function() {
            var item = items.getItem(itemName)
            if(item.state != 'OPEN') {
              logger.warn(itemName + ' open timer expired but the door is ' 
                          + item.state + '. Timer should have been cancelled.');
            }
            else {
              alerting.sendAlert(name + ' has been open for ' + when);
              // Reschedule if it's night
              //var tod = items.getItem('TimeOfDay').state;
              //if(alerting.isNight()) {
                logger.info('Rescheduling timer for ' + name + " because it's night");
                timers.check(itemName, when, reminderGenerator(itemName, name, when, timers), true);
                logger.info('Creating a new reminder timer for ' + itemName + ' for ' + when); //logger.debug
              //}
            }
          }
        }


        var item = items.getItem(event.itemName);

        logger.info('Handling new door state for reminder: ' + item.name + ' = ' + item.state); //logger.debug

        if(item.state == 'CLOSED' && this.timers.hasTimer(item.name)) {
          logger.info('Cancelling the timer for ' + item.name);
          this.timers.cancel(item.name);
        }

        else {
          var name = item.getMetadataValue('name') || item.label.replace(' Sensor', '').replace(' Status', '');
          var remTime = item.getMetadataValue('rem_time') || '1m';
          logger.info('Creating a reminder timer for ' + item.name + ' for ' + remTime); //logger.debug
          timers.check(item.name, remTime, reminderGenerator(event.itemName, name, remTime, timers));
        }
    type: script.ScriptAction

But I cannot re-schedule the timer. I think I made something wrong but I didn’t understand where.

For the problem for CLOSED, this is what happens in my log:

2022-02-03 16:20:21.875 [INFO ] [openhab.event.ItemStateChangedEvent ] - Item ‘GF_Bathroom_Finestra’ changed from UNDEF to CLOSED
2022-02-03 16:20:21.922 [INFO ] [automation.script.open door reminder] - Handling new door state for reminder: GF_Bathroom_Finestra = CLOSED
2022-02-03 16:20:21.930 [INFO ] [automation.script.open door reminder] - Creating a reminder timer for GF_Bathroom_Finestra for 1m
2022-02-03 16:21:21.956 [WARN ] [automation.script.open door reminder] - GF_Bathroom_Finestra open timer expired but the door is CLOSED. Timer should have been cancelled.

How do you know the timer isn’t rescheduled? If the door is closed when the timer runs it’s not going to even try to reschedule the timer. And the logging indicates that it clearly things that the item’s state is not OPEN and when it logs out it’s reading as ‘CLOSED’.

So you’ve really got just one problem that needs to be solved, but the solution might not be in this code at all. Why is the Item’s state reading as not being ‘OPEN’ when the timer goes off?

If what you are really after is a repeating timer, LoopingTimer is a better choice.

In my log, when I keep the windows OPEN, I have:

2022-02-03 17:57:41.921 [INFO ] [openhab.event.ItemStateChangedEvent ] - Item 'GF_Bathroom_Finestra' changed from CLOSED to OPEN
2022-02-03 17:57:41.996 [INFO ] [automation.script.open door reminder] - Handling new door state for reminder: GF_Bathroom_Finestra = OPEN
2022-02-03 17:57:42.012 [INFO ] [automation.script.open door reminder] - Creating a reminder timer for GF_Bathroom_Finestra for 1m
2022-02-03 17:58:42.061 [WARN ] [.openhab.automation.script.sendalert] - ALERT: Finestra Bagno has been open for 1m
2022-02-03 17:58:42.076 [INFO ] [automation.script.open door reminder] - Rescheduling timer for Finestra Bagno because it's night
2022-02-03 17:58:42.151 [INFO ] [automation.script.open door reminder] - Creating a new reminder timer for GF_Bathroom_Finestra for 1m

but nothing happens later…

when moves to CLOSED:

2022-02-03 18:01:49.932 [INFO ] [openhab.event.ItemStateChangedEvent ] - Item 'GF_Bathroom_Finestra' changed from OPEN to CLOSED
2022-02-03 18:01:49.977 [INFO ] [automation.script.open door reminder] - Handling new door state for reminder: GF_Bathroom_Finestra = CLOSED
2022-02-03 18:01:49.983 [INFO ] [automation.script.open door reminder] - Creating a reminder timer for GF_Bathroom_Finestra for 1m
2022-02-03 18:02:49.989 [WARN ] [automation.script.open door reminder] - GF_Bathroom_Finestra open timer expired but the door is CLOSED. Timer should have been cancelled.

Now I take a look at LoopingTimer…

JS Scripting has a limitation where it doesn’t allow threaded operations. Among those operations are the ability for the lambda acting on a timer to cancel itself. This might also extend to rescheduling the timer too.

In my original code I deliberately recreate the Timer instead of rescheduling it. But even so, there can be other things going on in the background that can cause problems which is why I created LoopingTimers in the first place.

All I can really offer is that this rule works for me as posted. I’m unable to reproduce the behavior you are seeing.

Hi again,
I modified the debug line into info into the library, and the strange thing about timerMgr in function reminderGenerator is:

alerting.sendAlert(name + ' has been open for ' + when);
logger.info('Rescheduling timer for ' + name + " because it's night");
timers.check(itemName, when, reminderGenerator(itemName, name, when));

the the log:

2022-02-04 17:47:01.973 [WARN ] [.openhab.automation.script.sendalert] - ALERT: Finestra Bagno has been open for 1m
2022-02-04 17:47:01.997 [INFO ] [automation.script.open door reminder] - Rescheduling timer for Finestra Bagno because it's night
2022-02-04 17:47:02.014 [INFO ] [tomation.script.rules_tools.timermgr] - Timer manager check called for GF_Bathroom_Finestra
2022-02-04 17:47:02.066 [INFO ] [tomation.script.rules_tools.timermgr] - Timer to be set for 2022-02-04T17:48:02.026+01:00[SYSTEM]
2022-02-04 17:47:02.070 [INFO ] [tomation.script.rules_tools.timermgr] - Cancelling timer GF_Bathroom_Finestra
2022-02-04 17:47:02.084 [INFO ] [tomation.script.rules_tools.timermgr] - Cancelling GF_Bathroom_Finestra

it seems to create a new timer and then cancel it.
Any help?

OK, based on those logs what’s happening is that check is called. The second log “Timer to be set for…” happens after what ever is passed as when is converted to a ZonedDateTime. The timer isn’t created yet.

Next it checks to see if there is already a timer associated with this key. Based on these logs, there is infact a timer already associated with this key.

Then it checks to see if the reschedule flag was passed in. Either reschedule was not passed in or false was passed in so it cancels the timer.

The root problem is that you’ve not told it to rechedule because you’ve not passed in the reschedule flag with true. Make sure to pass true as the fourth argument when calling check.

Note that when you add the TimerMgr to the cache, that same TimerMgr gets reused even if you save the file. So it’s possible that you have old versions of your timer and old versions of your timer lambdas still living in the cache. You can clear out the old TimerMgr by calling cache.put(key, null) from somewhere with key being the same as used in your rule. The cache is shared by all rules so you could do this from -Scratchpad- if you want.

I don’t know that I’ve ever really tested the repeated alerts part of the code since rewriting it in ECMAScript 2021 so it’s possible my original has a bug.

I’ve added the reschedule flag when calling the function “timer.check” and from logs, it reschedule the timer but after it went deleted.
I’m taking a look at the timerMgr.js and I see the part under function _notFlapping seems to the reschedule command goes into this:

  _notFlapping(key) {
   this.logger.info("Creating expire function"); //debug
    return function(context) {
      context.logger.debug('Timer expired for {}', key);
      if(key in context.timers && 'notFlapping' in context.timers[key]){
        context.logger.info('Calling expired function {}', context.timers[key]['notFlapping']);
        context.timers[key]['notFlapping']();
      }
      if(key in context.timers) {
        context.logger.info('Deleting the epired timer');
	delete context.timers[key];
	}
 }

but the context delete is didn’t take care about the timer rescheduled, so it delete it without any further check.
Now i’m trying to see if I can modify the function and pass the reschedule flag so I can bypass in case of rescheduling active… But I’m not a real programmer, so I see it very harder.

How can I delete it? I’m using your script with same values but if I try to do in scratchpad

cache.put(open_windows_reminder_tm, null);

I receive Script execution of rule with UID ‘scratchpad’ failed: ReferenceError: “cache” is not defined in at line number 2

That’s the function that gets called when the timer goes off. It calls the function you passed in as the third argument and does some cleanup.

The key is a string.

Also make sure the -Scratchpad- is using ECMAScript 2021, not 5.1 or Rules DSL.

I made a modification sending the flag “rescheduled” and seems working… I’ll send if someone interested:

_notFlapping(key,reschedule) {
    this.logger.debug("Creating expire function"); //debug
    return function(context) {
      context.logger.debug('Timer expired for {}', key);
      if(key in context.timers && 'notFlapping' in context.timers[key]){
        context.logger.debug('Calling expired function {}', context.timers[key]['notFlapping']);
        context.timers[key]['notFlapping']();
      }
      if(key in context.timers) {
        if(!reschedule) {
			context.logger.debug('Deleting the epired timer');
			delete context.timers[key];
		}
      }
    }
  }

 

  check(key, when, func, reschedule, flappingFunc) {
    this.logger.debug('Timer manager check called for {}', key);

    var timeout = timeUtils.toDateTime(when);
    this.logger.debug('Timer to be set for ' + timeout.toString());

    // timer exists
    if(key in this.timers){
      if(reschedule) {
        this.logger.debug("Rescheduling timer {} for {}", key, timeout.toString());
        this.timers[key]['timer'].reschedule(timeout); 
      }
      else  {
        this.logger.debug('Cancelling timer {}', key);
        this.cancel(key);
      }
      if(flappingFunc) {
        this.logger.debug('Running flapping function for {}', key);
        flappingFunc();
      }
    }

    // timer doesn't already exist, create a new one
    else {
      this.logger.debug('Creating timer for {}', key);
      var timer = actions.ScriptExecution.createTimerWithArgument(timeout, 
                                                                  this,
                                                                  this._notFlapping(key,reschedule));
      this.timers[key] = { 'timer': timer,
                           'flapping': flappingFunc,
                           'notFlapping': (func) ? func : this._noop };
      this.logger.debug('Timer created for {}', key);
    }
  }

I’ve found the problem while you’re replying to me… Many thanks.