Journey to JSR223 Python 9 of 9

My head is lost in other things right now, so I’ll circle back with more feedback. But one thing that stands out is that you should cancel your timers when you reload the script. Do this in a scriptUnloaded, which is a special function that the org.openhab.automation.script.ScriptEngineManagerImpl.java. There’s also scriptLoaded too. There are references to these in the docs. It will be added to a timer example, but it doesn’t fit well for the power management examples. Sometimes it makes sense to kill a timer when a script reloads and sometimes it’s better to let it keep running. In your ToD rule, I think they should be stopped.

I had already added a ToD example in the repo too. This supports both cron times and Channels. Instead of metadata, I used a dict in the configuration module, where users can modify the times of the changes. In my example, I call the Item Mode instead of vTimeOfDay, since you can have modes that are not times, like Party and Security. The rule includes functionality to forgo the automated changes when the mode has been changed to one of these. For example, when the mode is Party, there are special lighting levels. Otherwise, the lights would dim when Mode changed to Night.

I use Mode in this automation script.

That’s a useful tip. I have other places where cancelling timers and doing other activities at unload.

I missed those I suppose. Part of the problem is the docs are great references but to use a reference you need to know what to look up. Something to ponder when it’s time to write migration docs.

I saw the example but I needed something that would work with:

  1. Astro Channels
  2. statically defined times
  3. Items that get their time from other sources (e.g. caldav)
  4. can handle overlaps and skips (e.g. in my case timer if year when sun rise is before 06:00)
  5. can be set up and used to support arbitrary time periods without modifying any rules code, as a library should

I thought about using a dict in configuration or a separate config file or something like that but disliked having stuff defined in more than one location. I also considered encoding the time and state name in the Item name but that seemed clunkier than using metadata.

It may seem like it but I’m not trying to metadata all the things.

When using scriptLoaded and scriptUnloaded you should define them with (*args) for safety, I’ve had issues where an argument is passed but the function doesn’t have any arguments. The * tells python to accept any number of unnamed arguments, we don’t actually want them anyway.

def scriptLoaded(*args):
    pass

def scriptUnloaded(*args):
    pass

You have this import twice. Also now might be the time to start weening people off of Joda and on to Java time. The usage is quite similar for what we do in openHAB.


I have a preference for list comprehension, I find it easier to read and I dislike lambdas. What you are doing works and there is nothing wrong with it though.

for start_time in [item for item in ir.getItem("TimeOfDay_StartTime").members if isinstance(item.state, UnDefType)]:

Might also be more adaptable if you pulled ToD item and group names from configuration.py values, that way the user could specify them. Just make sure you run the item names you pull through core.utils.validate_item and check if it’s None in case the item doesn’t exist.


I’m not familiar with Scott’s ToD script in the repo, so I can’t comment on comparability there.

scriptLoaded should be created with one parameter. When it is invoked, the full path of the script is passed as the argument. scriptUnloaded should be created without parameters, as nothing is passed as an argument when it is invoked. You could use *args, but it’s not necessary.

Thanks for doing these @rlkoshak. They’ve been very helpful in my ongoing project of converting my DSL rules.! :+1::smiley:

1 Like

Yes, I need to clean up the imports. I still have the “old” implementation in the file as a backup so the imports are a mess. That’s first on my list to change.

But Java Time can’t be used with openHAB Timers. If we are going to push the use of openHAB Timers as the preferred way to create Timers we are stuck with Joda until createTimer accepts Java. I’d love to drop the use of Joda, but can’t do so until OH no longer requires it.

This is going to become a huge problem I predict for non-coding users. It already causes lots of confusion for users that in Rules DSL we can do:

createTimer(now.plusMinuts(1), [ | blah blah blah])

or

createTimer(now,plusMinutes(1)) [ | blah blah blah ]

The difference between list comprehension and calling filter is a much much larger difference. New users are going to have a hard time with this. Those used to one way will find the other way all but incomprehensible.

I choose to use the functions because it maps to the Rules DSL way of doing List operations more intuitively than list comprehension. Left on my own, I would probably prefer list comprehension as well. But as with everything I post, I’m very aware of and tailor the code to the audience, and the stated audience for this series of posts are those users looking to migrate from Rules DSL.

