├── LICENSE ├── README.md ├── package.json └── schedule └── __init__.py /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Patrick Joy - Think Transit 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # micropython-aioschedule 2 | 3 | Overview 4 | ----- 5 | 6 | Run Python coroutines periodically using a friendly syntax. 7 | 8 | - Asynchronous using asyncio 9 | - A simple to use API for scheduling jobs 10 | - In-process scheduler for periodic jobs 11 | - Persistence, tasks survive reboot/restart/crash 12 | - Tested on MicroPython 1.19 13 | 14 | Based on the python schedule library by dbader https://github.com/dbader/schedule 15 | 16 | Background 17 | ----- 18 | 19 | This project is very much a work in progress, having been ported from CPython it is not very "micro" and the codebase needs to be reduced. All issues/pr's welcome. 20 | 21 | The goal of this library is to create a simple micropython scheduler that is asynchronous and persistent (tasks are stored in flash and can be reloaded after a reboot/restart/crash). This is important for devices that operate on battery power and need to conserve power. Devices can be put to deep sleep when no tasks are scheduled to run. 22 | 23 | At this point in the time the library is designed to call async coroutines, in future non-async calls may be supported. 24 | 25 | 26 | Requirements 27 | ----- 28 | 29 | Requires datetime and functools from micropython-lib 30 | 31 | Automatic installation, requires MicroPython from github (later than 30-09-2022) 32 | ```python 33 | >>> import mip 34 | >>> mip.install("github:ThinkTransit/micropython-aioschedule") 35 | 36 | Installing github:ThinkTransit/micropython-aioschedule/package.json to /lib 37 | Copying: /lib/schedule/schedule.py 38 | Installing functools (latest) from https://micropython.org/pi/v2 to /lib 39 | Copying: /lib/functools.mpy 40 | Installing datetime (latest) from https://micropython.org/pi/v2 to /lib 41 | Copying: /lib/datetime.mpy 42 | Done 43 | >>> 44 | 45 | ``` 46 | Manual installation 47 | ``` 48 | Copy schedule.py to your micropython lib directory 49 | Copy functools.py and datetime.py to your lib directory from micropython-lib 50 | ``` 51 | 52 | Usage 53 | ----- 54 | 55 | Create some tasks. 56 | 57 | ```python 58 | import schedule 59 | 60 | async def job(): 61 | print("I'm working...") 62 | schedule.every(1).minutes.do(job) 63 | 64 | async def job_with_argument(name): 65 | print(f"I am {name}") 66 | schedule.every(10).seconds.do(job_with_argument, name="MicroPython") 67 | 68 | ``` 69 | 70 | If you have an existing async application, add the scheduler task to your main event loop. 71 | ```python 72 | import schedule 73 | import uasyncio as asyncio 74 | 75 | asyncio.create_task(schedule.run_forever()) 76 | ``` 77 | 78 | Full simplified working example code. 79 | ```python 80 | import schedule 81 | import uasyncio as asyncio 82 | 83 | 84 | async def job(): 85 | print("I'm working...") 86 | schedule.every(1).minutes.do(job) 87 | 88 | 89 | async def job_with_argument(name): 90 | print(f"I am {name}") 91 | schedule.every(10).seconds.do(job_with_argument, name="MicroPython") 92 | 93 | 94 | async def main_loop(): 95 | # Create the scheduling task 96 | t = asyncio.create_task(schedule.run_forever()) 97 | await t 98 | 99 | try: 100 | asyncio.run(main_loop()) 101 | except KeyboardInterrupt: 102 | print('Interrupted') 103 | except Exception as e: 104 | print("caught") 105 | print(e) 106 | finally: 107 | asyncio.new_event_loop() 108 | 109 | ``` 110 | Persistence 111 | ----- 112 | 113 | To persist the schedule it must be saved to flash by calling save_flash(). Typically this should be done before your microcontroller goes to sleep so that the last run times are saved. 114 | 115 | ```python 116 | import schedule 117 | 118 | # Save schedule to flash 119 | schedule.save_flash() 120 | ``` 121 | 122 | To load from flash call load_flash(). Typically this should be done when your program first boots. 123 | ```python 124 | import schedule 125 | 126 | # Load schedule from flash 127 | schedule.load_flash() 128 | ``` 129 | 130 | If your device is permanently running it is still a good idea to save the schedule to flash periodically so that in the event of a crash, last run times will be preserved. You can use a scheduled task to do this. 131 | ```python 132 | import schedule 133 | 134 | async def save_schedule(): 135 | schedule.save_flash() 136 | 137 | schedule.every(30).minutes.do(save_schedule) 138 | ``` 139 | 140 | It is important to note that when using persistence, the scheduled tasks only need to be created and saved to flash once. The easiest way to do this is to write a deployment function that creates the scheduled tasks, this function would be called once when the board is deployed, or when code is updated. 141 | ```python 142 | import schedule 143 | 144 | async def job(): 145 | print("I'm working...") 146 | 147 | def create_schedule(): 148 | schedule.clear() 149 | 150 | schedule.every(30).minutes.do(job) 151 | schedule.every().hour.do(job) 152 | schedule.every().day.at("10:30").do(job) 153 | schedule.every().wednesday.at("13:15").do(job) 154 | 155 | schedule.save_flash() 156 | ``` 157 | 158 | Deepsleep support 159 | ----- 160 | One of the key advantages of a persistent scheduler is the ability for the device to sleep or enter low power mode when no tasks are scheduled to run. This is important for devices that operate on battery power. 161 | 162 | The library includes a helper function idle_seconds() which can be used to determine how long to sleep for. 163 | 164 | ```python 165 | import schedule 166 | import machine 167 | 168 | sleep_time = schedule.idle_seconds() 169 | 170 | machine.deepsleep(int(sleep_time*1000)) 171 | ``` 172 | 173 | When using deep sleep functionality there is no need to run a scheduling task in the main event loop. The device will simply boot, run pending tasks and then go back to sleep. 174 | In this situation the simplifed example from above will now look like this. 175 | 176 | ```python 177 | import schedule 178 | import machine 179 | import uasyncio as asyncio 180 | 181 | async def job(): 182 | print("I'm working...") 183 | 184 | async def job_with_argument(name): 185 | print(f"I am {name}") 186 | 187 | # This function is only run once during device deployment 188 | def create_schedule(): 189 | schedule.clear() 190 | 191 | schedule.every(1).minutes.do(job) 192 | schedule.every(10).seconds.do(job_with_argument, name="MicroPython") 193 | 194 | schedule.save_flash() 195 | 196 | async def main_loop(): 197 | # Load schedule from flash 198 | schedule.load_flash() 199 | 200 | # Run pending tasks 201 | await schedule.run_pending() 202 | 203 | # Save schedule, this step is important to make sure the last run time is preserved 204 | schedule.save_flash() 205 | 206 | sleep_time = schedule.idle_seconds() 207 | 208 | print(f'Going to sleep for { sleep_time } seconds.') 209 | 210 | machine.deepsleep(sleep_time*1000) 211 | 212 | 213 | try: 214 | asyncio.run(main_loop()) 215 | except KeyboardInterrupt: 216 | print('Interrupted') 217 | except Exception as e: 218 | print("caught") 219 | print(e) 220 | finally: 221 | asyncio.new_event_loop() 222 | 223 | ``` 224 | 225 | Limitations 226 | ----- 227 | The following are know limitations that need to be addressed in future releases. 228 | 229 | Coros names and arguments are saved to flash and run using eval. This creates two limitations. 230 | 231 | 1. Only in scope coros that exist inside main can be used 232 | 2. There is a potential security risk if the schedule.json file is modified to run arbitary code 233 | 234 | 235 | ---- 236 | 237 | Patrick Joy `patrick@joytech.com.au` 238 | 239 | Inspired by Daniel Bader and distributed under the MIT license. See `LICENSE.txt ` for more information. 240 | 241 | https://github.com/ThinkTransit/schedule-micropython 242 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "urls": [ 3 | ["schedule/__init__.py", "github:ThinkTransit/micropython-aioschedule/schedule/__init__.py"] 4 | ], 5 | "deps": [ 6 | ["functools", "latest"], 7 | ["datetime", "latest"] 8 | ], 9 | "version": "0.3" 10 | } 11 | -------------------------------------------------------------------------------- /schedule/__init__.py: -------------------------------------------------------------------------------- 1 | # --------------------------------------------------------------------------- 2 | # schedule.py 3 | # --------------------------------------------------------------------------- 4 | import datetime 5 | import functools 6 | import random 7 | import re 8 | import time 9 | import json 10 | import asyncio 11 | from datetime import timezone 12 | 13 | try: 14 | import logging 15 | log = logging.getLogger(__name__) 16 | except ImportError: 17 | class logging: 18 | def critical(self, entry): 19 | print('CRITICAL: ' + entry) 20 | def error(self, entry): 21 | print('ERROR: ' + entry) 22 | def warning(self, entry): 23 | print('WARNING: ' + entry) 24 | def info(self, entry): 25 | print('INFO: ' + entry) 26 | def debug(self, entry, value=None): 27 | if value: 28 | print('DEBUG: ' + entry % value) 29 | else: 30 | print('DEBUG: ' + entry) 31 | log = logging() 32 | 33 | class ScheduleError(Exception): 34 | """Base schedule exception""" 35 | 36 | pass 37 | 38 | 39 | class ScheduleValueError(ScheduleError): 40 | """Base schedule value error""" 41 | 42 | pass 43 | 44 | 45 | class IntervalError(ScheduleValueError): 46 | """An improper interval was used""" 47 | 48 | pass 49 | 50 | 51 | class CancelJob(object): 52 | """ 53 | Can be returned from a job to unschedule itself. 54 | """ 55 | pass 56 | 57 | 58 | class Scheduler(object): 59 | """ 60 | Objects instantiated by the :class:`Scheduler ` are 61 | factories to create jobs, keep record of scheduled jobs and 62 | handle their execution. 63 | """ 64 | 65 | def __init__(self) -> None: 66 | self.jobs = [] 67 | self.tasks = [] 68 | self.tz = timezone.utc 69 | 70 | async def run_pending(self) -> None: 71 | """ 72 | Run all jobs that are scheduled to run. 73 | 74 | Please note that it is *intended behavior that run_pending() 75 | does not run missed jobs*. For example, if you've registered a job 76 | that should run every minute and you only call run_pending() 77 | in one hour increments then your job won't be run 60 times in 78 | between but only once. 79 | """ 80 | runnable_jobs = (job for job in self.jobs if job.should_run) 81 | for job in sorted(runnable_jobs): 82 | self._run_job(job) 83 | 84 | self.tasks = [task for task in self.tasks if not task.done()] 85 | 86 | if self.tasks: 87 | log.debug(f"Jobs to await: {len(self.tasks)}") 88 | await asyncio.gather(*self.tasks) 89 | 90 | 91 | def run_all(self, delay_seconds: int = 0) -> None: 92 | """ 93 | Run all jobs regardless if they are scheduled to run or not. 94 | 95 | A delay of `delay` seconds is added between each job. This helps 96 | distribute system load generated by the jobs more evenly 97 | over time. 98 | 99 | :param delay_seconds: A delay added between every executed job 100 | """ 101 | log.debug( 102 | "Running *all* %i jobs with %is delay in between", 103 | len(self.jobs), 104 | delay_seconds, 105 | ) 106 | for job in self.jobs[:]: 107 | self._run_job(job) 108 | time.sleep(delay_seconds) 109 | 110 | def get_jobs(self): 111 | """ 112 | Gets scheduled jobs 113 | """ 114 | return self.jobs 115 | 116 | def clear(self) -> None: 117 | """ 118 | Deletes scheduled jobs marked with the given tag, or all jobs 119 | if tag is omitted. 120 | 121 | :param tag: An identifier used to identify a subset of 122 | jobs to delete 123 | """ 124 | 125 | log.debug("Deleting *all* jobs") 126 | del self.jobs[:] 127 | 128 | def cancel_job(self, job: "Job") -> None: 129 | """ 130 | Delete a scheduled job. 131 | 132 | :param job: The job to be unscheduled 133 | """ 134 | try: 135 | log.debug('Cancelling job %s', str(job)) 136 | self.jobs.remove(job) 137 | except ValueError: 138 | log.error('Cancelling not-scheduled job %s', str(job)) 139 | 140 | def every(self, interval: int = 1) -> "Job": 141 | """ 142 | Schedule a new periodic job. 143 | 144 | :param interval: A quantity of a certain time unit 145 | :return: An unconfigured :class:`Job ` 146 | """ 147 | job = Job(interval, self) 148 | return job 149 | 150 | def _run_job(self, job: "Job") -> None: 151 | ret = job.run() 152 | if isinstance(ret, CancelJob) or ret is CancelJob: 153 | self.cancel_job(job) 154 | 155 | def get_next_run(self): 156 | """ 157 | Datetime when the next job should run. 158 | 159 | :return: A :class:`~datetime.datetime` object 160 | or None if no jobs scheduled 161 | """ 162 | if not self.jobs: 163 | return None 164 | jobs_filtered = self.get_jobs() 165 | if not jobs_filtered: 166 | return None 167 | return min(jobs_filtered).next_run 168 | 169 | next_run = property(get_next_run) 170 | 171 | @property 172 | def idle_seconds(self): 173 | """ 174 | :return: Number of seconds until 175 | :meth:`next_run ` 176 | or None if no jobs are scheduled 177 | """ 178 | if not self.next_run: 179 | return None 180 | return (self.next_run - datetime.datetime.now(tz=self.tz)).total_seconds() 181 | 182 | 183 | class Job(object): 184 | """ 185 | A periodic job as used by :class:`Scheduler`. 186 | 187 | :param interval: A quantity of a certain time unit 188 | :param scheduler: The :class:`Scheduler ` instance that 189 | this job will register itself with once it has 190 | been fully configured in :meth:`Job.do()`. 191 | 192 | Every job runs at a given fixed time interval that is defined by: 193 | 194 | * a :meth:`time unit ` 195 | * a quantity of `time units` defined by `interval` 196 | 197 | A job is usually created and returned by :meth:`Scheduler.every` 198 | method, which also defines its `interval`. 199 | """ 200 | 201 | def __init__(self, interval: int, scheduler: Scheduler = None): 202 | self.interval: int = interval # pause interval * unit between runs 203 | self.latest = None # upper limit to the interval 204 | self.job_func = None # the job job_func to run 205 | 206 | # Callable args 207 | self.args = None 208 | 209 | # Callable kwargs 210 | self.kwargs = None 211 | 212 | # Callable name 213 | self.job_func_name = None 214 | 215 | # time units, e.g. 'minutes', 'hours', ... 216 | self.unit = None 217 | 218 | # optional time at which this job runs 219 | self.at_time = None 220 | 221 | # optional time zone of the self.at_time field. Only relevant when at_time is not None 222 | self.at_time_zone = None 223 | 224 | # datetime of the last run 225 | self.last_run = None 226 | 227 | # datetime of the next run 228 | self.next_run = None 229 | 230 | # timedelta between runs, only valid for 231 | self.period = None 232 | 233 | # Specific day of the week to start on 234 | self.start_day = None 235 | 236 | # optional time of final run 237 | self.cancel_after = None 238 | 239 | self.scheduler = scheduler # scheduler to register with 240 | 241 | def __lt__(self, other) -> bool: 242 | """ 243 | PeriodicJobs are sortable based on the scheduled time they 244 | run next. 245 | """ 246 | return self.next_run < other.next_run 247 | 248 | @property 249 | def second(self): 250 | if self.interval != 1: 251 | raise IntervalError("Use seconds instead of second") 252 | return self.seconds 253 | 254 | @property 255 | def seconds(self): 256 | self.unit = "seconds" 257 | return self 258 | 259 | @property 260 | def minute(self): 261 | if self.interval != 1: 262 | raise IntervalError("Use minutes instead of minute") 263 | return self.minutes 264 | 265 | @property 266 | def minutes(self): 267 | self.unit = "minutes" 268 | return self 269 | 270 | @property 271 | def hour(self): 272 | if self.interval != 1: 273 | raise IntervalError("Use hours instead of hour") 274 | return self.hours 275 | 276 | @property 277 | def hours(self): 278 | self.unit = "hours" 279 | return self 280 | 281 | @property 282 | def day(self): 283 | if self.interval != 1: 284 | raise IntervalError("Use days instead of day") 285 | return self.days 286 | 287 | @property 288 | def days(self): 289 | self.unit = "days" 290 | return self 291 | 292 | @property 293 | def week(self): 294 | if self.interval != 1: 295 | raise IntervalError("Use weeks instead of week") 296 | return self.weeks 297 | 298 | @property 299 | def weeks(self): 300 | self.unit = "weeks" 301 | return self 302 | 303 | @property 304 | def monday(self): 305 | if self.interval != 1: 306 | raise IntervalError( 307 | "Scheduling .monday() jobs is only allowed for weekly jobs. " 308 | "Using .monday() on a job scheduled to run every 2 or more weeks " 309 | "is not supported." 310 | ) 311 | self.start_day = "monday" 312 | return self.weeks 313 | 314 | @property 315 | def tuesday(self): 316 | if self.interval != 1: 317 | raise IntervalError( 318 | "Scheduling .tuesday() jobs is only allowed for weekly jobs. " 319 | "Using .tuesday() on a job scheduled to run every 2 or more weeks " 320 | "is not supported." 321 | ) 322 | self.start_day = "tuesday" 323 | return self.weeks 324 | 325 | @property 326 | def wednesday(self): 327 | if self.interval != 1: 328 | raise IntervalError( 329 | "Scheduling .wednesday() jobs is only allowed for weekly jobs. " 330 | "Using .wednesday() on a job scheduled to run every 2 or more weeks " 331 | "is not supported." 332 | ) 333 | self.start_day = "wednesday" 334 | return self.weeks 335 | 336 | @property 337 | def thursday(self): 338 | if self.interval != 1: 339 | raise IntervalError( 340 | "Scheduling .thursday() jobs is only allowed for weekly jobs. " 341 | "Using .thursday() on a job scheduled to run every 2 or more weeks " 342 | "is not supported." 343 | ) 344 | self.start_day = "thursday" 345 | return self.weeks 346 | 347 | @property 348 | def friday(self): 349 | if self.interval != 1: 350 | raise IntervalError( 351 | "Scheduling .friday() jobs is only allowed for weekly jobs. " 352 | "Using .friday() on a job scheduled to run every 2 or more weeks " 353 | "is not supported." 354 | ) 355 | self.start_day = "friday" 356 | return self.weeks 357 | 358 | @property 359 | def saturday(self): 360 | if self.interval != 1: 361 | raise IntervalError( 362 | "Scheduling .saturday() jobs is only allowed for weekly jobs. " 363 | "Using .saturday() on a job scheduled to run every 2 or more weeks " 364 | "is not supported." 365 | ) 366 | self.start_day = "saturday" 367 | return self.weeks 368 | 369 | @property 370 | def sunday(self): 371 | if self.interval != 1: 372 | raise IntervalError( 373 | "Scheduling .sunday() jobs is only allowed for weekly jobs. " 374 | "Using .sunday() on a job scheduled to run every 2 or more weeks " 375 | "is not supported." 376 | ) 377 | self.start_day = "sunday" 378 | return self.weeks 379 | 380 | def at(self, time_str: str): 381 | 382 | """ 383 | Specify a particular time that the job should be run at. 384 | 385 | :param time_str: A string in one of the following formats: 386 | 387 | - For daily jobs -> `HH:MM:SS` or `HH:MM` 388 | - For hourly jobs -> `MM:SS` or `:MM` 389 | - For minute jobs -> `:SS` 390 | 391 | The format must make sense given how often the job is 392 | repeating; for example, a job that repeats every minute 393 | should not be given a string in the form `HH:MM:SS`. The 394 | difference between `:MM` and `:SS` is inferred from the 395 | selected time-unit (e.g. `every().hour.at(':30')` vs. 396 | `every().minute.at(':30')`). 397 | 398 | 399 | :return: The invoked job instance 400 | """ 401 | if self.unit not in ("days", "hours", "minutes") and not self.start_day: 402 | raise ScheduleValueError( 403 | "Invalid unit (valid units are `days`, `hours`, and `minutes`)" 404 | ) 405 | 406 | if not isinstance(time_str, str): 407 | raise TypeError("at() should be passed a string") 408 | if self.unit == "days" or self.start_day: 409 | if not re.match(r"^[0-2]\d:[0-5]\d(:[0-5]\d)?$", time_str): 410 | raise ScheduleValueError( 411 | "Invalid time format for a daily job (valid format is HH:MM(:SS)?)" 412 | ) 413 | if self.unit == "hours": 414 | if not re.match(r"^([0-5]\d)?:[0-5]\d$", time_str): 415 | raise ScheduleValueError( 416 | "Invalid time format for an hourly job (valid format is (MM)?:SS)" 417 | ) 418 | 419 | if self.unit == "minutes": 420 | if not re.match(r"^:[0-5]\d$", time_str): 421 | raise ScheduleValueError( 422 | "Invalid time format for a minutely job (valid format is :SS)" 423 | ) 424 | time_values = time_str.split(":") 425 | hour = None 426 | minute = None 427 | second = None 428 | if len(time_values) == 3: 429 | hour, minute, second = time_values 430 | elif len(time_values) == 2 and self.unit == "minutes": 431 | hour = 0 432 | minute = 0 433 | _, second = time_values 434 | elif len(time_values) == 2 and self.unit == "hours" and len(time_values[0]): 435 | hour = 0 436 | minute, second = time_values 437 | else: 438 | hour, minute = time_values 439 | second = 0 440 | if self.unit == "days" or self.start_day: 441 | hour = int(hour) 442 | if not (0 <= hour <= 23): 443 | raise ScheduleValueError( 444 | "Invalid number of hours ({} is not between 0 and 23)" 445 | ) 446 | elif self.unit == "hours": 447 | hour = 0 448 | elif self.unit == "minutes": 449 | hour = 0 450 | minute = 0 451 | hour = int(hour) 452 | minute = int(minute) 453 | second = int(second) 454 | self.at_time = datetime.time(hour, minute, second) 455 | return self 456 | 457 | def to(self, latest: int): 458 | """ 459 | Schedule the job to run at an irregular (randomized) interval. 460 | 461 | The job's interval will randomly vary from the value given 462 | to `every` to `latest`. The range defined is inclusive on 463 | both ends. For example, `every(A).to(B).seconds` executes 464 | the job function every N seconds such that A <= N <= B. 465 | 466 | :param latest: Maximum interval between randomized job runs 467 | :return: The invoked job instance 468 | """ 469 | self.latest = latest 470 | return self 471 | 472 | def until( 473 | self, 474 | until_time, 475 | ): 476 | """ 477 | Schedule job to run until the specified moment. 478 | 479 | The job is canceled whenever the next run is calculated and it turns out the 480 | next run is after the until_time. The job is also canceled right before it runs, 481 | if the current time is after until_time. This latter case can happen when the 482 | the job was scheduled to run before until_time, but runs after until_time. 483 | 484 | If until_time is a moment in the past, ScheduleValueError is thrown. 485 | 486 | :param until_time: A moment in the future representing the latest time a job can 487 | be run. If only a time is supplied, the date is set to today. 488 | The following formats are accepted: 489 | 490 | - datetime.datetime 491 | - datetime.timedelta 492 | - datetime.time 493 | - String in one of the following formats: "%Y-%m-%d %H:%M:%S", 494 | "%Y-%m-%d %H:%M", "%Y-%m-%d", "%H:%M:%S", "%H:%M" 495 | as defined by strptime() behaviour. If an invalid string format is passed, 496 | ScheduleValueError is thrown. 497 | 498 | :return: The invoked job instance 499 | """ 500 | 501 | if isinstance(until_time, datetime.datetime): 502 | self.cancel_after = until_time 503 | elif isinstance(until_time, datetime.timedelta): 504 | self.cancel_after = datetime.datetime.now(tz=timezone.utc) + until_time 505 | elif isinstance(until_time, datetime.time): 506 | self.cancel_after = datetime.datetime.combine( 507 | datetime.datetime.now(tz=timezone.utc), until_time 508 | ) 509 | elif isinstance(until_time, str): 510 | cancel_after = self._decode_datetimestr( 511 | until_time, 512 | [ 513 | "%Y-%m-%d %H:%M:%S", 514 | "%Y-%m-%d %H:%M", 515 | "%Y-%m-%d", 516 | "%H:%M:%S", 517 | "%H:%M", 518 | ], 519 | ) 520 | if cancel_after is None: 521 | raise ScheduleValueError("Invalid string format for until()") 522 | if "-" not in until_time: 523 | # the until_time is a time-only format. Set the date to today 524 | now = datetime.datetime.now(tz=timezone.utc) 525 | cancel_after = cancel_after.replace( 526 | year=now.year, month=now.month, day=now.day 527 | ) 528 | self.cancel_after = cancel_after 529 | else: 530 | raise TypeError( 531 | "until() takes a string, datetime.datetime, datetime.timedelta, " 532 | "datetime.time parameter" 533 | ) 534 | if self.cancel_after < datetime.datetime.now(tz=timezone.utc): 535 | raise ScheduleValueError( 536 | "Cannot schedule a job to run until a time in the past" 537 | ) 538 | return self 539 | 540 | def do(self, job_func, *args, **kwargs): 541 | """ 542 | Specifies the job_func that should be called every time the 543 | job runs. 544 | 545 | Any additional arguments are passed on to job_func when 546 | the job runs. 547 | 548 | :param job_func: The function to be scheduled 549 | :return: The invoked job instance 550 | """ 551 | self.args = args 552 | self.kwargs = kwargs 553 | self.job_func_name = job_func.__name__ 554 | 555 | self._schedule_next_run() 556 | if self.scheduler is None: 557 | raise ScheduleError( 558 | "Unable to a add job to schedule. " 559 | "Job is not associated with an scheduler" 560 | ) 561 | self.scheduler.jobs.append(self) 562 | return self 563 | 564 | @property 565 | def should_run(self) -> bool: 566 | """ 567 | Check if a job is due to be run 568 | :return: ``True`` if the job should be run now. 569 | """ 570 | assert self.next_run is not None, "must run _schedule_next_run before" 571 | return datetime.datetime.now(tz=timezone.utc) >= self.next_run 572 | 573 | def run(self): 574 | """ 575 | Run the job and immediately reschedule it. 576 | If the job's deadline is reached (configured using .until()), the job is not 577 | run and CancelJob is returned immediately. If the next scheduled run exceeds 578 | the job's deadline, CancelJob is returned after the execution. In this latter 579 | case CancelJob takes priority over any other returned value. 580 | 581 | :return: The return value returned by the `job_func`, or CancelJob if the job's 582 | deadline is reached. 583 | 584 | """ 585 | if self._is_overdue(datetime.datetime.now(tz=timezone.utc)): 586 | log.debug("Cancelling overdue job %s", self) 587 | return CancelJob 588 | 589 | log.debug("Running job %s", self) 590 | self.job_func = functools.partial(eval(self.job_func_name), *self.args, **self.kwargs) 591 | task = asyncio.create_task(self.job_func()) 592 | self.scheduler.tasks.append(task) 593 | 594 | self.last_run = datetime.datetime.now(tz=timezone.utc) 595 | self._schedule_next_run() 596 | 597 | if self._is_overdue(self.next_run): 598 | log.debug("Cancelling overdue job post-run %s", self) 599 | return CancelJob 600 | return task 601 | 602 | def _schedule_next_run(self) -> None: 603 | """ 604 | Compute the instant when this job should run next. 605 | """ 606 | if self.unit not in ("seconds", "minutes", "hours", "days", "weeks"): 607 | raise ScheduleValueError( 608 | "Invalid unit (valid units are `seconds`, `minutes`, `hours`, " 609 | "`days`, and `weeks`)" 610 | ) 611 | 612 | if self.latest is not None: 613 | if not (self.latest >= self.interval): 614 | raise ScheduleError("`latest` is greater than `interval`") 615 | interval = random.randint(self.interval, self.latest) 616 | else: 617 | interval = self.interval 618 | 619 | self.period = datetime.timedelta(**{self.unit: interval}) 620 | if self.last_run: 621 | self.next_run = self.last_run + self.period 622 | elif self.next_run and not self.last_run: 623 | pass 624 | else: 625 | self.next_run = datetime.datetime.now(tz=timezone.utc) + self.period 626 | if self.start_day is not None: 627 | if self.unit != "weeks": 628 | raise ScheduleValueError("`unit` should be 'weeks'") 629 | weekdays = ( 630 | "monday", 631 | "tuesday", 632 | "wednesday", 633 | "thursday", 634 | "friday", 635 | "saturday", 636 | "sunday", 637 | ) 638 | if self.start_day not in weekdays: 639 | raise ScheduleValueError( 640 | "Invalid start day (valid start days are {})".format(weekdays) 641 | ) 642 | weekday = weekdays.index(self.start_day) 643 | days_ahead = weekday - self.next_run.weekday() 644 | if days_ahead <= 0: # Target day already happened this week 645 | days_ahead += 7 646 | self.next_run += datetime.timedelta(days_ahead) - self.period 647 | if self.at_time is not None: 648 | if self.unit not in ("days", "hours", "minutes") and self.start_day is None: 649 | raise ScheduleValueError("Invalid unit without specifying start day") 650 | kwargs = {"second": self.at_time.second, "microsecond": 0} 651 | if self.unit == "days" or self.start_day is not None: 652 | kwargs["hour"] = self.at_time.hour 653 | if self.unit in ["days", "hours"] or self.start_day is not None: 654 | kwargs["minute"] = self.at_time.minute 655 | self.next_run = self.next_run.replace(**kwargs) # type: ignore 656 | 657 | 658 | # Make sure we run at the specified time *today* (or *this hour*) 659 | # as well. This accounts for when a job takes so long it finished 660 | # in the next period. 661 | if not self.last_run or (self.next_run - self.last_run) > self.period: 662 | now = datetime.datetime.now(tz=timezone.utc) 663 | if ( 664 | self.unit == "days" 665 | and self.at_time > now.time() 666 | and self.interval == 1 667 | ): 668 | self.next_run = self.next_run - datetime.timedelta(days=1) 669 | elif self.unit == "hours" and ( 670 | self.at_time.minute > now.minute 671 | or ( 672 | self.at_time.minute == now.minute 673 | and self.at_time.second > now.second 674 | ) 675 | ): 676 | self.next_run = self.next_run - datetime.timedelta(hours=1) 677 | elif self.unit == "minutes" and self.at_time.second > now.second: 678 | self.next_run = self.next_run - datetime.timedelta(minutes=1) 679 | if self.start_day is not None and self.at_time is not None: 680 | # Let's see if we will still make that time we specified today 681 | if (self.next_run - datetime.datetime.now(tz=timezone.utc)).days >= 7: 682 | self.next_run -= self.period 683 | log.debug("Job %s re-scheduled to run at %s", self, self.next_run) 684 | 685 | def _is_overdue(self, when: datetime.datetime): 686 | return self.cancel_after is not None and when > self.cancel_after 687 | 688 | def _decode_datetimestr( 689 | self, datetime_str: str, formats 690 | ): 691 | for f in formats: 692 | try: 693 | return datetime.datetime.strptime(datetime_str, f) 694 | except ValueError: 695 | pass 696 | return None 697 | 698 | def asdict(self): 699 | # TODO: Document and add error handling 700 | 701 | data= {'interval': self.interval, 702 | 'latest': self.latest, 703 | 'args': self.args, 704 | 'kwargs': self.kwargs, 705 | 'job_func_name': self.job_func_name, 706 | 'unit': self.unit, 707 | 'start_day': self.start_day} 708 | 709 | if self.at_time is not None: 710 | data['at_time'] = self.at_time.isoformat() 711 | 712 | if self.last_run is not None: 713 | data['last_run'] = self.last_run.isoformat() 714 | 715 | if self.cancel_after is not None: 716 | data['cancel_after'] = self.cancel_after.isoformat() 717 | 718 | if self.next_run is not None: 719 | data['next_run'] = self.next_run.isoformat() 720 | 721 | return data 722 | 723 | 724 | # The following methods are shortcuts for not having to 725 | # create a Scheduler instance: 726 | 727 | #: Default :class:`Scheduler ` object 728 | default_scheduler = Scheduler() 729 | 730 | #: Default :class:`Jobs ` list 731 | jobs = default_scheduler.jobs # todo: should this be a copy, e.g. jobs()? 732 | 733 | 734 | def every(interval: int = 1) -> Job: 735 | """Calls :meth:`every ` on the 736 | :data:`default scheduler instance `. 737 | """ 738 | return default_scheduler.every(interval) 739 | 740 | 741 | async def run_pending() -> None: 742 | """Calls :meth:`run_pending ` on the 743 | :data:`default scheduler instance `. 744 | """ 745 | await default_scheduler.run_pending() 746 | 747 | def run_all(delay_seconds: int = 0) -> None: 748 | """Calls :meth:`run_all ` on the 749 | :data:`default scheduler instance `. 750 | """ 751 | default_scheduler.run_all(delay_seconds=delay_seconds) 752 | 753 | 754 | def get_jobs(): 755 | """Calls :meth:`get_jobs ` on the 756 | :data:`default scheduler instance `. 757 | """ 758 | return default_scheduler.get_jobs() 759 | 760 | 761 | def clear() -> None: 762 | """Calls :meth:`clear ` on the 763 | :data:`default scheduler instance `. 764 | """ 765 | default_scheduler.clear() 766 | 767 | 768 | def cancel_job(job: Job) -> None: 769 | """Calls :meth:`cancel_job ` on the 770 | :data:`default scheduler instance `. 771 | """ 772 | default_scheduler.cancel_job(job) 773 | 774 | 775 | def next_run(): 776 | """Calls :meth:`next_run ` on the 777 | :data:`default scheduler instance `. 778 | """ 779 | return default_scheduler.get_next_run() 780 | 781 | 782 | def idle_seconds(): 783 | """Calls :meth:`idle_seconds ` on the 784 | :data:`default scheduler instance `. 785 | """ 786 | return default_scheduler.idle_seconds 787 | 788 | 789 | def repeat(job, *args, **kwargs): 790 | """ 791 | Decorator to schedule a new periodic job. 792 | 793 | Any additional arguments are passed on to the decorated function 794 | when the job runs. 795 | 796 | :param job: a :class:`Jobs ` 797 | """ 798 | 799 | def _schedule_decorator(decorated_function): 800 | job.do(decorated_function, *args, **kwargs) 801 | return decorated_function 802 | 803 | return _schedule_decorator 804 | 805 | def to_json(): 806 | # TODO: Document and add error handling 807 | data = [] 808 | 809 | for j in jobs: 810 | data.append(j.asdict()) 811 | 812 | return json.dumps(data) 813 | 814 | # datetime.EPOCH is 2000 on ESP32 vs 1970 815 | # allow for scheduling and saving on unix port and uploading to ESP32 816 | # or downloading to test 817 | def parse_float_or_isotime(str): 818 | dt = None 819 | if (str is not None): 820 | try: 821 | 822 | # parse possible timestamp float value 823 | ts = float(str) 824 | dt = datetime.datetime.fromtimestamp(ts, tz=timezone.utc) 825 | except (TypeError, ValueError): 826 | 827 | # try ISO format 828 | try: 829 | dt = datetime.datetime.fromisoformat(str) 830 | except (ValueError): 831 | log.error("invalid date/time: %s", str) 832 | return dt 833 | 834 | def from_json(data): 835 | # TODO: Document and add error handling 836 | 837 | for j in data: 838 | job = Job(interval=j['interval'], scheduler=default_scheduler) 839 | job.latest = j['latest'] 840 | job.args = j['args'] 841 | job.kwargs = j['kwargs'] 842 | job.job_func_name = j['job_func_name'] 843 | job.unit = j['unit'] 844 | job.start_day = j['start_day'] 845 | 846 | try: 847 | job.at_time = datetime.time.fromisoformat(j['at_time']) 848 | except: 849 | pass 850 | 851 | if ('last_run' in j): 852 | job.last_run = parse_float_or_isotime(j['last_run']) 853 | if ('cancel_after' in j): 854 | job.cancel_after = parse_float_or_isotime(j['cancel_after']) 855 | 856 | # next_run should have already been set and saved 857 | # avoid calling _schedule_next_run again, because if last_run is not set and the system is restarted then the job will wait for another interval 858 | if ('next_run' in j): 859 | job.next_run = parse_float_or_isotime(j['next_run']) 860 | if (job.next_run is None): 861 | try: 862 | job._schedule_next_run() 863 | except Exception as e: 864 | log.error("error calculating next_run: %s", repr(e)) 865 | 866 | if (job.next_run is not None): 867 | log.debug("Loaded job %s scheduled to run at %s", str(job), job.next_run) 868 | jobs.append(job) 869 | return jobs 870 | 871 | async def run_forever(interval=1): 872 | while True: 873 | await default_scheduler.run_pending() 874 | await asyncio.sleep(interval) 875 | 876 | def save_flash(): 877 | # TODO: Document and add error handling 878 | f = open('schedule.json', "w+") 879 | f.write(to_json()) 880 | f.close() 881 | 882 | def load_flash(keep_existing=False): 883 | # TODO: Document 884 | # Clear existing jobs 885 | if not keep_existing: 886 | for jb in list(jobs): # Iterate over a copy of the list 887 | default_scheduler.cancel_job(jb) 888 | # Read jobs from flash 889 | try: 890 | from_json(json.load(open('schedule.json', "r"))) 891 | except OSError: 892 | log.warning("Schedule file not found") 893 | except Exception as e: 894 | log.error("Error reading schedule file: %s", repr(e)) 895 | --------------------------------------------------------------------------------