Skip to content

Commit 7784a9f

Browse files
committed
Initial commit of sched module
1 parent 2262ff6 commit 7784a9f

File tree

9 files changed

+641
-0
lines changed

9 files changed

+641
-0
lines changed

v3/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ Documented in the tutorial. Comprises:
2525
* Classes for interfacing switches and pushbuttons.
2626
* A software retriggerable monostable timer class, similar to a watchdog.
2727

28+
### A scheduler
29+
30+
This [lightweight scheduler](./docs/SCHEDULE.md) enables tasks to be scheduled
31+
at future times. These can be assigned in a flexible way: a task might run at
32+
4.10am on Monday and Friday if there's no "r" in the month.
33+
2834
### Asynchronous device drivers
2935

3036
These device drivers are intended as examples of asynchronous code which are

v3/as_drivers/sched/__init__.py

Whitespace-only changes.

v3/as_drivers/sched/asynctest.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# asynctest.py Demo of asynchronous code scheduling tasks with cron
2+
3+
# Copyright (c) 2020 Peter Hinch
4+
# Released under the MIT License (MIT) - see LICENSE file
5+
6+
import uasyncio as asyncio
7+
from sched.sched import schedule
8+
from sched.cron import cron
9+
from time import localtime
10+
11+
def foo(txt): # Demonstrate callback
12+
yr, mo, md, h, m, s, wd = localtime()[:7]
13+
fst = 'Callback {} {:02d}:{:02d}:{:02d} on {:02d}/{:02d}/{:02d}'
14+
print(fst.format(txt, h, m, s, md, mo, yr))
15+
16+
async def bar(txt): # Demonstrate coro launch
17+
yr, mo, md, h, m, s, wd = localtime()[:7]
18+
fst = 'Coroutine {} {:02d}:{:02d}:{:02d} on {:02d}/{:02d}/{:02d}'
19+
print(fst.format(txt, h, m, s, md, mo, yr))
20+
await asyncio.sleep(0)
21+
22+
async def main():
23+
print('Asynchronous test running...')
24+
cron4 = cron(hrs=None, mins=range(0, 60, 4))
25+
asyncio.create_task(schedule(cron4, foo, ('every 4 mins',)))
26+
27+
cron5 = cron(hrs=None, mins=range(0, 60, 5))
28+
asyncio.create_task(schedule(cron5, foo, ('every 5 mins',)))
29+
30+
cron3 = cron(hrs=None, mins=range(0, 60, 3)) # Launch a coroutine
31+
asyncio.create_task(schedule(cron3, bar, ('every 3 mins',)))
32+
33+
cron2 = cron(hrs=None, mins=range(0, 60, 2))
34+
asyncio.create_task(schedule(cron2, foo, ('one shot',), True))
35+
await asyncio.sleep(900) # Quit after 15 minutes
36+
37+
try:
38+
asyncio.run(main())
39+
finally:
40+
_ = asyncio.new_event_loop()