That’s an idea. I really only need to have them define the Group that way. Then the Items become self contained. And that would be closer to how it would work as a Rule Template.

My ultimate goal is this is a stepping stone to a Rule template that users just download (from some Rules marketplace), configures (see Kai’s talk on the next gen rules engine) and it just runs. So this does push me in some directions that may be a little sub-optimal from a pure JSR223 Python perspective. New users are going to be using the UI generated Rules so my end goal is to be able to support those users.

I’m also looking to build something that is feature complete with the version of the Rule in the DP post. I am aware that I am currently missing a way to support multiple simultaneous states quite as easily as can be done with the original version. I was going to work on it over my lunch break but diggers have cut the cable to my neighborhood so I’ve no remote access (eta for the fix is after 11pm tonight).

EDIT: RE Joda, maybe this would be something that could be supported by the Helper Library. A series of functions that take Java Time, and Python datetime and centralizes the creation of the Joda time that createTimer needs. Then when joda goes away it’s less of an impact to those using the Helper Library. They’s just be a couple of two-three line functions.

That makes sense, I’m thinking from a Python perspective because I’m used to Python.


Hence my suggestion, if the rule is already pulling the ToD item name and group from a config then changing the source of those names has an extremely minimal effect on the code. I’m on my phone right now so I can’t really write an example, but look at my PR for Eos in the config.py file there is a function that pulls values from configuration.py safely.


See core.date.py. No function to spawn a timer, but there are conversion functions.

EDIT: I figured it out with some Google-fu. https://docs.python.org/3/faq/programming.html#why-do-lambdas-defined-in-a-loop-with-different-values-all-return-the-same-result. It as a simple fix.

I’ve updated the OP with a working version. Now to start working on improvements.


OK, there is clearly something I’m missing with how Python works. It appears that every time through the loop, instead of creating a new lambda to go with my timer, it overwrites the existing lambda for all the Timers.

For example, Let’s say I have Items in the following order in the Group:

  • MORNING
  • DAY
  • AFTERNOON
  • EVENING
  • NIGHT
  • BED

Since BED happens after midnight, it is actually the first event of the day, but it’s the last Item processed by this Rule.

When I trigger the Rule I’ve tried having the lambda just call events.sendCommand() directly, I’ve tried passing the Item itself to the state, just implement a log statement, and a number of other things. The Timer always goes off at the right time but it always uses BED instead of the state that was passed to it in the lambda when the Timer was created.

Does Python pass by reference? I’ve scoped the state variable to inside the for loop so shouldn’t it create a new variable each time through the loop? Is there a way I can force it to do that? What appears to be happening is the state variable is not being recreated each time through the loop but being reused and then the variable is passed to the lambda by reference so by the time the timers go off, state has been reset to be what ever state was the last time through the loop.

I even tried to call str(state) before passing it in the lambda function in the hopes that a new variable and therefore reference would be created.

Here is the latest version of the code. The problem code is the for start_time in loop in the new_tod function.

from core.rules import rule
from core.triggers import when
from org.joda.time import DateTime
from core.utils import sendCommandCheckFirst, postUpdateCheckFirst
from core.metadata import get_key_value
from time import sleep
from core.actions import ScriptExecution
from personal.util import send_info
from core.log import log_traceback

tod_timers = {}

def tod_init(log):
    """
    Initializes any member of TimeOfDay_StartTimes with the time in it's metadata if it's state is
    UnDefType. This depends upon the rule loading delay from the Helper Libraries to ensure that 
    restoreOnStartup and Astro have finished populating the Items prior to this function executing.
    """

    log.info("About to initialize Time of Day Items")
    now = DateTime.now()

    # loop through the undefined Items
    init_done = False
    for start_time in filter(lambda t: isinstance(t.state, UnDefType), ir.getItem("TimeOfDay_StartTimes").members):
        # extract the initialization string from metadata
        t = get_key_value(start_time.name, "ToD", "start_time")

        # Log an error if there is no such metadata
        if t is None: log.error("{} is uninitialized and doesn't have metadata!".format(start_time.name))

        # Parse the init string to HH:MM:SS:MS (SS:MS are both optional)
        else:
            time_parts = t.split(':')
            num_parts = len(time_parts)
            if num_parts < 2: log.error("{} is malformed metadata to initialize {}".format(t, start_time.name))
            else: 
                events.postUpdate(start_time, str(now.withTime(int(time_parts[0]),
                                                               int(time_parts[1]),
                                                               int(time_parts[2]) if num_parts > 2 else 0,
                                                               int(time_parts[3]) if num_parts > 3 else 0)))
                init_done = True

    if init_done: sleep(0.2) # give the Items a chance to update

    log.info("Done initializing Time of Day")

