Skip to content

Commit f89eaef

Browse files
committed
added "now" as datetime spec to mean trigger startup time; see #139
1 parent aeaaf02 commit f89eaef

File tree

4 files changed

+153
-81
lines changed

4 files changed

+153
-81
lines changed

custom_components/pyscript/trigger.py

Lines changed: 69 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,18 @@ def parse_time_offset(offset_str):
3838
value = 0
3939
if len(match) == 4:
4040
value = float(match[1].replace(" ", ""))
41-
if match[2] == "m" or match[2] == "min" or match[2] == "minutes":
41+
if match[2] in {"m", "min", "mins", "minute", "minutes"}:
4242
scale = 60
43-
elif match[2] == "h" or match[2] == "hr" or match[2] == "hours":
43+
elif match[2] in {"h", "hr", "hour", "hours"}:
4444
scale = 60 * 60
45-
elif match[2] == "d" or match[2] == "day" or match[2] == "days":
45+
elif match[2] in {"d", "day", "days"}:
4646
scale = 60 * 60 * 24
47-
elif match[2] == "w" or match[2] == "week" or match[2] == "weeks":
47+
elif match[2] in {"w", "week", "weeks"}:
4848
scale = 60 * 60 * 24 * 7
49+
elif match[2] not in {"", "s", "sec", "second", "seconds"}:
50+
_LOGGER.error("can't parse time offset %s", offset_str)
51+
else:
52+
_LOGGER.error("can't parse time offset %s", offset_str)
4953
return value * scale
5054

5155

