Journey to JSR223 Python 9 of 9

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()