[OH3] jython Mode (Time of day) helper library

As many of you I’m working on migrating to OpenHAB3. In my production OpenHAB2 instance I use the helper libraries (jython) for some of my rules. I’ve managed to get the helper libraries and jython plug-in working under OpenHAB3.

I’m now trying to get the jython Mode helper library working (openhab-helper-libraries/update_mode.py at master · openhab-scripters/openhab-helper-libraries · GitHub). I know that I need to adjust the Joda time but I was wondering if somebody else already has this working under OpenHAB3?

I’m in particular interested in how to setup the Interval without Joda. It’s this part:


        if mode != last_mode_of_day:
            if event is None and MODE_CONFIGURATION[day_of_week][mode].get("hour") is not None and Interval(
                DateTime.now().withTime(
                    value["hour"],
                    value["minute"],
                    value["second"],
                    0
                ),
                DateTime.now().withTime(

I considered the JS version of the time of day which works but would prefer the additional configuration options of the jython version (having a channel and a time per mode).

If anybody has a tip how to convert this to zoneddattime or already has a working version it would be highly appreciated!

1 Like

Are you using my updated libraries? Ivan’s Helper Libraries - OH3, Python, JavaScript

I have not done any work on the community scripts, but a PR would be welcome if you get this working. The information you need can be found in the Java documentation for ZonedDateTime.

Yes, I’m using the updated libraries, thanks for that. I’ll try to get it working although I’m more of a copy/paste developer normally…

Ok, I made some changes to update_mode.py and it seems to work. Next to importing ZonedDateTime I only had to adjust the IF statement with the Interval part:

if event is None and MODE_CONFIGURATION[day_of_week][mode].get("hour") is not None and ZonedDateTime.now().isAfter(ZonedDateTime.now().withHour(value["hour"]).withMinute(value["minute"]).withSecond(value["second"])) and ZonedDateTime.now().isBefore(ZonedDateTime.now().withHour(MODE_CONFIGURATION[day_of_week].items()[i + 1][1].get("hour", value["hour"])).withMinute(MODE_CONFIGURATION[day_of_week].items()[i + 1][1].get("minute", value["minute"] + 1)).withSecond(MODE_CONFIGURATION[day_of_week].items()[i + 1][1].get("second", value["second"]))):

Not sure if this is in line with proper coding standards but at least it works for now. I can continue until a better solution comes along.

That’s great, would you be interested in submitting a PR to update that script in the Helper Libraries?

Hey,
I am currently facing the same problems.
After updating to OH3, I quickly got jython working again thanks to @CrazyIvan359 .
When I rewrote my rules in OH2.5 jython, I took in many places @rlkoshak libs to help (which are great!).

Unfortunately, a lot of this is also based on Joda time. And I haven’t really figured out how to convert joda time to java. So now after conversion to oh3 many rules are not executable.

Do you have a hint for me?

Cheers!
Chris

Get the helper libraries from GitHub - CrazyIvan359/openhab-helper-libraries: JSR223-Jython scripts and modules for use with openHAB. For my libraries you’ll either have to wait a bit or look into them yourself. It’s on my list to bring the Python libs up to where they will work in OH 3 but there are a lot of things in front of doing that right now so it will take me some time.

You can look at the JavaScript version of the library which were rewritten for OH 3 support and see what’s different in terms of dealing with DateTimes to get you started. If the only problem is ZonedDateTime then you pretty much only need to look in timeUtils I think to update/add support for ZonedDateTime.

Also, IIRC someone submitted a PR to the timer_utils.py library that will let you give it an argument to cause it to return a ZonedDateTime instead of a Joda DateTime. But the stuff that uses it (e.g. timer_mgr) needs the minor change to update to add that argument to get the right type.

My intent was to add a createTimer method to timer_utils that can accept just about anything and schedule the Timer as appropriate but never got to that. And a lot of what is in those libraries I’d like to make a part of the Helper Libraries in the first place so I’ve not touched it too much until we’re ready to do that.

Anyway, tl;dr is the fixes are pretty easy but I don’t have time to make them right now. But you probably could do so pretty easily, at least in a temporary way until I get to updating the library overall.

1 Like

Hey Rich,

thanks for the quick and detailed answer.

The creatTimer method sounds really good.

Currently I’m having a bit of a struggle converting timer_utils and timer_mgr. But I will try to build a workaraound.

Thanks again, looking forward to an update from you in this area. Always very great help!

Cheers!
Chris

I’ll have to look into how to create a PR. I do have a GitHub account but never actually created a PR. I guess there’s a first for everything!

Hi all,
I too had the need to get time_utils.py and ephem_tod.py working with OH3.
I did the changes which were needed to my best knowledge, but there may be still bugs.
It works fine for my usecases and the tests from time_utils-test.py (which also needed some adaptions) pass.

I am adding the files here to the post - hopefully they can save someone a bit of work.
All the lines I changed are marked with comments at the end.

I am using @CrazyIvan359 Python stubs (see this post) and I highly recommend them. If you don’t want to use them, delete the lines starting with # improve typing and linting... up to the next empty line.

time_utils.py

"""
Copyright June 25, 2020 Richard Koshak

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import re
import traceback
from datetime import datetime, date, time, timedelta
from core.log import logging, LOG_PREFIX
from core.date import to_python_datetime, to_java_zoneddatetime
from core.jsr223 import scope
from java.time import ZonedDateTime
from java.time.temporal import ChronoUnit

# improve typing and linting as per
# https://github.com/CrazyIvan359/openhab-stubs/blob/master/Usage.md
import typing as t
if t.TYPE_CHECKING:
    basestring = str
    unicode = str
else:
    basestring = basestring  # type: ignore # pylint: disable=self-assigning-variable
    unicode = unicode  # type: ignore # pylint: disable=self-assigning-variable


duration_regex = re.compile(r'^((?P<days>[\.\d]+?)d)? *((?P<hours>[\.\d]+?)h)? *((?P<minutes>[\.\d]+?)m)? *((?P<seconds>[\.\d]+?)s)?$')
iso8601_regex = re.compile(r'^(-?(?:[1-9][0-9]*)?[0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9])T(2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9])(\.[0-9]+)?(Z|[+-](?:2[0-3]|[01][0-9]):[0-5][0-9])?$')

def parse_duration(time_str, log=logging.getLogger("{}.time_utils".format(LOG_PREFIX))):
    """Parse a time string e.g. (2h13m) into a timedelta object
    https://stackoverflow.com/questions/4628122/how-to-construct-a-timedelta-object-from-a-simple-string

    Arguments:
        - time_str: A string identifying a duration. Use
            - d: days
            - h: hours
            - m: minutes
            - s: seconds
          All options are optional but at least one needs to be supplied. Float
          values are allowed (e.g. "1.5d" is the same as "1d12h"). Spaces
          between each field is allowed. Examples:
              - 1h 30m 45s
              - 1h05s
              - 55h 59m 12s
        - log: optional, logger object for logging a warning if the passed in
        string is not parsable. A "time_utils" logger will be used if not
        supplied.

    Returns:
        A ``datetime.timedelta`` object representing the supplied time duration
        or ``None`` if ``time_str`` cannot be parsed.
    """

    parts = duration_regex.match(time_str)
    if parts is None:
        log.warn("Could not parse any time information from '{}'. Examples "
                  "of valid strings: '8h', '2d8h5m20s', '2m 4s'"
                   .format(time_str))
        return None
    else:
        time_params = {name: float(param) for name, param in parts.groupdict().items() if param}
        return timedelta(**time_params)

def delta_to_datetime(td):
    """Takes a Python timedelta Object and converts it to a ZonedDateTime from now.

    Arguments:
        - td: The Python datetime.timedelta Object

    Returns:
        A ZonedDateTime td from now.
    """

    return (ZonedDateTime.now().plusDays(td.days)
            .plusSeconds(td.seconds)
            .plusNanos(td.microseconds//1000 * 1000000))


def parse_duration_to_datetime(time_str, log=logging.getLogger("{}.time_utils".format(LOG_PREFIX))):
    """Parses the passed in time string (see parse_duration) and returns a
    ZonedDateTime that amount of time from now.

    Arguments:
        - time_str: A string identifying a duration. See parse_duration above

    Returns:
        A ZonedDateTime time_str from now
    """

    return delta_to_datetime(parse_duration(time_str, log))

def is_iso8601(dt_str):
    """Returns True if dt_str conforms to ISO 8601
    Arguments:
        - dt_str: the String to check
    Returns:
        True if dt_str conforms to dt_str and False otherwise
    """

    try:
        if iso8601_regex.match(dt_str) is not None:
            return True
    except:
        pass
    return False

def to_datetime(when, log=logging.getLogger("{}.time_utils".format(LOG_PREFIX)), output = 'Java'):
    """Based on what type when is, converts when to a Python DateTime object.
    Type:
        - int: returns now.plusMillis(when)
        - openHAB number type: returns now.plusMillis(when.intValue())
        - ISO8601 string: DateTime(when)
        - Duration definition: see parse_duration_to_datetime
        - java ZonedDateTime
        For python make sure the datetime object is not assigned to a variable when this function is called)
        otherwise a java.time.sql object will be returned due to a bug in Jython
        - Python datetime
        - Python time: returns DateTime with today date and system timezone

    Arguments:
        - when: the Object to convert to a DateTime
        - log: optional logger, when not supplied one is created for logging errors
        - output: object returned as a string. If not specified returns a ZonedDateTime object
                  'Python': return datetime object
                  'Java': return a ZonedDateTime object

    Returns:
        - ZonedDateTime specified by when
        - datetime specified by when if output = 'Python'
        - ZonedDateTime specified by when if output = 'Java'
    """
    log.debug('when is: ' + str(when) + ' output is ' + str(output))

    dt_python = None
    dt_java = None

    try:
        if isinstance(when, (str, unicode)):
            if is_iso8601(when):
                log.debug('when is iso8601: '+str(when))
                dt_java = ZonedDateTime.parse(str(when))

            else:
                log.debug('when is duration: ' + str(when))
                dt_python = datetime.now() + parse_duration(when, log)

        elif isinstance(when, int):
            log.debug('when is int: ' + str(when))
            dt_java = ZonedDateTime.now().plus(when, ChronoUnit.MILLIS)

        elif isinstance(when, (scope.DateTimeType)):
            log.debug('when is DateTimeType: ' + str(when))
            dt_java = when.getZonedDateTime()

        elif isinstance(when, (scope.DecimalType, scope.PercentType, scope.QuantityType)):
            log.debug('when is decimal, percent or quantity type: ' + str(when))
            dt_python = datetime.now() + timedelta(milliseconds = when.intValue())

        elif isinstance(when, datetime):
            log.debug('when is datetime: ' + str(when))
            dt_python = when

        elif isinstance(when, ZonedDateTime):
            log.debug('when is ZonedDateTime: ' + str(when))
            dt_java = when

        elif isinstance(when, time):
            log.debug('when is python time object: ' + str(when))
            dt_java = ZonedDateTime.now() \
                            .withHour(when.hour) \
                            .withMinute(when.minute) \
                            .withSecond(when.second) \
                            .withNano(when.microsecond * 1000) # nanos need to be set, otherwise they_ll be taken from the actual time

        else:
            log.warn('When is an unknown type {}'.format(type(when)))
            return None

    except:
        log.error('Exception: {}'.format(traceback.format_exc()))

    if output == 'Python':
        log.debug('returning dt python')
        return dt_python if dt_python is not None else to_python_datetime(dt_java)
    elif output == 'Java':
        log.debug("returning dt java")
        return dt_java if dt_java is not None else to_java_zoneddatetime(dt_python)
    elif output == 'Joda':
        log.error("to_datetime trying to return dt joda - use output = 'Python' or output = 'Java' instead")
    else:
        log.error("to_datetime cannot output [{}]".format(output))

def to_today(when, log=logging.getLogger("{}.time_utils".format(LOG_PREFIX)), output='Java'):
    """Takes a when (see to_datetime) and updates the date to today.
    Arguments:
        - when : One of the types or formats supported by to_datetime
        - log: optional logger, when not supplied one is created for logging errors
    Returns:
        - ZonedDateTime specified by when with today's date.
        - datetime specified by when with today's date if output = 'Python'
        - ZonedDateTime specified by when with today's date if output = 'Java'
    """
    log.debug('output is: '+ str(output))

    if output == 'Python':
        dt = to_datetime(when, log=log, output = 'Python')
        return datetime.combine(date.today(), dt.timetz())
    elif output == 'Java':
        dt = to_datetime(when, log=log, output = 'Java')
        now = dt.now()
        return (now.withHour(dt.getHour())
                   .withMinute(dt.getMinute())
                   .withSecond(dt.getSecond())
                   .withNano(dt.getNano()))
    elif output == 'Joda':
        log.error("to_today trying to return dt joda - use output = 'Python' or output = 'Java' instead")
    else:
        log.error("to_today cannot output [{}]".format(output))

time_utils-tests.py

from datetime import datetime, time
import community.time_utils
reload(community.time_utils)
from community.time_utils import to_today, to_datetime
from core.date import days_between, seconds_between, to_python_datetime, to_joda_datetime, to_java_zoneddatetime
from core.log import logging, LOG_PREFIX
# from org.joda.time import DateTime # changed
from java.time import ZonedDateTime, ZoneId, ZoneOffset
from java.time.temporal import ChronoUnit

# improve typing and linting as per
# https://github.com/CrazyIvan359/openhab-stubs/blob/master/Usage.md
import typing as t
if t.TYPE_CHECKING:
    basestring = str
    unicode = str
else:
    basestring = basestring  # type: ignore # pylint: disable=self-assigning-variable
    unicode = unicode  # type: ignore # pylint: disable=self-assigning-variable


log = logging.getLogger("{}.TEST.time_utils".format(LOG_PREFIX))

#To_today tests

today_time = to_today(time(23, 00, 00, 00))
today_datetime = to_today(datetime(2019, 10, 8, 23, 00, 00, 00))
today_ZonedDateTime = to_today(ZonedDateTime.of(2019, 11, 8, 23, 00, 00, 00, ZoneId.systemDefault()))

try:
    log.info("start test to_today with different input and output Joda datetime")
    #Check date was changed
    assert days_between(ZonedDateTime.now(), today_time) == 0, "time object failed to change date for today"
    assert days_between(ZonedDateTime.now(), today_datetime) == 0, "datetime object failed to change date for today"
    assert days_between(ZonedDateTime.now(), today_ZonedDateTime) == 0, "ZonedDateTime object failed to change date for today"
    #Check time wasn't changed
    assert time(23, 00, 00, 00) == to_python_datetime(today_time).time(), "time object failed, time has changed"
    # assert to_joda_datetime(datetime(2019, 10, 8, 23, 00, 00)).toLocalTime() == today_datetime.toLocalTime(), "datetime object failed, time has changed" # changed
    # assert to_joda_datetime(ZonedDateTime.of(2019, 11, 8, 23, 00, 00, 00, ZoneId.systemDefault())).toLocalTime() == today_ZonedDateTime.toLocalTime() #changed


    log.info("start test to_today with different input and output python datetime")
    #Check date was changed
    #cannot store python datetime in variable due to jython bug
    assert days_between(ZonedDateTime.now(), \
                    to_today(time(23, 00, 00, 00), output= 'Python', log = log)) == 0, \
                    "time object failed to change date for today"
    assert days_between(ZonedDateTime.now(), \
                    to_today(datetime(2019, 10, 8, 23, 00, 00), output= 'Python', log = log)) == 0, \
                    "datetime object failed to change date for today"
    assert days_between(ZonedDateTime.now(), today_ZonedDateTime) == 0, \
                    "ZonedDateTime object failed to change date for today"
    #Check time wasn't changed
    assert time(22, 59, 59, 00) <= to_today(time(23,00,00, 00), output= 'Python', log = log).time() \
                    <= time(23, 00, 1, 00), \
                    "time object failed, time has changed"+str(today_time)
    assert datetime(2019, 10, 8, 23, 00, 00).time() \
                    == to_today(datetime(2019, 10, 8, 23, 00, 00), output= 'Python', log = log).time(), \
                    "datetime object failed, time has changed"
    assert to_python_datetime(ZonedDateTime.of(2019, 11, 8, 23, 00, 00, 00, ZoneId.systemDefault())).time() \
                    == to_today(ZonedDateTime.of(2019, 11, 8, 23, 00, 00, 00, ZoneId.systemDefault()), output= 'Python', log = log).time(), \
                    "ZonedDateTime object failed, time has changed"




    log.info("start test to_today with different input and output Java ZonedDateTime")

    today_time = to_today(time(23,00,00, 00), output='Java', log=log)
    today_datetime = to_today(datetime(2019, 10, 8, 23, 00, 00), output='Java', log=log)
    today_ZonedDateTime = to_today(ZonedDateTime.of(2019, 11, 8, 23, 00, 00, 00, ZoneId.systemDefault()), output='Java', log=log)

    #Check date was changed
    assert days_between(ZonedDateTime.now(), today_time) == 0, \
                "time object failed to change date for today"
    assert days_between(ZonedDateTime.now(), today_datetime) == 0, \
                "datetime object failed to change date for today"
    assert days_between(ZonedDateTime.now(), today_ZonedDateTime) == 0, \
                "ZonedDateTime object failed to change date for today"
    #Check time wasn't changed
    assert time(22, 59, 59, 500000) <= to_python_datetime(today_time).time() <= time(23, 00, 1, 00), \
                             "time object failed, time has changed"
    assert to_java_zoneddatetime(datetime(2019, 10, 8, 22, 59, 59,0)).toLocalTime() \
                    <= today_datetime.toLocalTime() <= \
                    to_java_zoneddatetime(datetime(2019, 10, 8, 23, 00, 1, 00)).toLocalTime(), \
                    "datetime object failed, time has changed {} {}" \
                    .format(str(to_java_zoneddatetime(datetime(2019, 10, 8, 23, 00, 00)).toLocalTime()), \
                    str(today_datetime.toLocalTime()))
    assert ZonedDateTime.of(2019, 11, 8, 22, 59, 59, 500000, ZoneId.systemDefault()).toLocalTime() \
                    <= today_ZonedDateTime.toLocalTime()<= \
                    ZonedDateTime.of(2019, 11, 8, 23, 00, 1, 00, ZoneId.systemDefault()).toLocalTime() \
                    , "ZonedDateTime object failed, time has changed {} {}" \
                    .format(str(ZonedDateTime.of(2019, 11, 8, 23, 00, 00, 00, ZoneId.systemDefault()).toLocalTime()), \
                    str(today_ZonedDateTime.toLocalTime()))

    #Test other format
    test_dict={'integer: ': int(5000),
               'duration: ': "5s",
               'Decimal type: ': DecimalType(5000),
               #'Percent type: ': PercentType(100),
               'Quantity Type: ': QuantityType('5000ms'),
               'ISO 8601 format': ZonedDateTime.now(ZoneOffset.ofHours(2)).plusSeconds(5).toString() # changed
               }
    #Test other format to Java # changed - used to be "to Joda..."
    for keys in test_dict:
        log.info("Checking " + keys + " to Java DateTime") # changed
        assert abs(seconds_between(ZonedDateTime.now().plus(5000, ChronoUnit.MILLIS),
                               to_datetime(test_dict[keys], log = log))) < 1, \
                               "failed to return a datetime with offset of {} from {}" \
                               .format(str(test_dict[keys]),str(keys))


    #Test other format to python
    test_dict['ISO 8601 format'] = ZonedDateTime.now(ZoneOffset.ofHours(2)).plusSeconds(5).toString() # changed
    for keys in test_dict:
        log.info("Checking " + keys +" to Python datetime")
        assert abs(seconds_between(ZonedDateTime.now().plus(5000, ChronoUnit.MILLIS),
                               to_datetime(test_dict[keys], output='Python', log = log))) < 1, \
                               "failed to return a datetime with offset of {} from {}" \
                               .format(str(test_dict[keys]),str(keys))

    #Test other format to Java
    test_dict['ISO 8601 format'] = ZonedDateTime.now(ZoneOffset.ofHours(2)).plusSeconds(5).toString() # changed
    for keys in test_dict:
        log.info("Checking " + keys +" to Java ZonedDateTime")
        assert abs(seconds_between(ZonedDateTime.now().plus(5000, ChronoUnit.MILLIS),
                               to_datetime(test_dict[keys], output='Java', log = log))) < 1, \
                               "failed to return a ZonedDateTime with offset of {} from {}" \
                               .format(str(test_dict[keys]),str(keys))


except AssertionError:
    import traceback
    log.error("Exception: {}".format(traceback.format_exc()))

else:
    log.info("Test passed!")

ephem_tod.py

"""
Copyright June 30, 2020 Richard Koshak

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
from core.metadata import get_metadata, get_key_value, get_value
from core.actions import Ephemeris
from core.utils import send_command_if_different
from core.log import log_traceback, logging, LOG_PREFIX
from java.time import ZonedDateTime
from community.time_utils import to_today, to_datetime
from community.timer_mgr import TimerMgr
from community.rules_utils import create_simple_rule, delete_rule, load_rule_with_metadata

# improve typing and linting as per
# https://github.com/CrazyIvan359/openhab-stubs/blob/master/Usage.md
import typing as t
if t.TYPE_CHECKING:  # imports used only for type hints
    from core.jsr223.scope import events, items, UnDefType, DateTimeType


# Name of the Item to trigger reloading of the time of day rule.
ETOD_RELOAD_ITEM = "Reload_ETOD"

# Create the time of day state Item if it doesn't exist.
ETOD_ITEM = "TimeOfDay"
if ETOD_ITEM not in items:
    from core.items import add_item
    add_item(ETOD_ITEM, item_type="String")

# Metadata name space.
NAMESPACE = "etod"

# Timers that run at time of day transitions.
timers = TimerMgr()

# Logger to use before
log = logging.getLogger("{}.Ephemeris Time of Day".format(LOG_PREFIX))

@log_traceback
def check_config(i, log):
    """Verifies that all the required elements are present for an etod metadata."""

    cfg = get_metadata(i, NAMESPACE)
    if not cfg:
        log.error("Item {} does not have {} metadata".format(i, NAMESPACE))
        return None

    if not cfg.value or cfg.value == "":
        log.error("Item {} does not have a value".format(i))
        return None

    day_type = cfg.configuration["type"]

    if not day_type:
        log.error("Item {} does not have a type".format(i))
        return None

    if day_type == "dayset" and not cfg.configuration["set"]:
        log.error("Item {} is of type dayset but doesn't have a set".format(i))
        return None

    elif day_type == "custom" and not cfg.configuration["file"]:
        log.error("Item {} is of type custom but does't have a file".format(i))
        return None

    return cfg

@log_traceback
def get_times():
    """Gets the list of Items that define the start times for today. It uses
    Ephemeris to determine which set of Items to select. The hierarchy is:
        - custom: custom defined holidays
        - holiday: default holidays
        - dayset: custom defined dayset
        - weekend: weekend as defined in Ephemeris
        - weekday: not weekend days
        - default: used when no other day type is detected for today

    Returns:
        - a list of names for DateTime Items; None if no valid start times were
        found.
    """

    def cond(lst, cond):
        return [i for i in lst if cond(i)]

    def types(type):
        return [i for i in items if get_key_value(i, NAMESPACE, "type") == type]

    # Get all of the etod Items that are valid for today.
    start_times = {'default': types("default"),
                   'weekday': types("weekday") if not Ephemeris.isWeekend() else [],
                   'weekend': types("weekend") if Ephemeris.isWeekend() else [],
                   'dayset': cond(types('dayset'),
                                lambda i: Ephemeris.isInDayset(get_key_value(i, NAMESPACE, "set"))),
                   'holiday': types('holiday') if Ephemeris.isBankHoliday() else [], # changed to simpler way of getting holidays
                   'custom': cond(types('custom'),
                                lambda i: Ephemeris.isBankHoliday(0, get_key_value(i, NAMESPACE, "file")))}

    # Determins which start time Items to use according to the hierarchy.
    day_type = None
    if start_times['custom']:
        day_type = 'custom'
    elif start_times['holiday']:
        day_type = 'holiday'
    elif start_times['dayset']:
        day_type = 'dayset'
    elif start_times['weekend']:
        day_type = 'weekend'
    elif start_times['weekday']:
        day_type = 'weekday'
    elif start_times['default']:
        day_type = 'default'

    log.info("Today is a {} day, there are {} time periods today.".format(day_type, len(start_times[day_type])))
    return start_times[day_type] if day_type else None

@log_traceback
def etod_transition(state):
    """Called from the timers, transitions to the next time of day.

    Arguments:
        - state: the state to transition into
    """
    log.info("Transitioning Time of Day from {} to {}"
             .format(items[ETOD_ITEM], state))
    events.sendCommand(ETOD_ITEM, state)

@log_traceback
def create_timers(start_times):
    """Creates Timers to transition the time of day based on the passed in list
    of DateTime Item names. If an Item is dated with yesterday, the Item is
    updated to today. The ETOD_ITEM is commanded to the current time of day if
    it's not already the correct state.

    Arguments:
        - start_times: list of names for DateTime Items containing the start
        times for each time period
    """

    now = ZonedDateTime.now() # changed as DateTime is not available in OH3
    most_recent_time = now.minusDays(1)
    most_recent_state = items[ETOD_ITEM]

    for time in start_times:

        item_time = to_datetime(items[time]) # changed as DateTime is not available in OH3

        trigger_time = to_today(items[time])

        # Update the Item with today's date if it was for yesterday.
        if item_time.isBefore(trigger_time):
            log.debug("Item {} is yesterday, updating to today".format(time))
            events.postUpdate(time, str(DateTimeType(trigger_time))) # changed as DateTime is not available in OH3

        # Get the etod state from the metadata.
        state = get_value(time, NAMESPACE)

        # If it's in the past but after most_recent, update most_recent.
        if trigger_time.isBefore(now) and trigger_time.isAfter(most_recent_time):
            log.debug("NOW:    {} start time {} is in the past but after {}"
                     .format(state, trigger_time, most_recent_time))
            most_recent_time = trigger_time
            most_recent_state = get_value(time, NAMESPACE)

        # If it's in the future, schedule a Timer.
        elif trigger_time.isAfter(now):
            log.debug("FUTURE: {} Scheduleing Timer for {}"
                     .format(state, trigger_time))
            timers.check(state, trigger_time,
                         function=lambda st=state: etod_transition(st))

        # If it's in the past but not after most_recent_time we can ignore it.
        else:
            log.debug("PAST:   {} start time of {} is before now {} and before {}"
                     .format(state, trigger_time, now, most_recent_time))

    log.info("Created {} timers.".format(len(timers.timers)))
    log.info("The current time of day is {}".format(most_recent_state))
    send_command_if_different(ETOD_ITEM, most_recent_state)

def ephem_tod(event):
    """Rule to recalculate the times of day for today. It triggers at system
    start, two minutes after midnight (to give Astro a chance to update the
    times for today), when ETOD_TRIGGER_ITEM (default is CalculateETOD) receives
    an ON command, or when any of the Items with etod metadata changes.
    """
    log.info("Recalculating time of day")

    # Get the start times.
    start_times = get_times()

    if not start_times:
        log.error("No start times found! Cannot run the rule!")
        return

    # If any are NULL, kick off the init rule.
    null_items = [i for i in start_times if isinstance(items[i], UnDefType)]
    if null_items and "InitItems" in items:
        log.warn("The following Items are are NULL/UNDEF, kicking off "
                 "initialization using item_init: {}"
                 .format(null_items))
        events.sendCommand("InitItems", "ON")
        from time import sleep
        sleep(5)

    # Check to see if we still have NULL/UNDEF Items.
    null_items = [i for i in start_times if isinstance(items[i], UnDefType)]
    if null_items:
        log.error("The following Items are still NULL/UNDEF, "
                  "cannot create Time of Day timers: {}"
                  .format(null_items))
        return

    # Cancel existing Items and then generate all the timers for today.
    timers.cancel_all()
    create_timers(start_times)

    # Create a timer to run this rule again a little after midnight. Work around
    # to deal with the fact that cron triggers do not appear to be workind.
    now = ZonedDateTime.now() # changed as DateTime is not available in OH3
    reload_time = now.withHour(0).withMinute(2).withSecond(0).withNano(0) # changed as DateTime is not available in OH3
    if reload_time.isBefore(now):
        reload_time = reload_time.plusDays(1)
        log.info("Creating reload timer for {}".format(reload_time))
    timers.check("etod_reload", reload_time, function=lambda: ephem_tod(None))

@log_traceback
def load_etod(event):
    """Called at startup or when the Reload Ephemeris Time of Day rule is
    triggered, deletes and recreates the Ephemeris Time of Day rule. Should be
    called at startup and when the metadata is added to or removed from Items.
    """

    # Remove the existing rule if it exists.
    if not delete_rule(ephem_tod, log):
        log.error("Failed to delete rule!")
        return None

    # Generate the rule triggers with the latest metadata configs.
    etod_items = load_rule_with_metadata(NAMESPACE, check_config, "changed",
                                         "Ephemeris Time of Day", ephem_tod,
                                         log,
                                         description=("Creates the timers that "
                                                      "drive the {} state"
                                                      "machine".format(ETOD_ITEM)),
                                         tags=["openhab-rules-tools","etod"])
    if etod_items:
        for i in [i for i in timers.timers if not i in etod_items]:
            timers.cancel(i)

    # Generate the timers now.
    ephem_tod(None)

@log_traceback
def scriptLoaded(*args):
    """Create the Ephemeris Time of Day rule."""

    delete_rule(ephem_tod, log)
    if create_simple_rule(ETOD_RELOAD_ITEM, "Reload Ephemeris Time of Day",
            load_etod, log,
            description=("Regenerates the Ephemeris Time of Day rule using the"
                         " latest {0} metadata. Run after adding or removing any"
                         " {0} metadata to/from and Item."
                         .format(NAMESPACE)),
            tags=["openhab-rules-tools","etod"]):
        load_etod(None)


@log_traceback
def scriptUnloaded():
    """Cancel all Timers at unload to avoid errors in the log and removes the
    rules.
    """

    timers.cancel_all()
    delete_rule(ephem_tod, log)
    delete_rule(load_etod, log)

@rlkoshak : I do not feel confident enough, to put in a pull request to your original repo. Apart from possible errors, I did just change stuff to make it work with OH3. I suspect there is a way of getting the code to work for OH2 and OH3 and I think this approach would be preferable.
But feel free to take the files or parts into your codebase if that is of any help.

Cheers,
Bastian

3 Likes

Nice work @bastian, thanks for sharing and the plug of my stubs repo.

Some thoughts on making it OH2+3 compatible, but bear in mind I just skimmed your code and have not used this library yet. Assuming the original was made using Joda, you could simply use ZonedDateTime for all date operations. If you are also using the Helper Libraries (I think Rich designed this library without them, on purpose) you can use the to_zoneddatetime function, which will give you a ZonedDateTime object in either version of OH. You can also look at the way this is done and duplicate it in this library to remove the dependency on the HLs. You wouldn’t even need the whole function, since here we have a smaller subset of possible input types.

1 Like

Here though the DateTime is used to schedule a Timer. And in OH 2.5 you need to use a Joda DateTime for that and in OH 3 you need to use a ZonedDateTime. So it’s a little bit more complicated I think. My first thought is to move the creation of the Timer itself to a function that can do some checking and call createTimer with what it needs based on the version of OH running. That would be easy enough to handle in time_utils I think. Everything in openhab-rules-tools use to_datetime when scheduling a timer. So probably just a little bit of checking in to_datetime is all that’s needed.

Ultimately, I would like to submit the time_utils (those parts that are not redundant with what’s already there) to the HL. to_datetime above is a little more comprehensive than the HL to_zoneddatetime function as it handles stuff like “2h3m”, integers, ISO8601 date time strings, etc. So you can do something like:

# schedule a timer for five minutes from now
ScriptExecution.createTimer(to_datetime("5m"), runme)

# Schedule a timer based on the state of an DateTime Item
ScriptExecution.createTimer(to_datetime(items["AlarmTime"]), runme)

# Or if the DateTime may need to be moved to today first
ScriptExecution.createTimer(to_today(to_datetime(items["AlarmTime"])), runme)

# Schedule a timer X msec into the future based on a Number Item
ScriptExecution.createTimer(to_datetime(items["MyDelayItem"]), rumme)

The only part I’m not yet sure on is how to test whether to return a ZonedDateTime or a JodaDateTime. My first thought is to try to import Joda and if the import fails we know to use ZonedDateTime.

Largely, at least in my own rules, I hardly ever need to use ZonedDateTimes directly. Any time I need them, which is almost always when needing to schedule a Timer, I can pass just about anything I have to to_datetime and it works out great. So this does seems like the natural place to handle the compatibility.

And in fact, someone (I forget who and will need to go back to the PR to give credit where credit is due) added a new optional argument to to_datetime to specify what type of DateTime one want’s returned. If you pass it “Joda” it gives a Joda DateTime, if passed Python it returns a Python datetime and if passed “Java” it returns a ZonedDateTime. So maybe we just need to change that default so it automatically defaults to ZonedDateTime when Joda isn’t importable and “Joda” when it is. The users can always override it in their code by passing the output argument in the function call and if “Joda” is passed in OH 3 a meaningful error can be generated.

That’s the case for the JavaScript libraries but not for the Python libraries. Those still very much depend on the Helper Libraries and I don’t plan on changing that. At this point the main reason I’m not using them in JavaScript is I want the rules to be just copy and paste into the UI to import them rather than requiring downloading a bunch of stuff from several different repos.

When/if we have a distribution mechanism for rules libraries like these, be it as add-ons or a marketplace, or something else, I will likely rework these all again and probably introduce some HL dependencies even for the JavaScript versions.

1 Like

When you get to it, look at what I did in date.py. If the Joda import fails I still need JodaDateTime to exist, so I set it to None and check for that before trying to use it.

You could use the same logic to decide what to return from to_datetime.

1 Like