@@ -352,9 +356,12 @@ async def wait_until(
352356
this_timeout = None
353357
state_trig_timeout = False
354358
time_next = None
359+
startup_time = None
355360
if time_trigger is not None:
356361
now = dt_now()
357-
time_next = cls.timer_trigger_next(time_trigger, now)
362+
if startup_time is None:
363+
startup_time = now
364+
time_next = cls.timer_trigger_next(time_trigger, now, startup_time)
358365
_LOGGER.debug(
359366
"trigger %s wait_until time_next = %s, now = %s", ast_ctx.name, time_next, now,
360367
)
@@ -507,26 +514,27 @@ async def wait_until(
507514
return ret
508515

509516
@classmethod
510-
def parse_date_time(cls, date_time_str, day_offset, now):
517+
def parse_date_time(cls, date_time_str, day_offset, now, startup_time):
511518
"""Parse a date time string, returning datetime."""
512519
year = now.year
513520
month = now.month
514521
day = now.day
515522

516-
dt_str = date_time_str.strip().lower()
523+
dt_str_orig = dt_str = date_time_str.strip().lower()
517524
#
518525
# parse the date
519526
#
520-
skip = True
521-
match0 = re.split(r"^0*(\d+)[-/]0*(\d+)(?:[-/]0*(\d+))?", dt_str)
522-
match1 = re.split(r"^(\w+).*", dt_str)
523-
if len(match0) == 5:
524-
if match0[3] is None:
525-
month, day = int(match0[1]), int(match0[2])
526-
else:
527+
match0 = re.match(r"0*(\d+)[-/]0*(\d+)(?:[-/]0*(\d+))?", dt_str)
528+
match1 = re.match(r"(\w+)", dt_str)
529+
if match0:
530+
if match0[3]:
527531
year, month, day = int(match0[1]), int(match0[2]), int(match0[3])
532+
else:
533+
month, day = int(match0[1]), int(match0[2])
528534
day_offset = 0 # explicit date means no offset
529-
elif len(match1) == 3:
535+
dt_str = dt_str[len(match0.group(0)) :]
536+
elif match1:
537+
skip = True
530538
if match1[1] in cls.dow2int:
531539
dow = cls.dow2int[match1[1]]
532540
if dow >= (now.isoweekday() % 7):
@@ -539,39 +547,38 @@ def parse_date_time(cls, date_time_str, day_offset, now):
539547
day_offset = 1
540548
else:
541549
skip = False
542-
else:
543-
skip = False
550+
if skip:
551+
dt_str = dt_str[len(match1.group(0)) :]
544552
if day_offset != 0:
545553
now = dt.datetime(year, month, day) + dt.timedelta(days=day_offset)
546554
year = now.year
547555
month = now.month
548556
day = now.day
549557
else:
550558
now = dt.datetime(year, month, day)
551-
if skip:
552-
i = dt_str.find(" ")
553-
if i >= 0:
554-
dt_str = dt_str[i + 1 :].strip()
555-
else:
556-
return now
559+
dt_str = dt_str.strip()
560+
if len(dt_str) == 0:
561+
return now
557562

558563
#
559564
# parse the time
560565
#
561-
skip = True
562-
match0 = re.split(r"0*(\d+):0*(\d+)(?::0*(\d*\.?\d+(?:[eE][-+]?\d+)?))?", dt_str)
563-
if len(match0) == 5:
564-
if match0[3] is not None:
566+
match0 = re.match(r"0*(\d+):0*(\d+)(?::0*(\d*\.?\d+(?:[eE][-+]?\d+)?))?", dt_str)
567+
if match0:
568+
if match0[3]:
565569
hour, mins, sec = int(match0[1]), int(match0[2]), float(match0[3])
566570
else:
567571
hour, mins, sec = int(match0[1]), int(match0[2]), 0
572+
dt_str = dt_str[len(match0.group(0)) :]
568573
elif dt_str.startswith("sunrise") or dt_str.startswith("sunset"):
569574
location = sun.get_astral_location(cls.hass)
570575
try:
571576
if dt_str.startswith("sunrise"):
572577
time_sun = location.sunrise(dt.date(year, month, day))
578+
dt_str = dt_str[7:]
573579
else:
574580
time_sun = location.sunset(dt.date(year, month, day))
581+
dt_str = dt_str[6:]
575582
except Exception:
576583
_LOGGER.warning("'%s' not defined at this latitude", dt_str)
577584
# return something in the past so it is ignored
@@ -580,27 +587,30 @@ def parse_date_time(cls, date_time_str, day_offset, now):
580587
hour, mins, sec = time_sun.hour, time_sun.minute, time_sun.second
581588
elif dt_str.startswith("noon"):
582589
hour, mins, sec = 12, 0, 0
590+
dt_str = dt_str[4:]
583591
elif dt_str.startswith("midnight"):
584592
hour, mins, sec = 0, 0, 0
593+
dt_str = dt_str[8:]
594+
elif dt_str.startswith("now") and dt_str_orig == dt_str:
595+
#
596+
# "now" means the first time, and only matches if there was no date specification
597+
#
598+
hour, mins, sec = 0, 0, 0
599+
now = startup_time
600+
dt_str = dt_str[3:]
585601
else:
586602
hour, mins, sec = 0, 0, 0
587-
skip = False
588603
now += dt.timedelta(seconds=sec + 60 * (mins + 60 * hour))
589-
if skip:
590-
i = dt_str.find(" ")
591-
if i >= 0:
592-
dt_str = dt_str[i + 1 :].strip()
593-
else:
594-
return now
595604
#
596605
# parse the offset
597606
#
598-
if len(dt_str) > 0 and (dt_str[0] == "+" or dt_str[0] == "-"):
607+
dt_str = dt_str.strip()
608+
if len(dt_str) > 0:
599609
now = now + dt.timedelta(seconds=parse_time_offset(dt_str))
600610
return now
601611

602612
@classmethod
603-
def timer_active_check(cls, time_spec, now):
613+
def timer_active_check(cls, time_spec, now, startup_time):
604614
"""Check if the given time matches the time specification."""
605615
results = {"+": [], "-": []}
606616
for entry in time_spec if isinstance(time_spec, list) else [time_spec]:
@@ -627,10 +637,10 @@ def timer_active_check(cls, time_spec, now):
627637
_LOGGER.error("Invalid range expression: %s", exc)
628638
return False
629639

630-
start = cls.parse_date_time(dt_start.strip(), 0, now)
631-
end = cls.parse_date_time(dt_end.strip(), 0, start)
640+
start = cls.parse_date_time(dt_start.strip(), 0, now, startup_time)
641+
end = cls.parse_date_time(dt_end.strip(), 0, start, startup_time)
632642

633-
if start < end:
643+
if start <= end:
634644
this_match = start <= now <= end
635645
else: # Over midnight
636646
this_match = now >= start or now <= end
@@ -649,7 +659,7 @@ def timer_active_check(cls, time_spec, now):
649659
return result
650660

651661
@classmethod
652-
def timer_trigger_next(cls, time_spec, now):
662+
def timer_trigger_next(cls, time_spec, now, startup_time):
653663
"""Return the next trigger time based on the given time and time specification."""
654664
next_time = None
655665
if not isinstance(time_spec, list):
@@ -668,39 +678,41 @@ def timer_trigger_next(cls, time_spec, now):
668678
next_time = val
669679

670680
elif len(match1) == 3:
671-
this_t = cls.parse_date_time(match1[1].strip(), 0, now)
672-
if this_t <= now:
681+
this_t = cls.parse_date_time(match1[1].strip(), 0, now, startup_time)
682+
if this_t <= now and this_t != startup_time:
673683
#
674684
# Try tomorrow (won't make a difference if spec has full date)
675685
#
676-
this_t = cls.parse_date_time(match1[1].strip(), 1, now)
677-
if now < this_t and (next_time is None or this_t < next_time):
686+
this_t = cls.parse_date_time(match1[1].strip(), 1, now, startup_time)
687+
startup = now == this_t and now == startup_time
688+
if (now < this_t or startup) and (next_time is None or this_t < next_time):
678689
next_time = this_t
679690

680691
elif len(match2) == 5:
681692
start_str, period_str = match2[1].strip(), match2[2].strip()
682-
start = cls.parse_date_time(start_str, 0, now)
693+
start = cls.parse_date_time(start_str, 0, now, startup_time)
683694
period = parse_time_offset(period_str)
684695
if period <= 0:
685696
_LOGGER.error("Invalid non-positive period %s in period(): %s", period, time_spec)
686697
continue
687698

688699
if match2[3] is None:
689-
if now < start and (next_time is None or start < next_time):
700+
startup = now == start and now == startup_time
701+
if (now < start or startup) and (next_time is None or start < next_time):
690702
next_time = start
691-
if now >= start:
703+
if now >= start and not startup:
692704
secs = period * (1.0 + math.floor((now - start).total_seconds() / period))
693705
this_t = start + dt.timedelta(seconds=secs)
694706
if now < this_t and (next_time is None or this_t < next_time):
695707
next_time = this_t
696708
continue
697709
end_str = match2[3].strip()
698-
end = cls.parse_date_time(end_str, 0, now)
710+
end = cls.parse_date_time(end_str, 0, now, startup_time)
699711
end_offset = 1 if end < start else 0
700712
for day in [-1, 0, 1]:
701-
start = cls.parse_date_time(start_str, day, now)
702-
end = cls.parse_date_time(end_str, day + end_offset, now)
703-
if now < start:
713+
start = cls.parse_date_time(start_str, day, now, startup_time)
714+
end = cls.parse_date_time(end_str, day + end_offset, now, startup_time)
715+
if now < start or (now == start and now == startup_time):
704716
if next_time is None or start < next_time:
705717
next_time = start
706718
break
@@ -894,13 +906,17 @@ async def trigger_watch(self):
894906
state_trig_waiting = False
895907
state_trig_notify_info = [None, None]
896908
state_false_time = None
909+
startup_time = None
897910
check_state_expr_on_start = self.state_check_now or self.state_hold_false is not None
898911

899912
while True:
900913
timeout = None
901914
state_trig_timeout = False
902915
notify_info = None
903916
notify_type = None
917+
now = dt_now()
918+
if startup_time is None:
919+
startup_time = now
904920
if self.run_on_startup:
905921
#
906922
# first time only - skip waiting for other triggers
@@ -921,8 +937,7 @@ async def trigger_watch(self):
921937
check_state_expr_on_start = False
922938
else:
923939
if self.time_trigger:
924-
now = dt_now()
925-
time_next = TrigTime.timer_trigger_next(self.time_trigger, now)
940+
time_next = TrigTime.timer_trigger_next(self.time_trigger, now, startup_time)
926941
_LOGGER.debug(
927942
"trigger %s time_next = %s, now = %s", self.name, time_next, now,
928943
)
@@ -1066,7 +1081,7 @@ async def trigger_watch(self):
10661081
self.active_expr.get_logger().error(exc)
10671082
trig_ok = False
10681083
if trig_ok and self.time_active:
1069-
trig_ok = TrigTime.timer_active_check(self.time_active, dt_now())
1084+
trig_ok = TrigTime.timer_active_check(self.time_active, now, startup_time)
10701085

10711086
if not trig_ok:
10721087
_LOGGER.debug(

docs/reference.rst

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -537,37 +537,48 @@ Several of the time specifications use a ``datetime`` format, which is ISO: ``yy
537537
with the following features:
538538

539539
- There is no time-zone (local is assumed).
540-
- Seconds can include a decimal (fractional) portion if you need finer resolution.
541540
- The date is optional, and the year can be omitted with just ``mm/dd``.
542541
- The date can also be replaced by a day of the week (either full like ``sunday`` or 3-letters like
543542
``sun``, in your local languarge based on the locale; however, on Windows and other platforms that
544543
lack ``locale.nl_langinfo``, the days of week default to English).
545544
- The meaning of partial or missing dates depends on the trigger, as explained below.
545+
- The date and time can be replaced with ``now``, which means the current date and time when the
546+
trigger was first evaluated (eg, at startup or when created as an inner function or closure),
547+
and remains fixed for the lifetime of the trigger.
546548
- The time can instead be ``sunrise``, ``sunset``, ``noon`` or ``midnight``.
549+
- If the time is missing, midnight is assumed (so ``thursday`` is the same as ``thursday 00:00:00``)
550+
- Seconds are optional, and can include a decimal (fractional) portion if you need finer resolution.
547551
- The ``datetime`` can be followed by an optional offset
548-
of the form ``[+-]number{seconds|minutes|hours|days|weeks}`` and abbreviations ``{s|m|h|d|w}`` or
549-
``{sec|min|hr|day|week}`` can be used. That allows things like ``sunrise + 30m`` to mean 30
550-
minutes after sunrise, or ``sunday sunset - 1h`` to mean an hour before sunset on Sundays. The
551-
``number`` can be floating point. (Note, there is no i18n support for those offset abbreviations -
552-
they are in English.)
552+
of the form ``[+-]number{seconds|minutes|hours|days|weeks}`` with abbreviations:
553+
554+
- ``{s|sec|second|seconds}`` or empty for seconds,
555+
- ``{m|min|mins|minute|minutes}`` for minutes,
556+
- ``{h|hr|hour|hours}`` for hours,
557+
- ``{d|day|days}`` for days,
558+
- ``{w|week|weeks}`` for weeks.
559+
That allows things like ``sunrise + 30m`` to mean 30 minutes after sunrise, or ``sunday sunset - 1.5 hour``
560+
to mean 1.5 hours before sunset on Sundays. The ``number`` can be floating point. (Note, there is no
561+
i18n support for those offset abbreviations - they are in English.)
553562

554563
In ``@time_trigger``, each string specification ``time_spec`` can take one of four forms:
555564

556-
- ``"startup"`` triggers on HASS start and reload (ie, on function definition)
565+
- ``"startup"`` triggers on HASS start and reload (ie, on function definition), and is
566+
equivalent to ``"once(now)"``
557567
- ``"shutdown"`` triggers on HASS shutdown and reload (ie, when the trigger function is
558568
no longer referenced)
559569
- ``"once(datetime)"`` triggers once on the date and time. If the year is
560570
omitted, it triggers once per year on the date and time (eg, birthday). If the date is just a day
561571
of week, it triggers once on that day of the week. If the date is omitted, it triggers once each
562-
day at the indicated time.
572+
day at the indicated time. ``once(now + 5 min)`` means trigger once 5 minutes after startup.
563573
- ``"period(datetime_start, interval, datetime_end)"`` or
564574
``"period(datetime_start, interval)"`` triggers every interval starting at the starting datetime
565575
and finishing at the optional ending datetime. When there is no ending datetime, the periodic
566576
trigger runs forever. The interval has the form ``number{sec|min|hours|days|weeks}`` (the same as
567-
datetime offset without the leading sign), and single-letter abbreviations can be used.
568-
- ``"cron(min hr dom mon dow)"`` triggers
569-
according to Linux-style crontab. Each of the five entries are separated by spaces and correspond
570-
to minutes, hours, day-of-month, month, day-of-week (0 = sunday):
577+
datetime offset without the leading sign), and the same abbreviations can be used. Period start
578+
and ends can also be based on ``now``, for example ``period(now + 10m, 5min, now + 30min)`` will
579+
cause five triggers at 10, 15, 20, 25 and 30 minutes after startup.
580+
- ``"cron(min hr dom mon dow)"`` triggers according to Linux-style crontab. Each of the five entries
581+
are separated by spaces and correspond to minutes, hours, day-of-month, month, day-of-week (0 = sunday):
571582

572583
============ ==============
573584
field allowed values
@@ -583,7 +594,11 @@ In ``@time_trigger``, each string specification ``time_spec`` can take one of fo
583594
numbers or ranges (no spaces). Ranges are inclusive. For example, if you specify hours as
584595
``6,10-13`` that means hours of 6,10,11,12,13. The trigger happens on the next minute, hour, day
585596
that matches the specification. See any Linux documentation for examples and more details (note:
586-
names for days of week and months are not supported; only their integer values are).
597+
names for days of week and months are not supported; only their integer values are). The cron
598+
features use the ``croniter`` package, so check its `documentation <https://pypi.org/project/croniter/>`
599+
for additional specification formats that are supported (eg: ``*/5`` repeats every 5th unit,
600+
days of week can be specified with English abbreviations, and an optional 6th field allows seconds
601+
to be specified).
587602

588603
When the ``@time_trigger`` occurs and the function is called, the keyword argument ``trigger_type``
589604
is set to ``"time"``, and ``trigger_time`` is the exact ``datetime`` of the time specification that

tests/test_function.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,11 @@ async def test_state_trigger(hass, caplog):
191191
hass,
192192
notify_q,
193193
notify_q2,
194-
[dt(2020, 7, 1, 10, 59, 59, 999998), dt(2020, 7, 1, 11, 59, 59, 999998)],
194+
[
195+
dt(2020, 7, 1, 10, 59, 59, 999998),
196+
dt(2020, 7, 1, 10, 59, 59, 999998),
197+
dt(2020, 7, 1, 11, 59, 59, 999998),
198+
],
195199
"""
196200
197201
from math import sqrt

0 commit comments

Comments
 (0)