v3/as_drivers/sched/cron.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# cron.py
2+
3+
# Copyright (c) 2020 Peter Hinch
4+
# Released under the MIT License (MIT) - see LICENSE file
5+
6+
from time import mktime, localtime
7+
# Validation
8+
_valid = ((0, 59, 'secs'), (0, 59, 'mins'), (0, 23, 'hrs'),
9+
(1, 31, 'mday'), (1, 12, 'month'), (0, 6, 'wday'))
10+
_mdays = {2:28, 4:30, 6:30, 9:30, 11:30}
11+
# A call to the inner function takes 270-520μs on Pyboard depending on args
12+
def cron(*, secs=0, mins=0, hrs=3, mday=None, month=None, wday=None):
13+
# Given an arg and current value, return offset between arg and cv
14+
# If arg is iterable return offset of next arg +ve for future -ve for past (add modulo)
15+
def do_arg(a, cv): # Arg, current value
16+
if a is None:
17+
return 0
18+
elif isinstance(a, int):
19+
return a - cv
20+
try:
21+
return min(x for x in a if x >= cv) - cv
22+
except ValueError: # wrap-round
23+
return min(a) - cv # -ve
24+
except TypeError:
25+
raise ValueError('Invalid argument type', type(a))
26+
27+
if secs is None: # Special validation for seconds
28+
raise ValueError('Invalid None value for secs')
29+
if not isinstance(secs, int) and len(secs) > 1: # It's an iterable
30+
ss = sorted(secs)
31+
if min((a[1] - a[0] for a in zip(ss, ss[1:]))) < 2:
32+
raise ValueError("Can't have consecutive seconds.", last, x)
33+
args = (secs, mins, hrs, mday, month, wday) # Validation for all args
34+
valid = iter(_valid)
35+
vestr = 'Argument {} out of range'
36+
vmstr = 'Invalid no. of days for month'
37+
for arg in args: # Check for illegal arg values
38+
lower, upper, errtxt = next(valid)
39+
if isinstance(arg, int):
40+
if not lower <= arg <= upper:
41+
raise ValueError(vestr.format(errtxt))
42+
elif arg is not None: # Must be an iterable
43+
if any(v for v in arg if not lower <= v <= upper):
44+
raise ValueError(vestr.format(errtxt))
45+
if mday is not None and month is not None: # Check mday against month
46+
max_md = mday if isinstance(mday, int) else max(mday)
47+
if isinstance(month, int):
48+
if max_md > _mdays.get(month, 31):
49+
raise ValueError(vmstr)
50+
elif sum((m for m in month if max_md > _mdays.get(m, 31))):
51+
raise ValueError(vmstr)
52+
if mday is not None and wday is not None and do_arg(mday, 23) > 0:
53+
raise ValueError('mday must be <= 22 if wday also specified.')
54+
55+
def inner(tnow):
56+
tev = tnow # Time of next event: work forward from time now
57+
yr, mo, md, h, m, s, wd = localtime(tev)[:7]
58+
init_mo = mo # Month now
59+
toff = do_arg(secs, s)
60+
tev += toff if toff >= 0 else 60 + toff
61+
62+
yr, mo, md, h, m, s, wd = localtime(tev)[:7]
63+
toff = do_arg(mins, m)
64+
tev += 60 * (toff if toff >= 0 else 60 + toff)
65+
66+
yr, mo, md, h, m, s, wd = localtime(tev)[:7]
67+
toff = do_arg(hrs, h)
68+
tev += 3600 * (toff if toff >= 0 else 24 + toff)
69+
70+
yr, mo, md, h, m, s, wd = localtime(tev)[:7]
71+
toff = do_arg(month, mo)
72+
mo += toff
73+
md = md if mo == init_mo else 1
74+
if toff < 0:
75+
yr += 1
76+
tev = mktime((yr, mo, md, h, m, s, wd, 0))
77+
yr, mo, md, h, m, s, wd = localtime(tev)[:7]
78+
if mday is not None:
79+
if mo == init_mo: # Month has not rolled over or been changed
80+
toff = do_arg(mday, md) # see if mday causes rollover
81+
md += toff
82+
if toff < 0:
83+
toff = do_arg(month, mo + 1) # Get next valid month
84+
mo += toff + 1 # Offset is relative to next month
85+
if toff < 0:
86+
yr += 1
87+
else: # Month has rolled over: day is absolute
88+
md = do_arg(mday, 0)
89+
90+
if wday is not None:
91+
if mo == init_mo:
92+
toff = do_arg(wday, wd)
93+
md += toff % 7 # mktime handles md > 31 but month may increment
94+
tev = mktime((yr, mo, md, h, m, s, wd, 0))
95+
cur_mo = mo
96+
_, mo = localtime(tev)[:2] # get month
97+
if mo != cur_mo:
98+
toff = do_arg(month, mo) # Get next valid month
99+
mo += toff # Offset is relative to new, incremented month
100+
if toff < 0:
101+
yr += 1
102+
tev = mktime((yr, mo, 1, h, m, s, wd, 0)) # 1st of new month
103+
yr, mo, md, h, m, s, wd = localtime(tev)[:7] # get day of week
104+
toff = do_arg(wday, wd)
105+
md += toff % 7
106+
else:
107+
md = 1 if mday is None else md
108+
tev = mktime((yr, mo, md, h, m, s, wd, 0)) # 1st of new month
109+
yr, mo, md, h, m, s, wd = localtime(tev)[:7] # get day of week
110+
md += (do_arg(wday, 0) - wd) % 7
111+
112+
return mktime((yr, mo, md, h, m, s, wd, 0)) - tnow
113+
return inner

