Design Pattern: Simple State Machine (e.g. Time Of Day)

The JavaScript version works exactly like the Jython version which is what drives my production OH 2.5 system (I’m still transitioning so I have both running at the same time. The DSL version has the run conditions because there is no way to access Item metadata from Rules DSL Rules. Consequently there is no way to implement it any other way. But that approach is not suitable for making a reusable library. A reusable library should be usable without modifying the code.

Something changed recently in the core that might be a bug that needs to be filed here. I need more experimentation to figure out if that’s the case though.

At this point I’ve confirmed that the timers are being created. But it seems like they are silently being destroyed before they get a chance to run. I wish there were a way I could reproduce the behavior without waiting a whole day.

1 Like

But that approach is not suitable for making a reusable library. A reusable library should be usable without modifying the code.

I agree, the new version is also much more versatile (thinking about weekends/holidays).

I wish there were a way I could reproduce the behavior without waiting a whole day.

For that purpose I’ve created a test item, put it in the group along with the proper metadata, and set it to a time 10 minutes from now. When the script is run manually, that gets updated along with the others and the timer is scheduled. Or is that too easy?

It’s not a matter of triggering the rule, because when you manually run it, it works. When OH restarts it works. It seems to be only when the rule is triggered at midnight that the timers disappear. And the question is do they disappear immediately, after a few hours, or when?

My first thought was maybe the context is different depending on how the rule is triggered meaning when it’s cron triggered it has a different set of variables from when it’s triggered based on a change or system started. I think I’ve eliminated that as the root cause (one more test is needed to verify).

So now my thought is that the timers are being garbage collected over night. But I need a stretch of time to give the system time to garbage collect them. So far six hours doesn’t seem to be long enough. If that is what’s happening I’ll file a bug in the core because it shouldn’t do that and it didn’t used to do that.

1 Like

Ah, I see, thanks for the clarification.
In that case patience is probably necessary :wink:

Hello,

Just a question about being able to change some of the times (using Rules DSL). In the example, it lists that following TODs are as follows:

  • MORNING: 06:00 to sunrise; Note during some times of year sunrise is before 06:00
  • DAY: Sunrise to 90 minutes before sunset
  • EVENING: 90 minutes before sunset to 23:00
  • NIGHT: 23:00 to 00:100:
  • BED: 00:00 to 06:00

However, if I wanted to change BED to be between 22:30 and 06:00, how would I change that? I understand that the value of ‘now.withTimeAtStartOfDay’ gives a value of midnight (or 00:00), hence the adding/subtracting of days and hours, but how would I get the minutes? Can I give the value of ‘minusHours()’ as a decimal, like this:

val bed_start = now.withTimeAtStartOfDay.plusDays(1).minusHours(2.5)

to get a value of 22:30?

(NOTE: I realize if I change the BED value, I will have to adjust other values like EVENING and NIGHT, but those should be fairly easy once I know if I can use decimals in my ‘minusHours()’ string)

Ok, so you can use Minutes to define the time. A bed start of 22:30 would be:
.plusDays(1).minusHours(2).plusMinutes(30) or even
.plusDays(1).minusHours(1).minusMinutes(30)

I think you can also use seconds, if necessary.

However, I found that the design pattern defines bedtime to start at 00:01. If you want to stretch it over the day change at midnight, as I did, I found that you need to define an additional variable for the end of the day. That’s because at midnight the day changes to the next one and the before/after statements don’t work correctly. Without it, the TimeOfDay changes to “UNKNOWN” at midnight.

I’ve already transitioned to the ZonedDateTime class as I am on Openhab3 and the older classes are not supported anymore.

val  ZonedDateTime      zdt = ZonedDateTime.now()
val  ZonedDateTime      start_of_day = zdt.toLocalDate().atStartOfDay(zdt.getOffset())
val  ZonedDateTime      end_of_day = start_of_day.plusDays(1).minusMinutes(1)

That puts start_of_day at 0:00, and end_of_day at 23:59.
The case part of the rule now looks like this:

case now.isAfter(bed_start)       && now.isBefore(end_of_day):      curr = "BED"
case now.isAfter(start_of_day)    && now.isBefore(morning_start):   curr = "BED"

Hope that helps.

1 Like

Just wanted to bring an update. Curiouser and curiouser to quote Alice in Wonderland. Some days it works and some days it doesn’t and I can’t find a pattern between the two. Still doing more tests. I’ve a bunch of logs to look through later today. Hopefully that will reveal something useful.

If it helps, I’m now on OH3 Milestone 5, and the script continues to run twice at night (00:00:30 and again 00:01:00), moves the date and schedules the timers, but no transitions take place.

That at least tells me that it’s not changed behavior. That’s useful information too. Thanks!

1 Like

Do you see the log “Creating timer manager” at any time other than when OH first starts?

If you insert another trigger to the rule to run, let’s say at noon, does it work correctly? That could be a work around until I get to the bottom of this.

No, nothing about a “timer manager” in the logs.
Do you mean noon as driven by a time or by astro?

I currently have five different times scheduled, some by a fixed time and some by astro. Currently, none of them run.

I’ve inserted a NOON time by astro, let’s see what it does. The script did run automatically on the change of the TimeOfDay group, so at least that works. It did re-create all timers, so they really seem to have been killed somehow between the last run at midnight and now (11:30).

It doesn’t matter. I had a trigger at 1:35 pm that I forgot about over the weekend and discovered that since adding that trigger to test with all the timers went off as they were supposed to (and really messed up my data for testing :frowning: ). That extra trigger might be enough to keep the timers from being garbage collected (if that is in fact what is happening.).

But the lack of that “created timer manager” log is evidence against the problem being garbage collection. If the variables are not saved than when the rule triggers ten seconds from midnight, it will have to recreate the TimeMgr and we would see that log statement. Since we don’t that means the TimerMgr is still there.

It always cancels and recreates all the timers. Doing so is way simpler to implement than to check whih timers already exist, match them up to the new timers, and making sure any timers that don’t need to be rescheduled are cancelled. So I just cancel them all and recreate them all every time the rule runs.

Understood.
I created a NOON item, but it didn’t fire when it should have.
As that was supposed to be only like half an hour from the time it was scheduled, that may be another indication that it’s not some kind of garbage disposal algorithm at work.

Well, if the rule didn’t fire that’s a problem with the rule trigger and not anything related to the rule’s code.

What I meant is that my TimeOfDay item did not transition (DAY to NOON) when it should have.

Maybe we are talking cross purposes. I didn’t mean to create a new Item with ephem_tod metadata. I meant to add a new trigger to the rule so it runs at noon(ish). Your Items would stay the same and there would be no new state to transition to. The Rule would just run more than just at midnight.

Remember, the way this rule works is the Rule should run just once a day (around midnight, though because of the timing of updates from Astro it ends up running twice around midnight). I having you add a new trigger so the rule runs twice a day, 12 hours apart.

Sorry, then I misunderstood you.
I can certainly add another trigger, but since the script ran at 11:30 when I added the NOON-item (verified that in the logs), and the transition to NOON was scheduled to occur only 20 minutes later (and didn’t happen), I’m not sure that would help.

There could be other reasons why that didn’t happen. I can’t verify that it is related to the current problem or something else without spending time looking at the Item definition and a bunch of other stuff. But that does not fit the pattern that is consistent between both your setup and my setup where the times of day that are farther from midnight are the ones that are skipped.

I think it’s a conincidence that those just happen to be the ones linked to Astro for you. For me they are half Astro and half statically set times.

Currently, no transitions take place at all, astro or not. That’s with the latest version you posted here a couple days back. I don’t see a version number in the script, so I can’t tell which version it is exactly.

Really, that us different. Most days I’m getting all the transitions as expected. There were two days where it didn’t transition from BED to DAY. I’ve never had it not transition when it was run again though.

Post your Items as they currently are defined.

Post the logs from the last run or two.

Here is the rule again in case there is something went wrong in the copy and paste.

triggers:
  - id: "1"
    configuration:
      groupName: TimesOfDay
    type: core.GroupStateChangeTrigger
  - id: "2"
    configuration:
      startlevel: 20
    type: core.SystemStartlevelTrigger
  - id: "4"
    label: One minute after midnight
    configuration:
      time: 00:01
    type: timer.TimeOfDayTrigger
  - id: "5"
    configuration:
      time: 13:43
    type: timer.TimeOfDayTrigger
conditions: []
actions:
  - inputs: {}
    id: "3"
    configuration:
      type: application/javascript
      script: >
        // Imports

        var logger = Java.type("org.slf4j.LoggerFactory").getLogger("org.openhab.model.script.Rules.TimeOfDay");

        scriptExtension.importPreset("default");

        this.Ephemeris = (this.Ephemeris === undefined) ? Java.type("org.openhab.core.model.script.actions.Ephemeris") : this.Ephemeris;

        this.ZonedDateTime = (this.ZonedDateTime === undefined) ? Java.type("java.time.ZonedDateTime") : this.ZonedDateTime;


        //   Get Metadata query stuff

        this.FrameworkUtil = (this.FrameworkUtil === undefined) ? Java.type("org.osgi.framework.FrameworkUtil") : this.FrameworkUtil;

        this._bundle = (this._bundle === undefined) ? FrameworkUtil.getBundle(scriptExtension.class) : this._bundle;

        this.bundle_context = (this.bundle_context === undefined) ? this._bundle.getBundleContext() : this.bundle_context;

        this.MetadataRegistry_Ref = (this.MetadataRegistry_Ref === undefined) ? bundle_context.getServiceReference("org.openhab.core.items.MetadataRegistry") : this.MetadataRegistry_Ref;

        this.MetadataRegistry = (this.MetadataRegistry === undefined) ? bundle_context.getService(MetadataRegistry_Ref) : this.MetadataRegistry;

        this.Metadata = (this.Metadata === undefined) ? Java.type("org.openhab.core.items.Metadata") : this.Metadata;

        this.MetadataKey = (this.MetadataKey === undefined) ? Java.type("org.openhab.core.items.MetadataKey") : this.MetadataKey;


        // Constants

        var ETOD_ITEM = "TimeOfDay";

        var ETOD_GROUP = "TimesOfDay";

        var DAY_TYPES = ["default", "weekday", "weekend", "dayset", "holiday", "custom"];

        var EXPECTED = "Invalid metadata for Item! "
                     + "Expected metadata in the form of etod=\"STATE\"[type=\"daytype\", set=\"dayset\", file=\"uri\"] "
                     + "where set is required if type is dayset and file is required if type is custom.";
        var ETOD_NAMESPACE = "etod";


        // Load TimerMgr

        this.OPENHAB_CONF = (this.OPENHAB_CONF === undefined) ? java.lang.System.getenv("OPENHAB_CONF") : this.OPENHAB_CONF;

        load(OPENHAB_CONF+'/automation/lib/javascript/community/timerMgr.js');

        load(OPENHAB_CONF+'/automation/lib/javascript/community/timeUtils.js');


        /**
         * Return the value or a key value from the Item's metadata
         * @param {string} item name of the item
         * @param {string} namespace metadata namespace to pull
         * @param {string} key index into the configuration dict for the value
         * @return {string} value assocaited with key or null if it doesn't exist.
         */
        var getValue = function(item, namespace, key) {
          var md = MetadataRegistry.get(new MetadataKey(namespace, item));
          if(md === null || md === undefined) {
            return null;
          }
          else if(key === undefined) {
            return md.value;
          }
          else {
            return md.configuration[key];
          }
        }


        /**
         * Verify Item and Item metadata
         * @param {string} item name of the Item
         * return {string} error string or null if the metadata checks out
         */
        var verifyMetadata = function(item) {
          if(items[item].class == UnDefType.class) {
            return item +"'s state is " + items[items];
          }
          if(getValue(item, ETOD_NAMESPACE) === null) {
            return item + " lacks metadata or metadata value.";
          }
          var type = getValue(item, ETOD_NAMESPACE, "type");
          if(type === null) {
            return item + " lacks a type key."
          }
          
          if(DAY_TYPES.indexOf(type) < 0) {
            return item + " has " + type + " which is not a valid day type, expected one of " + DAY_TYPES + ".";
          }
          
          if(type == "dayset" && getValue(item, ETOD_NAMESPACE, "set") === null) {
            return item + " has type " + type + " which requires a 'set' value to be defined.";
          }
          
          if(type == "custom" && getValue(item, ETOD_NAMESPACE, "file")) {
            return item + " has type " + type + " which requires a 'file' value to be defined.";
          }
          
          return null;
        }


        /**
         * Get a list of all the Items that have ephem metadata with type
         * @param {java.util.List} etodItems collection of all the ETOD Items
         * @param {string} type the type of day 
         * @return {java.util.List} those Items with a type metadata matching type
         */
        var getType = function(etodItems, type){
          return etodItems.stream()
                          .filter(function(item){ 
                              return getValue(item.name, ETOD_NAMESPACE, "type") == type;
                           })
                          .toArray();
        }


        /**
         * Pull the set of Items for today based on Ephemeris
         * @param {java.util.List} etodItems collection of all ETOD Items
         * @return {java.util.List} only those Items defined for today's daytype
         */
        var getTodayItems = function(etodItems) {
          /** 
          Get the Items for today. Hierarchy is:
            - custom
            - holiday
            - dayset
            - weekend
            - weekday
            - default
          */
          var startTimes = {"default": getType(etodItems, "default"),
                            "weekday": (!Ephemeris.isWeekend()) ? getType(etodItems, "weekday") : [],
                            "weekend": (Ephemeris.isWeekend()) ? getType(etodItems, "weekend") : [],
                            "dayset": etodItems.stream()
                                               .filter(function(item) {
                                                  return getValue(item.name, ETOD_NAMESPACE, "type") == "dayset"
                                                         && Ephemeris.isInDayset(getValue(item.name, ETOD_NAMESPACE, "set"));
                                               })
                                               .toArray(),
                            "holiday": (Ephemeris.isBankHoliday()) ? getType(etodItems, "holiday") : [],
                            "custom": etodItems.stream()
                                               .filter(function(item) {
                                                  return getValue(item.name, ETOD_NAMESPACE, "type") == "custom"
                                                         && Ephemeris.isBankHoliday(0, getValue(item.name, ETOD_NAMESPACE, "file"));
                                               })
                                               .toArray()
                           };

          var dayType = null;
          if(startTimes["custom"].length > 0) {
            dayType = "custom";
          }
          else if(startTimes["holiday"].length > 0) {
            dayType = "holiday";
          }
          else if(startTimes["dayset"].length > 0) {
            dayType = "dayset";
          }
          else if(startTimes["weekend"].length > 0) {
            dayType = "weekend";
          }
          else if(startTimes["weekday"].length > 0) {
            dayType = "weekday";
          }
          else if(startTimes["default"].length > 0) {
            dayType = "default";
          }

          logger.info("Today is a " + dayType + " day.");
          return (dayType === null) ? null : startTimes[dayType];

          
        }


        /**
         * Update Items to today
         * @param {java.util.List} times list of all the ETOD Items for today
         */
        var moveTimes = function(times) {
          var now = ZonedDateTime.now();
          for each(var time in times) {
            if(time.state.zonedDateTime.isBefore(now.withHour(0).withMinute(0).withSecond(0))) {
              events.postUpdate(time.name, toToday(items[time.name]).toString());
              logger.info("Moved " + time.name + " to today.");
            }
          }
        }


        /**
         * Create timers for all Items with a time in the future
         * @param {java.util.List} times list of all the ETOD Items for todayu
         */
        var createTimersGenerator = function(times, timers) {
          return function() {
            var now = ZonedDateTime.now();
            var mostRecentTime = now.minusDays(1);
            var mostRecentState = items[ETOD_ITEM];

            logger.info("Cancelling any existing timers");
            timers.cancelAll();
            logger.info("Existing timers have been cancelled");
            for each (var time in times) {
              var name = time.name;
              var dt = time.state.zonedDateTime
              var state = getValue(name, ETOD_NAMESPACE);

              if(dt.isBefore(now) && dt.isAfter(mostRecentTime)) {
                logger.info("NOW:    " + state + " start time " + dt + " is in the past " 
                             + " after " + mostRecentTime);
                mostRecentTime = dt;
                mostRecentState = state;
              }

              else if(dt.isAfter(now)) {
                logger.info("FUTURE: " + state + " scheduleing timer for " + dt);
                timers.check(state, dt, etodTransitionGenerator(state));
              }

              else {
                logger.info("PAST  : " + state + " start time of " + dt + " is before " 
                             + now + " and before " + mostRecentState + " " + mostRecentTime);

              }
            }

            logger.info("Created " + (Object.keys(timers.timers).length - 1) + " time of day timers");
            logger.info("The current time of day is " + mostRecentState);
            if(items[ETOD_ITEM] != mostRecentState) {
              events.sendCommand(ETOD_ITEM, mostRecentState);
            }
          }
        }


        /**
         * Transition to a new Time of Day
         * @TODO look into moving this to another rule we can call so it shows up in schedule
         * @param {string} state the new time of day state
         */
        var etodTransitionGenerator = function(state) {
          return function() {
            logger.info("Transitioning Time of Day from " + items[ETOD_ITEM] + " to " + state);
            events.sendCommand(ETOD_ITEM, state);
          }
        }


        //--------------------------------------------

        // Main body of rule

        if(this.timers === undefined){
          logger.info("Creating timer manager");
        }

        this.timers = (this.timers === undefined) ? new TimerMgr() : this.timers;


        // Skip if we have a flapping timer set

        if(!this.timers.hasTimer("ephem_tod_rule")) {

          // Check that all the required Items and Groups exist
          if(items[ETOD_ITEM] === undefined) {
            throw "The " + ETOD_ITEM + " Item is not defined!";
          }
          if(items[ETOD_GROUP] === undefined) {
            throw "The " + ETOD_GROUP + " Group is not defined!";
          }

          var etodItems = ir.getItem("TimesOfDay").getMembers();
          
          if(etodItems.size() == 0) {
            throw ETOD_GROUP + " has no members!";
          }

          // Check the metadata for all the relevant Items
          for each (var item in etodItems) {
            var verify = verifyMetadata(item.name);
            if(verify !== null) {
              throw verify + "\n" + EXPECTED;
            }
          }

          // Get the time Items for today
          var times = getTodayItems(etodItems);
          if(times === null){
            throw "No set of date times were found for today! Do you have a default set of date times?";
          }
          
          // Update the Items to today
          moveTimes(times);

          // Schedule a timer to wait for all the Items to update before creating the Timers.
          this.timers.check("ephem_tod_rule", 
                            "30s", 
                            createTimersGenerator(times, this.timers), 
                            true, 
                            function() { logger.info("Flapping timer, waiting before creating timers for time of day"); });

        }
    type: script.ScriptAction

Notice this one has an additional trigger for around 1:45 pm.