You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
shepherd-agent/shepherd/scheduler.py

209 lines
7.7 KiB

#!/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
import shepherd.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()