v3/as_drivers/sched/crontest.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# crontest.py
2+
3+
# Copyright (c) 2020 Peter Hinch
4+
# Released under the MIT License (MIT) - see LICENSE file
5+
6+
from time import time, ticks_diff, ticks_us, localtime
7+
from sched.cron import cron
8+
import sys
9+
10+
maxruntime = 0
11+
fail = 0
12+
def result(t, msg):
13+
global fail
14+
if t != next(iexp):
15+
print('FAIL', msg, t)
16+
fail += 1
17+
return
18+
print('PASS', msg, t)
19+
20+
def test(*, secs=0, mins=0, hrs=3, mday=None, month=None, wday=None, tsource=None):
21+
global maxruntime
22+
ts = int(time() if tsource is None else tsource) # int() for Unix build
23+
cg = cron(secs=secs, mins=mins, hrs=hrs, mday=mday, month=month, wday=wday)
24+
start = ticks_us()
25+
t = cg(ts) # Time relative to ts
26+
delta = ticks_diff(ticks_us(), start)
27+
maxruntime = max(maxruntime, delta)
28+
print('Runtime = {}μs'.format(delta))
29+
tev = t + ts # Absolute time of 1st event
30+
yr, mo, md, h, m, s, wd = localtime(tev)[:7]
31+
print('{:02d}:{:02d}:{:02d} on {:02d}/{:02d}/{:02d}'.format(h, m, s, md, mo, yr))
32+
return t # Relative time
33+
34+
now = 1596074400 if sys.platform == 'linux' else 649393200 # 3am Thursday (day 3) 30 July 2020
35+
iexp = iter([79500, 79500, 86700, 10680, 13564800, 17712000,
36+
12781800, 11217915, 5443200, 21600, 17193600,
37+
18403200, 5353140, 13392000, 18662400])
38+
# Expect 01:05:00 on 31/07/2020
39+
result(test(wday=4, hrs=(1,2), mins=5, tsource=now), 'wday and time both cause 1 day increment.')
40+
# 01:05:00 on 31/07/2020
41+
result(test(hrs=(1,2), mins=5, tsource=now), 'time causes 1 day increment.')
42+
# 03:05:00 on 31/07/2020
43+
result(test(wday=4, mins=5, tsource=now), 'wday causes 1 day increment.')
44+
# 05:58:00 on 30/07/2020
45+
result(test(hrs=(5, 23), mins=58, tsource=now), 'time increment no day change.')
46+
# 03:00:00 on 03/01/2021
47+
result(test(month=1, wday=6, tsource=now), 'month and year rollover, 1st Sunday')
48+
# 03:00:00 on 20/02/2021
49+
result(test(month=2, mday=20, tsource=now), 'month and year rollover, mday->20 Feb')
50+
# 01:30:00 on 25/12/2020
51+
result(test(month=12, mday=25, hrs=1, mins=30, tsource=now), 'Forward to Christmas day, hrs backwards')
52+
# 23:05:15 on 06/12/2020
53+
result(test(month=12, wday=6, hrs=23, mins=5, secs=15, tsource=now), '1st Sunday in Dec 2020')
54+
# 03:00:00 on 01/10/2020
55+
result(test(month=10, tsource=now), 'Current time on 1st Oct 2020')
56+
# 09:00:00 on 30/07/2020
57+
result(test(month=7, hrs=9, tsource=now), 'Explicitly specify current month')
58+
# 03:00:00 on 14/02/2021
59+
result(test(month=2, mday=8, wday=6, tsource=now), 'Second Sunday in February 2021')
60+
# 03:00:00 on 28/02/2021
61+
result(test(month=2, mday=22, wday=6, tsource=now), 'Fourth Sunday in February 2021') # last day of month
62+
# 01:59:00 on 01/10/2020
63+
result(test(month=(7, 10), hrs=1, mins=59, tsource=now + 24*3600), 'Time causes month rollover to next legal month')
64+
# 03:00:00 on 01/01/2021
65+
result(test(month=(7, 1), mday=1, tsource=now), 'mday causes month rollover to next year')
66+
# 03:00:00 on 03/03/2021
67+
result(test(month=(7, 3), wday=(2, 6), tsource=now), 'wday causes month rollover to next year')
68+
print('Max runtime {}μs'.format(maxruntime))
69+
if fail:
70+
print(fail, 'FAILURES OCCURRED')
71+
else:
72+
print('ALL TESTS PASSED')
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# __init__.py Common functions for uasyncio primitives
2+
3+
# Copyright (c) 2018-2020 Peter Hinch
4+
# Released under the MIT License (MIT) - see LICENSE file
5+
6+
try:
7+
import uasyncio as asyncio
8+
except ImportError:
9+
import asyncio
10+
11+
12+
async def _g():
13+
pass
14+
type_coro = type(_g())
15+
16+
# If a callback is passed, run it and return.
17+
# If a coro is passed initiate it and return.
18+
# coros are passed by name i.e. not using function call syntax.
19+
def launch(func, tup_args):
20+
res = func(*tup_args)
21+
if isinstance(res, type_coro):
22+
res = asyncio.create_task(res)
23+
return res
24+
25+
def set_global_exception():
26+
def _handle_exception(loop, context):
27+
import sys
28+
sys.print_exception(context["exception"])
29+
sys.exit()
30+
loop = asyncio.get_event_loop()
31+
loop.set_exception_handler(_handle_exception)

