#!/usr/bin/env python3 from apscheduler.schedulers.blocking import BlockingScheduler from apscheduler.triggers.cron import CronTrigger from apscheduler.events import * from collections import namedtuple from tzlocal import get_localzone from datetime import datetime from datetime import timezone from datetime import timedelta import time import subprocess import os from . import plugin import io def is_raspberry_pi(raise_on_errors=False): """Checks if Raspberry PI. :return: """ try: with io.open('/proc/cpuinfo', 'r') as cpuinfo: found = False for line in cpuinfo: if line.startswith('Hardware'): found = True label, value = line.strip().split(':', 1) value = value.strip() if value not in ( 'BCM2708', 'BCM2709', 'BCM2835', 'BCM2836' ): if raise_on_errors: raise ValueError( 'This system does not appear to be a ' 'Raspberry Pi.' ) else: return False if not found: if raise_on_errors: raise ValueError( 'Unable to determine if this system is a Raspberry Pi.' ) else: return False except IOError: if raise_on_errors: raise ValueError('Unable to open `/proc/cpuinfo`.') else: return False return True _scheduler = None joblist_path = None JobDescription = namedtuple( 'Job', ['jobstring', 'hour', 'minute', 'second']) def init_scheduler(core_config): global _scheduler global joblist_path _scheduler = BlockingScheduler() joblist_path = os.path.expanduser(core_config["root_dir"]) joblist_path = os.path.join(joblist_path, "joblist.shp") def schedule_job(job_desc): if not isinstance(job_desc, JobDescription): raise Exception( "Argument to schedule_job() must be an instance of shepherd.scheduler.JobDescription") global _scheduler # resolve callable splitstring = job_desc.jobstring.split(':') if not ((len(splitstring) == 4) and (splitstring[0] == "shepherd")): raise Exception( "Could not add job - jobstring is not a valid Shepherd job") job_plugin, job_function, job_name = splitstring[1:] func = getattr(shepherd.plugin.plugin_functions[job_plugin], job_function) triggerstr = "!"+job_desc.hour+":"+job_desc.minute+":"+job_desc.second for job in _scheduler.get_jobs(): #Jobstring must be the same, triggerstring can vary and still match if job.id.startswith(job_desc.jobstring): # Jobstring matches existing job, so update it print("Modifying job "+job.id) print("next run was "+job.next_run_time) job.modify(func=func, trigger=CronTrigger( hour=job_desc.hour, minute=job_desc.minute, second=job_desc.second)) print("next is now "+job.next_run_time) break else: # No matching job found, so new job print("Scheduling job "+job_desc.jobstring) newjob=_scheduler.add_job(func, id=job_desc.jobstring+triggerstr, coalesce=True, misfire_grace_time=300, trigger=CronTrigger( hour=job_desc.hour, minute=job_desc.minute, second=job_desc.second)) #print("Next scheduled for "+str(newjob.next_run_time)) # Needs to be called after plugins are initialised, so interface functions # are available # Remember to wipe job store and not restore on next boot if config file was changed def save_jobs(): joblist = _scheduler.get_jobs() saved_jobs = [] next_job_time = None for job in joblist: jobstring, _, triggerstr = job.id.partition('!') jobstring_parts = jobstring.split(':') triggerstr_parts = triggerstr.split(':') if not ((len(jobstring_parts) == 4) and (jobstring_parts[0] == "shepherd")): raise Exception( "Could not save scheduler job "+job.id+" - ID is not a valid Shepherd job") if not isinstance(job.trigger, CronTrigger): raise Exception("Could not save scheduler job " + job.id+" - Trigger is not a CronTrigger") saved_jobs.append(job.id) if next_job_time is not None: if job.next_run_time < next_job_time: next_job_time = job.next_run_time else: next_job_time = job.next_run_time with open(joblist_path+".writing", 'w+') as f: for saved_job in saved_jobs: f.write("%s\n" % saved_job) os.rename(joblist_path+".writing", joblist_path) return next_job_time # Currently to wakeup functionality is based on a hard-coded dependancy on the Scout # plugin. We'd like this to instead be independant, and provide hooks for modules to register with that provide this # A problem with just providing a "set_alarm" hook or something is that we really need to be able to # confirm that an alarm has been set and we _are_ going to wake up gain correctly. # Could potentially provide a interface function that others can call to set a "Next Alarm" state variable. This can then be # checked after the hook call to verify. # At the moment shutdown is just triggered 1 minute after setting the alarm - mostly just to allow time for things to upload. # Having things be properly event based instead would be better - adding random delays all around the place inevetiably leads # "delay creep" - where all the "just in case" delays just build up and up and wind up making the whole thing take ages to do anything. # Instead we really need some sort of "current jobs/dependancies" queue thing - containing active stuff to be dealt with that should # hold the system active (sidenote - this same system could easily then be used to hold things on to access them remotely) # Would ideally have a mechanism that makes it hard to accidentally leave an item/job in the list that stops the system shutting down - # maybe look at a context manager used on an object in each plugin? def _jobs_changed(event): next_job_time = save_jobs() # default to idle for 5 mins early_wakeup_period = timedelta(minutes=1) now_datetime = datetime.now(get_localzone()) next_idle_period = timedelta(minutes=5) if next_job_time is not None: next_idle_period = next_job_time - now_datetime if next_idle_period > timedelta(hours=6): next_idle_period = timedelta(hours=6) wakeup_time = None if next_idle_period > timedelta(minutes=4): wakeup_time = now_datetime+(next_idle_period-early_wakeup_period) if wakeup_time is not None: alarm_str = str(int(wakeup_time.timestamp())) print("waking up at "+ str(wakeup_time) + " ("+alarm_str+")") retval=shepherd.plugin.plugin_functions["scout"].set_alarm(alarm_str) if retval is not None: print(retval) if retval == alarm_str: if is_raspberry_pi(): print("Shutting down in 1 minute") time.sleep(60) subprocess.run(["shutdown","now"]) else: print("Alarm set response was incorrect") else: print("Did not get reply from Scout after setting alarm") def restore_jobs(): pass def start(): global _scheduler _scheduler.add_listener(_jobs_changed, EVENT_JOB_ADDED | EVENT_JOB_REMOVED | EVENT_JOB_MODIFIED | EVENT_JOB_EXECUTED | EVENT_JOB_MISSED | EVENT_SCHEDULER_STARTED) _scheduler.start()