@log_traceback
def clear_timers():
    # Clear out all the existing timers
    for name, timer in tod_timers.items():
        new_tod.log.info("Cleaning out timer for {}.".format(name))
        if timer is not None and not timer.hasTerminated(): timer.cancel()
        del tod_timers[name]

@log_traceback 
def tod_transition(state_item, log):
    log.info("Transitioning to {} start time Item.".format(state_item.name))
    state = get_key_value(state_item.name, "ToD", "tod_state")
    # If there is no state we can't use this Item
    if state is None: new_tod.log.error("{} does not have tod_state metadata!".format(state_time.name))
    else: 
        log.info("Extracted {} from {} for state.".format(state, state_item.name))
        send_info("Transitioning time of day from {} to {}.".format(items["vTimeOfDay"],state),log)
        events.sendCommand("vTimeOfDay", state)

@rule("New Time of Day", description="Generic implementation state machine driven by time", tags=["weather"])
@when("System started")
@when("Time cron 0 1 0 * * ? *")
@when("Member of TimeOfDay_StartTimes changed")
def new_tod(event):
    """
    Time of Day: A time based state machine which commands a state Item (vTimeOfDay) with the current
    time of day as a String.
    
    To define the state machine, create a DateTime Item which will be populated with the start times 
    for each of the time of day states. Each of these Items must be made members of the 
    TimeOfDay_StartTimes Group.
    
    The values stored in the Items can come from anywhere but the two most common will be from Astro
    or statically defined. For Astro, just link the Item to the appropriate Astro Channel. For 
    statically defined the time is defined and the Item initialized through metadata. 
    
    The expected metadata is : Tod="init"[start_time="HH:MM:SS:MS", tod_state="EXAMPLE"] where
        - HH is hours
        - MM is minutes 
        - SS is seconds and optional
        - MS is milliseconds and optional
        - EXAMPLE is the State String
    
    All members of TimeOfDay_StartTimes are required to have a tod_state metadata entry. Only static
    Items require the start_time metadata entry.
    
    This Rule triggeres at system started, one minute after midnight, and if any member of 
    TimeOfDay_StartTimes changes. When the Rule triggers, it creates timers to go off at the 
    indicated times.
    """
    tod_init(new_tod.log) 

    now = DateTime.now()

    clear_timers()

    # Creates timers for all the members of TimeOfDay_StartTimes
    most_recent_time = now.minusDays(1)
    most_recent_state = str(items["vTimeOfDay"])
    for start_time in ir.getItem("TimeOfDay_StartTimes").members:

        item_time = DateTime(str(start_time.state))
        trigger_time = now.withTime(item_time.getHourOfDay(), 
                                    item_time.getMinuteOfHour(),
                                    item_time.getSecondOfMinute(),
                                    item_time.getMillisOfSecond())

        # Update the Item if it's still got yesterday's date
        if item_time.isBefore(trigger_time): events.postUpdate(start_time, str(trigger_time))

        state = get_key_value(start_time.name, "ToD", "tod_state")
        # If there is no state we can't use this Item
        if state is None: new_tod.log.error("{} does not have tod_state metadata!".format(start_time.name))

        # If we have already passed this time, keep track of the most recent time and state
        elif trigger_time.isBefore(now) and trigger_time.isAfter(most_recent_time): 
            most_recent_time = trigger_time
            most_recent_state = state
            new_tod.log.info("The most recent state is now {}".format(state))

        # Create future timers
        elif trigger_time.isAfter(now):
            new_tod.log.info("Setting timer for Item {}, Time {}, and State {}.".format(start_time.name, trigger_time, state)) 
            tod_timers[start_time.name] = ScriptExecution.createTimer(trigger_time, lambda: tod_transition(start_time, new_tod.log))

        else:
            new_tod.log.info("{} is in the past but there is a more recent state".format(state))

    # Command the time of day to the current state
    new_tod.log.info("Time of day is now {}".format(most_recent_state))
    sendCommandCheckFirst("vTimeOfDay", most_recent_state)