v3/as_drivers/sched/sched.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# sched.py
2+
3+
# Copyright (c) 2020 Peter Hinch
4+
# Released under the MIT License (MIT) - see LICENSE file
5+
6+
import uasyncio as asyncio
7+
from sched.primitives import launch
8+
from time import time
9+
10+
async def schedule(fcron, routine, args=(), run_once=False):
11+
maxt = 1000 # uasyncio can't handle arbitrarily long delays
12+
done = False
13+
while not done:
14+
tw = fcron(int(time())) # Time to wait (s)
15+
while tw > 0: # While there is still time to wait
16+
tw = min(tw, maxt)
17+
await asyncio.sleep(tw)
18+
tw -= maxt
19+
launch(routine, args)
20+
done = run_once
21+
await asyncio.sleep_ms(1200) # ensure we're into next second

v3/as_drivers/sched/synctest.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# synctest.py Demo of synchronous code scheduling tasks with cron
2+
3+
# Copyright (c) 2020 Peter Hinch
4+
# Released under the MIT License (MIT) - see LICENSE file
5+
6+
from .cron import cron
7+
from time import localtime, sleep, time
8+
9+
def foo(txt):
10+
yr, mo, md, h, m, s, wd = localtime()[:7]
11+
fst = "{} {:02d}:{:02d}:{:02d} on {:02d}/{:02d}/{:02d}"
12+
print(fst.format(txt, h, m, s, md, mo, yr))
13+
14+
def main():
15+
print('Synchronous test running...')
16+
tasks = [] # Entries: cron, callback, args, one_shot
17+
cron4 = cron(hrs=None, mins=range(0, 60, 4))
18+
tasks.append([cron4, foo, ('every 4 mins',), False, False])
19+
cron5 = cron(hrs=None, mins=range(0, 60, 5))
20+
tasks.append([cron5, foo, ('every 5 mins',), False, False])
21+
cron3 = cron(hrs=None, mins=range(0, 60, 3))
22+
tasks.append([cron3, foo, ('every 3 mins',), False, False])
23+
cron2 = cron(hrs=None, mins=range(0, 60, 2))
24+
tasks.append([cron2, foo, ('one shot',), True, False])
25+
to_run = []
26+
while True:
27+
now = int(time()) # Ensure constant: get once per iteration.
28+
tasks.sort(key=lambda x:x[0](now))
29+
to_run.clear() # Pending tasks
30+
deltat = tasks[0][0](now) # Time to pending task(s)
31+
for task in (t for t in tasks if t[0](now) == deltat): # Tasks with same delta t
32+
to_run.append(task)
33+
task[4] = True # Has been scheduled
34+
# Remove on-shot tasks which have been scheduled
35+
tasks = [t for t in tasks if not (t[3] and t[4])]
36+
sleep(deltat)
37+
for tsk in to_run:
38+
tsk[1](*tsk[2])
39+
sleep(1.2) # Ensure seconds have rolled over
40+
41+
main()

0 commit comments

Comments
 (0)