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.