def scriptUnloaded():
    clear_timers()

Any advice is most welcome.

1 Like

Sorry Rich, saw this one while I was at work and forgot to come back and look for a solution. Glad you found it though, it had me baffled too! And now I need to use the same concept for the motion decay feature in Eos.

Hi!

I have started to rewrite all my DSL rules now. This has been really helpful @rlkoshak , so thank you for that. My time is very limited due to life and small kids, but I have at least started and it looks good.

Regards, S

1 Like

Hey @rlkoshak did you get this ToD finished? I am looking to integrate it into my setup.

It’s mostly finished. It does time if day but I’m waiting for Ephemeris support in JSR223 so I can allow for different tods based on the type of day (e.g weekend) before I submit it to the helper libraries.

The latest version is posted in Design Pattern: Time Of Day, this I don’t think there is any significant difference between the above and that version.

1 Like

Awesome! I’ll be giving it a whirl sometime this week.
It’s much cleaner than my cludgy DSL version I made way back when.

Seems to work so far. I’ll let you know if I find anything.

I made a few minor changes a reduced the amount of logging it does to info. You don’t have to include those changes, thats just my preference.
I also added a delay to the beginning of tod_init because it would end up calling itself on startup every time it would update an item. That delay might need to be extended to support slower platforms like RPi though, I’m running OH on desktop hardware with an i5 so it’s pretty fast.

from core.rules import rule
from core.triggers import when
from org.joda.time import DateTime
from core.utils import sendCommand, postUpdate, send_command_if_different, post_update_if_different
from core.log import log_traceback
from core.metadata import get_key_value
from time import sleep
from core.actions import ScriptExecution
from configuration import tod_group, tod_item


tod_timers = {}

def tod_init(log):
    """
    Initializes any member of tod_group with the time in it's metadata if it's
    state is UnDefType. This depends upon the rule loading delay from the Helper
    Libraries to ensure that restoreOnStartup and Astro have finished populating
    the Items prior to this function executing.
    """

    log.debug("Initializing Time of Day Items")
    now = DateTime.now()
    sleep(0.2) # prevent repeat runs

    # Loop through the undefined Items.
    init_done = False
    for start_time in [t for t in ir.getItem(tod_group).members
                       if isinstance(t.state, UnDefType)]:
        # extract the initialization string from metadata.
        t = get_key_value(start_time.name, "ToD", "start_time")

        # Log an error if there is no such metadata.
        if t is None: log.error("{} is uninitialized and doesn't have metadata!"
                                .format(start_time.name))

        # Parse the init string to HH:MM:SS:MS (SS:MS are both optional).
        else:
            time_parts = t.split(':')
            num_parts = len(time_parts)
            if num_parts < 2: log.error("{} is malformed metadata to initialize"
                                        " {}".format(t, start_time.name))
            else:
                postUpdate(start_time,
                            str(now.withTime(int(time_parts[0]),
                                int(time_parts[1]),
                                int(time_parts[2]) if num_parts > 2 else 0,
                                int(time_parts[3]) if num_parts > 3 else 0)))
                init_done = True

    if init_done:
        sleep(0.2) # Give the Items a chance to update.
        log.info("Time of Day start times initialized")

@log_traceback
def clear_timers():
    # Clear out all the existing timers.
    for name, timer in tod_timers.items():
        new_tod.log.debug("Cleaning out timer for {}.".format(name))
        if timer is not None and not timer.hasTerminated():
            timer.cancel()
        del tod_timers[name]

@log_traceback
def tod_transition(state, log):
    log.info("Transitioning time of day from '{}' to '{}'."
             .format(items[tod_item],state))
    sendCommand(tod_item, state)

@rule("New Time of Day",
      description="Generic implementation state machine driven by time",
      tags=["designpattern"])
