We have reached the end of the series. This post is going to focus on the Time of Day Rule.
Background
When I first wrote the Time of Day Rule in Rules DSL, I wrote it to make it as self contained as possible and easy for users to copy and paste and edit it to make it work for their environment. But with Python I can write this with a different way of use in mind.
Python Library
The Helper Library has a community section where the community can contribute reusable Rules. By reusable I mean you can use them without copy/paste/editing them. You just copy them to your environment, configure your Items properly, and use the code without ever needing to look at the code. When I can move to a Rule Template, you won’t even need to conform to my Item/Group names and instead configure the Rule to use your Items and Groups.
How it works
This version of Time of Day is drastically different from the Design Pattern version. Instead of triggering the Rule every time there is a time change event and calculating which time period you are in, this Rule sets Timers and uses metadata so you can define all of your time of day states and times when each starts in your Items instead of in the Rule.
However, it supports all the same features as the Time of Day Rules DSL Rule in the design pattern. You can have as many times of day as you want, those times can change on a day by day basis (e.g. Astro times) but unlike the Rules DSL version, in this version you don’t need an event from a binding or a hard coded cron trigger to drive the Rule. You only need DateTime Items populated from any binding or in any way.
Initialization
One of the gotchas with openHAB is there is no way to create an Item with an initial state. Something outside of the Item definition needs to populate it with that first state. That isn’t very usable for us in this case. I thought of a lot of approaches and decided using metadata on the Item and an initialization function as part of the library is the easiest from the user’s perspective.
When the Rule runs, it will loop through all of the members of TimeOfDay_StartTimes and if any of them are UnDefType we try to initialize their state using the Item’s metadata. This gives you a way to create and populate a DateTime Item with a statically defined time of day.
Metadata
This requires up to two metadata entries. In a .items file it will look like:
{ Tod="init"[start_time="HH:MM:SS:MS", tod_state="EXAMPLE"] }
where
- HH is hours of the day, assuming 24 hour clock
- MM is minutes of the hour
- SS is seconds of the minute and optional
- MS is milliseconds of the second and optional
- “EXAMPLE” is the name of the state (e.g. DAY, MORNING, etc.)
The Rule
The Rule triggers at System started, a little after midnight (so Astro has time to calculate the new day’s solar event times, or when any member of TimeOfDay_StartTimes changes.
The first thing done is the initialization described above. I do this every time the Rule executes because the user can add or remove Items from the Group at any time and the Rule will not know it. This ensures that all Items get initialized before they are used the first time. It fails fast so it doesn’t nothing if there are no Items to initialize.
Next the Rule cancels all existing Timers. We don’t want any orphaned Timers floating around.
Then we loop through all members of TimeOfDay_StartTimes and:
- update the Item if it still has the DateTime for yesterday
- keep track of which time occurred most recently before now
- create a Timer for those Items that have a time in the future, the Timer body sends a command to vTimeOfDay with the state we extract from the Item’s metadata
Finally we sendCommand the state associated with the most recent Item before now (if it’s different from the vTimeOfDay current state), which is the current state.
Code
Items
// Time of Day
String vTimeOfDay "Current Time of Day [MAP(weather.map):%s]"
<tod>
Group TimeOfDay_StartTimes
DateTime vBed_Time "Bed [%1$tH:%1$tM]"
<bedroom_blue> (TimeOfDay_StartTimes)
{ ToD="init"[start_time="00:01",tod_state="BED"] }
DateTime vMorning_Time "Morning [%1$tH:%1$tM]"
<sunrise> (TimeOfDay_StartTimes)
{ ToD="init"[start_time="06:00",tod_state="MORNING"] }
DateTime vDay_Time "Day [%1$tH:%1$tM]"
<sun> (TimeOfDay_StartTimes)
{ channel="astro:sun:local:rise#start",
ToD="init"[tod_state="DAY"] }
DateTime vAfternoon_Time "Afternoon [ %1$tH:%1$tM]"
<sunset> (TimeOfDay_StartTimes)
{ channel="astro:sun:set120:set#start",
ToD="init"[tod_state="EVENING"] }
DateTime vEvening_Time "Evening [%1$tH:%1$tM]"
<sunset> (TimeOfDay_StartTimes)
{ channel="astro:sun:local:set#start",
ToD="init"[tod_state="AFTERNOON"] }
DateTime vNight_Time "Night [%1$tH:%1$tM]"
<moon> (TimeOfDay_StartTimes)
{ ToD="init"[start_time="23:00", tod_state="NIGHT"] }
All Items need a tod_state metadata entry. The static Items need a start_time entry.
Rule
from core.rules import rule
from core.triggers import when
from org.joda.time import DateTime
from core.utils import sendCommandCheckFirst
from core.metadata import get_key_value
from time import sleep
from core.actions import ScriptExecution
from configuration import tod_group, tod_item
from core.log import log_traceback
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.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(tod_group).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.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))
events.sendCommand(tod_item, 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 {} 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 triggeres 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()
# Creates 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): 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.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
new_tod.log.info("Time of day is now {}".format(most_recent_state))
sendCommandCheckFirst(tod_item, most_recent_state)
def scriptUnloaded():
clear_timers()
Edits:
- Fixed bugs, in particular a problem with the way I was creating the lambda
- Made it so the name of the Group and Item are defined in configuration.py
TODO:
- Add ability to have different sets of time for different types of days
- Overrides/temporary states (e.g. Party Mode)
- Date picker UI to adjust the start times manually (webview?)
- Convert to a Rule Template
The code above is minimally tested. If you see anything off or have ideas for improvements, especially changes that would make this a good candidate for the Community library, please let me know. @CrazyIvan359 and @5iver I particularly would like your reviews.
This ends our journey. I hope there are enough examples here to help a number of you take the same journey.
Previous: Journey to JSR223 Python 8 of 9