@when("System started")
@when("Time cron 0 1 0 * * ? *")
@when("Member of {} changed".format(tod_group))
def new_tod(event):
    """
    Time of Day: A time based state machine which commands a state Item
    (tod_item in configuration) with the current time of day as a String.

    To define the state machine, create a DateTime Item which will be populated
    with the start times for each of the time of day states. Each of these Items
    must be made members of the tod_group Group.

    The values stored in the Items can come from anywhere but the two most
    common will be from Astro or statically defined. For Astro, just link the
    Item to the appropriate Astro Channel. For statically defined the time is
    defined and the Item initialized through metadata.

    The expected metadata is : Tod="init"[start_time="HH:MM:SS:MS", tod_state="EXAMPLE"]
    where
        - HH is hours
        - MM is minutes
        - SS is seconds and optional
        - MS is milliseconds and optional
        - EXAMPLE is the State String

    All members of tod_group are required to have a tod_state metadata entry.
    Only static Items require the start_time metadata entry.

    This Rule triggers at system started, one minute after midnight, and if any
    member of tod_group changes. When the Rule triggers, it creates timers to go
    off at the indicated times.
    """
    tod_init(new_tod.log)
    now = DateTime.now()
    clear_timers()

    # Create timers for all the members of tod_group.
    most_recent_time = now.minusDays(1)
    most_recent_state = str(items[tod_item])
    for start_time in ir.getItem(tod_group).members:

        item_time = DateTime(str(start_time.state))
        trigger_time = now.withTime(item_time.getHourOfDay(),
                                    item_time.getMinuteOfHour(),
                                    item_time.getSecondOfMinute(),
                                    item_time.getMillisOfSecond())

        # Update the Item if it's still got yesterday's date.
        if item_time.isBefore(trigger_time):
            postUpdate(start_time, str(trigger_time))

        state = get_key_value(start_time.name, "ToD", "tod_state")
        # If there is no state we can't use this Item.
        if state is None:
            new_tod.log.error("{} does not have tod_state metadata!"
                              .format(start_time.name))

        # If we have already passed this time, keep track of the most recent
        # time and state.
        elif (trigger_time.isBefore(now)
                and trigger_time.isAfter(most_recent_time)):
            most_recent_time = trigger_time
            most_recent_state = state
            new_tod.log.debug("The most recent state is now {}".format(state))

        # Create future timers
        elif trigger_time.isAfter(now):
            new_tod.log.debug("Setting timer for Item {}, Time {}, and State {}"
                              ".".format(start_time.name, trigger_time, state))
            tod_timers[start_time.name] = ScriptExecution.createTimer(
                                            trigger_time,
                                            lambda s=state: tod_transition(s,
                                                                   new_tod.log))

        else:
            new_tod.log.debug("{} is in the past but there is a more recent "
                              "state".format(state))

    # Command the time of day to the current state
    if str(items[tod_item]) != most_recent_state:
        new_tod.log.info("Time of day is now '{}'".format(most_recent_state))
    sendCommand(tod_item, most_recent_state)

def scriptUnloaded():
    clear_timers()

I plan on moving them to debug when I submit it as a Pr.

Can you describe what’s happening a bit more. Either I’m not seeing the same behavior or I don’t understand.

I have 4 ToD start time items, all with metadata times. When the script loads tod_init runs and updates each item. Each time it updates an item the new_tod rule is run and calls tod_init again. So when it loads tod_init runs 5 or 6 times. With the delay, the first call updates the items and the other calls find all items initialized so don’t update anything or publish log messages.

Ah OK, now I understand. I’ll look into that some. The delay will work but there may be something that requires less fiddling with that can address this for all platforms. Like you say, it may take a longer sleep on SBCs or the like. If I can prevent it universally that would be better.

I suspect I’m not seeing it because I use restoreOnStartup on these Items so I’ve never had more than one or two to update at a time. I probably just missed this behavior. Thanks for testing it out!

I’m intentionally not using restore on startup because I want them to be recalculated. I didn’t have time on the weekend but I intend to look into a different way to init it all. I also don’t like that it doesn’t check if the time in metadata is the same as the item state, so you can’t change the times if you use restore either.

That was also something on my list of things to add before the PR. I did encounter that one as I was testing. I had to update the Item to UNDEF through the REST API to force it to reread the metadata. I just haven’t gotten around to it yet.

My thoughts were to split the triggers and have init run on startup and periodically to check item states vs metadata and update of different. Then have the recalc function keep its midnight and time changed triggers.

Something else that just came to mind: if you add an item at runtime a trigger won’t get added for it. To get around that with Eos I generate the when triggers dynamically at load time and have a reload switch item to avoid having to restart OH. That might be overkill for something like this though.