Commit da02f85d authored by Seblu's avatar Seblu
Browse files

Refactoring

- Put deamon code in Robot class
- Put package check/update code in Package class
- Use python3 f'string
- Use sentences for comments and docstrings
parent c2a4d28a
Loading
Loading
Loading
Loading
Loading
+256 −285
Original line number Diff line number Diff line
@@ -45,7 +45,8 @@ from urllib.request import urlopen, Request
from systemd.daemon import notify

class Error(BaseException):
  """Error handling"""
  '''Error handling.'''

  ERR_USAGE = 1
  ERR_ABORT = 2
  ERR_CRITICAL = 3
@@ -53,9 +54,8 @@ class Error(BaseException):


class ABFormatter(Formatter):
  '''
  Customer logging formater
  '''
  '''Customer logging formater.'''

  def __init__(self, fmt="[%(levelname)s] %(msg)s"):
    super().__init__(fmt)

@@ -69,9 +69,7 @@ class ABFormatter(Formatter):


class AURPackage(dict):
  '''
  Abstract AUR package action
  '''
  '''Abstract AUR package data.'''

  AUR_URL = 'https://aur.archlinux.org'
  USER_AGENT = "aurbot"
@@ -79,18 +77,17 @@ class AURPackage(dict):
  def __init__(self, name, timeout=None):
    super().__init__()
    self.name = name
    debug("getting %s aur infos" % self.name)
    url = "%s/rpc.php?type=info&arg=%s" % (self.AUR_URL, name)
    url_req = Request(url, headers={"User-Agent": self.USER_AGENT})
    debug("Requesting url: %s (timeout: %s)" % (url, timeout))
    debug(f"{name} Requesting url: {url} (timeout: {timeout}s)")
    url_fd = urlopen(url_req, timeout=timeout)
    d = jloads(url_fd.read().decode("utf-8"))
    if d["version"] != 1:
      raise Exception("Unknown AUR Backend version: %s" % d["version"])
      raise Exception(f"Unknown AUR Backend version: {d['version']}")
    if len(d["results"]) == 0:
      raise Exception("No such package: %s" % name)
      raise Exception(f"No such package: {name}")
    if d["results"]["PackageBase"] != name:
      raise Exception("No such base package: %s" % name)
      raise Exception(f"No such base package: {name}")
    self._info = d["results"]

  def __getattr__(self, name):
@@ -103,17 +100,15 @@ class AURPackage(dict):
    return "%s %s" % (self.name, self.version)

  def extract(self, path):
    '''
    Extract aur source tarball inside a directory path
    '''
    fo = urlopen('%s/%s' % (self.AUR_URL, self.urlpath))
    '''Extract aur source tarball inside a directory path.'''
    fo = urlopen(f"{self.AUR_URL}/{self.urlpath}")
    tarball = tar(mode='r|*', fileobj=fo)
    tarball.extractall(path)
    fo.close()


class LocalPackage(dict):
  '''Local package data abstraction'''
  '''Local package data.'''

  DEFAULT_DATA_DIR = "/var/lib/aurbot"

@@ -121,118 +116,90 @@ class LocalPackage(dict):
    super().__init__()
    self.name = name
    self.path = join(environ.get("AURBOT_DATADIR", self.DEFAULT_DATA_DIR), name)
    debug("local path is: %s" % self.path)
    debug(f"{name}: local path is: {self.path}")
    makedirs(self.path, exist_ok=True)

  @property
  def logdir(self):
    '''Return log files directory path'''
    '''Return log files directory path.'''
    logdir = join(self.path, "log")
    if not exists(logdir):
      mkdir(logdir)
    return logdir

  def getlastX(self, X, cast=int, default=0):
    '''Return saved value of X casted in cast'''
    '''Return saved value of X casted in cast.'''
    filepath = join(self.path, X)
    if not exists(filepath):
      return default
    try:
      return cast(open(filepath, "r").read())
    except Exception as exp:
      debug("Failed to load %s: %s" % (X, exp))
      debug(f"Failed to load {X}: {exp}")
      return default

  def setlastX(self, X, value, cast=int):
    '''Cast the value X in cast and save it to file named X'''
    '''Cast the value X in cast and save it to file named X.'''
    open(join(self.path, X), "w").write("%s" % cast(value))

  # store the moment where the build was done locally
  # Store the moment where the build was done locally.
  lastbuild = property(
    lambda x: LocalPackage.getlastX(x, "lastbuild"),
    lambda x, y: LocalPackage.setlastX(x, "lastbuild", y)
  )
  # store the aur lastmodified value of the last sucessful build
  # Store the aur lastmodified value of the last sucessful build.
  lastsuccess = property(
    lambda x: LocalPackage.getlastX(x, "lastsuccess"),
    lambda x, y: LocalPackage.setlastX(x, "lastsuccess", y)
  )
  # store the aur lastmodified value of the last failed build
  # Store the aur lastmodified value of the last failed build.
  lastfailed = property(
    lambda x: LocalPackage.getlastX(x, "lastfailed"),
    lambda x, y: LocalPackage.setlastX(x, "lastfailed", y)
  )
  # store the last time we check the aur
  # Store the last time we check the aur.
  lastchecked = property(
    lambda x: LocalPackage.getlastX(x, "lastchecked"),
    lambda x, y: LocalPackage.setlastX(x, "lastchecked", y)
  )
  # store the last maintainer for the package
  # Store the last maintainer for the package.
  lastmaintainer = property(
    lambda x: LocalPackage.getlastX(x, "lastmaintainer", str, ""),
    lambda x, y: LocalPackage.setlastX(x, "lastmaintainer", y, str)
  )


class Aurbot():
  ''' AUR Bot data and methods
  '''
class Package():
  '''Package Meta Abstraction.'''

  DEFAULT_CHECK_INTERVAL = 86400
  DEFAULT_CONFIG_FILE = "/etc/aurbot.conf"

  def __init__(self, path):
    ''' initialize the bot
    '''
    self.init_config(abspath(path))
    self.parse_config()

  def init_config(self, path=None):
    ''' default value for configured
    '''
    if path is not None:
      self.config_path = path
    self.config_mtime = 0
    self.config = ConfigParser()

  def parse_config(self):
    ''' parse the config file
    '''
    # get the modification time of the config file
    try:
      mtime = stat(self.config_path).st_mtime
    except Exception as exp:
      self.init_config()
      debug("Unable to stat config file, empty one used: %s" % str(exp))
      return
    # reload only when file has been modified
    if mtime > self.config_mtime:
      self.config_mtime = mtime
      self.config = ConfigParser()
      try:
        self.config.read(self.config_path)
      except Exception as exp:
        self.init_config()
        debug("Unable to parse config file, empty one used: %s" % str(exp))
      info("Config file loaded %s" % self.config_path)

  def send_message(self, pkgconfig, msg):
    ''' Send message to an smtp server
    '''
    info("Sending message to %s" % pkgconfig["notify"])
    # load smtp info
  def __init__(self, pkgname, pkgconfig):
    self.name = pkgname
    self._config = pkgconfig
    self._local = LocalPackage(pkgname)
    # Print sugars.
    self.debug = lambda msg: debug(f"{self.name}: {msg}")
    self.info = lambda msg: info(f"{self.name}: {msg}")
    self.error = lambda msg: error(f"{self.name}: {msg}")
    self.warn = lambda msg: warning(f"{self.name}: {msg}")

  def send_message(self, msg):
    '''Send message to an smtp server.'''
    self.info(f"Sending message to {self._config['notify']}")
    # Load smtp info.
    try:
      smtp_host = pkgconfig["smtp_host"]
      smtp_port = pkgconfig["smtp_port"]
      smtp_login = pkgconfig.get("smtp_login", "")
      smtp_pass = pkgconfig.get("smtp_pass", "")
      smtp_security = pkgconfig.get("smtp_security", "")
      smtp_host = self._config["smtp_host"]
      smtp_port = self._config["smtp_port"]
      smtp_login = self._config.get("smtp_login", "")
      smtp_pass = self._config.get("smtp_pass", "")
      smtp_security = self._config.get("smtp_security", "")
    except:
      error("Unable to load smtp config")
      self.error("Unable to load smtp config")
      return
    # display message content when debug
    debug(msg)
    # prepare connection
    # Display message content when debug.
    self.debug(msg)
    # Prepare connection.
    con = SMTP_SSL() if smtp_security == "ssl" else SMTP()
    if getLogger().isEnabledFor(DEBUG):
      con.set_debuglevel(True)
@@ -243,245 +210,252 @@ class Aurbot():
        con.starttls()
      if smtp_login != "" and smtp_pass != "":
        con.login(smtp_login, smtp_pass)
      # send it
      # Send it.
      con.send_message(msg)
      # gentleman quit
      # Gentleman quit.
      con.quit()
    except Exception as exp:
      error("Unable to send message via smtp: %s" % str(exp))
      self.error(f"Unable to send message via smtp: {exp}")

  def send_build_report(self, pkgconfig, localpkg, aurpkg, status, logfile):
    ''' Send build notification
    '''
    info("Send build report")
    # generate message
  def send_build_report(self, status, logfile):
    '''Send build notification.'''
    self.info("Send build report")
    # Generate message.
    msg = MIMEMultipart()
    msg["Subject"] = "Build %s for %s %s" % (
      "successful" if status else "failure", localpkg.name, aurpkg.version)
    msg["From"] = pkgconfig.get("from", "Aurbot")
    msg["To"] = pkgconfig["notify"]
    msg["Subject"] = f"Build {status} for {self.name} {self._aur.version}"
    msg["From"] = self._config.get("from", "Aurbot")
    msg["To"] = self._config["notify"]
    msg["Date"] = formatdate(localtime=True)
    # attach logfile
    # Attach logfile.
    with open(logfile, "r") as fd:
      mt = MIMEText(fd.read())
    msg.attach(mt)
    self.send_message(pkgconfig, msg)
    self.send_message(msg)

  def send_maintainer_report(self, pkgconfig, localpkg, aurpkg):
    ''' Send email to notify invalid maintainer
    '''
    info("Send invalid maintainer report")
    # generate message
  def send_maintainer_report(self):
    '''Send email to notify of invalid maintainership.'''
    self.info("Send invalid maintainer report")
    # Generate message.
    msg = MIMEText(
      "Maintainer for package %s is invalid.\r\n" % localpkg.name +
      "Maintainer for package %s is invalid.\r\n" % self.name +
      "He has probably changed. Check if the new one is trustworthy.\r\n"
      "\r\n"
      "Configured maintainer is %s.\r\n" % pkgconfig.get("maintainer") +
      "AUR maintainer is %s.\r\n" % aurpkg.maintainer +
      "Configured maintainer is %s.\r\n" % self._config.get("maintainer") +
      "AUR maintainer is %s.\r\n" % self._aur.maintainer +
      "\r\n"
      "Your aurbot configuration need to be updated!\r\n")
    msg["Subject"] = "Invalid maintainer for %s" % localpkg.name
    msg["From"] = pkgconfig.get("from", "Aurbot")
    msg["To"] = pkgconfig["notify"]
    msg["Subject"] = "Invalid maintainer for %s" % self.name
    msg["From"] = self._config.get("from", "Aurbot")
    msg["To"] = self._config["notify"]
    msg["Date"] = formatdate(localtime=True)
    self.send_message(pkgconfig, msg)
    self.send_message(msg)

  def build(self, pkgconfig, localpkg, aurpkg):
    ''' Build a package
    '''
    # register the build
    localpkg.lastbuild = time()
    # log files
    fp = join(localpkg.logdir, strftime("build-%Y-%m-%d-%H-%M-%S.log", localtime(time())))
    debug("Build log file path: %s" % fp)
    # find build dir
    build_dir = TemporaryDirectory()
    debug("Build dir is %s" % build_dir.name)
    # extract tarball
    debug("Extracting aur tarball")
    aurpkg.extract(build_dir.name)
    with open(fp, "w") as fd:
      cwd = getcwd()
      try:
        chdir("%s/%s" % (build_dir.name, aurpkg.name))
        # build
        info("Starting build command")
        debug(pkgconfig["build_cmd"])
        fd.write("Build command: %s\n" % pkgconfig["build_cmd"])
        fd.flush()
  def _run_command(self, name, cmd, log):
    '''Fancy run of command cmd and log output in file object log.'''
    self.info(f"Starting {name} command: {cmd}")
    log.write(f"Build command: {cmd}\n")
    log.flush()
    start_time = time()
    try:
          check_call(pkgconfig["build_cmd"], stdin=DEVNULL, stdout=fd,
            stderr=fd, shell=True, close_fds=True)
      check_call(cmd, stdin=DEVNULL, stdout=log, stderr=log, shell=True, close_fds=True)
    except Exception as exp:
          raise Exception("Build failure: %s" % str(exp)) from exp
      raise Exception(f"Build failure: {exp}") from exp
    end_time = time()
        info("Build duration: %.2fs" % (end_time - start_time))
        fd.write("Build duration: %.2fs\n" % (end_time - start_time))
        # commit
        if "commit_cmd" in pkgconfig:
          info("Starting commit command")
          debug(pkgconfig["commit_cmd"])
          fd.write("Commit command: %s\n" % pkgconfig["commit_cmd"])
          fd.flush()
          start_time = time()
    self.info(f"Build duration: {end_time - start_time:.2f}s")
    log.write(f"Build duration: {end_time - start_time:.2f}\n")

  def _build(self):
    '''Build a package.'''
    if "build_cmd" not in self._config:
      self.error("No build command.")
      return
    # Register the build start time.
    self._local.lastbuild = time()
    # Choose a log file name.
    logfn = join(self._local.logdir, strftime("build-%Y-%m-%d-%H-%M-%S.log", localtime(time())))
    self.debug(f"Build log file path: {logfn}")
    # Make a temporary build directory.
    build_dir = TemporaryDirectory()
    # Extract the tarball inside it.
    self.debug("Extracting aur tarball in {build_dir.name}")
    self._aur.extract(build_dir.name)
    with open(logfn, "w") as logfo:
      cwd = getcwd()
      try:
            check_call(pkgconfig["commit_cmd"], stdin=DEVNULL,
            stdout=fd, stderr=fd, shell=True, close_fds=True)
          except Exception as exp:
            raise Exception("Commit failure: %s" % str(exp)) from exp
          end_time = time()
          info("Commit duration: %.2fs" % (end_time - start_time))
          fd.write("Commit duration: %.2fs\n" % (end_time - start_time))
        chdir(f"{build_dir.name}/{self.name}")
        # Execute build command.
        self._run_command("build", self._config['build_cmd'], logfo)
        # Execute commit command.
        if "commit_cmd" in self._config:
          self._run_command("commit", self._config['commit_cmd'], logfo)
        chdir(cwd)
        # we have to register after chdir in the original directory
        localpkg.lastsuccess = aurpkg.lastmodified
        status = True
        self._local.lastsuccess = self._aur.lastmodified
        status = "successful"
      except Exception as exp:
        error("Update failure: %s" % exp)
        self.error(f"Update failure: {exp}")
        chdir(cwd)
        # we have to register after chdir in the original directory
        localpkg.lastfailed = aurpkg.lastmodified
        status = False
    if "notify" in pkgconfig:
      self.send_build_report(pkgconfig, localpkg, aurpkg, status, fp)

  def update(self, pkgconfig, localpkg, aurpkg):
    ''' Update (build and commit) a package
    '''
    debug("Updating %s" % aurpkg.name)
    # for security, if the maintainer is incorrect we fail
    debug("Configured maintainer: %s" % pkgconfig.get("maintainer"))
    debug("AUR maintainer: %s" % aurpkg.maintainer)
    debug("Last maintainer: %s" % localpkg.lastmaintainer)
    # str is required to handle no maintainer as None string
    if pkgconfig.get("maintainer") != str(aurpkg.maintainer):
        self._local.lastsuccess = self._aur.lastmodified
        status = "failure"
    if "notify" in self._config:
      self.send_build_report(status, logfn)

  def update(self):
    '''Update a package.'''
    # For security, if the maintainer is incorrect we fail.
    self.debug("Configured maintainer: %s" % self._config.get("maintainer"))
    self.debug("AUR maintainer: %s" % self._aur.maintainer)
    self.debug("Last maintainer: %s" % self._local.lastmaintainer)
    # str cast is required to handle no maintainer as None string
    if self._config.get("maintainer") == str(self._aur.maintainer):
      self._build()
    else:
      self.error(f"Invalid maintainer")
      # we notify by mail only once the maintainer is invalid
      if localpkg.lastmaintainer != str(aurpkg.maintainer):
        self.send_maintainer_report(pkgconfig, localpkg, aurpkg)
        localpkg.lastmaintainer = aurpkg.maintainer
      error("Invalid maintainer for package %s" % aurpkg.name)
      return
    localpkg.lastmaintainer = aurpkg.maintainer
    self.build(pkgconfig, localpkg, aurpkg)

  def start(self):
    ''' start the bot loop
    '''
    while True:
      try:
        # reload package list
        self.parse_config()
        next_checks = set()
        for pkgname, pkgconfig in self.config.items():
          if pkgname == "DEFAULT":
            continue
          info("[%s]" % pkgname)
          if "build_cmd" not in pkgconfig:
            error("build_cmd is missing in config file")
            continue
          localpkg = LocalPackage(pkgname)
          check_interval = pkgconfig.getint("check_interval", self.DEFAULT_CHECK_INTERVAL)
          debug("Check interval is %ss" % check_interval)
          check_delta = int(localpkg.lastchecked - time() + check_interval)
          debug("Check delta is %ss" % check_delta)
      if self._local.lastmaintainer != str(self._aur.maintainer):
        self.send_maintainer_report()
    self._local.lastmaintainer = self._aur.maintainer

  def check_delta(self):
    '''Return the time in seconds remaining before next check.'''
    check_interval = self._config.getint("check_interval", self.DEFAULT_CHECK_INTERVAL)
    self.debug(f"Check interval is {check_interval}s")
    check_delta = int(self._local.lastchecked - time() + check_interval)
    self.debug(f"Check delta is {check_delta}s")
    return check_delta

  def check(self):
    # compute check delta
    check_delta = self.check_delta()
    if check_delta > 0:
      # next check is in the future
            next_checks.add(check_delta)
            info("Next check is planned in %ss" % check_delta)
            continue
          next_checks.add(check_interval)
      self.info(f"Next check is planned in {check_delta}s")
      return check_delta
    # get remote data
    try:
            aurpkg = AURPackage(pkgname, pkgconfig.getint("timeout"))
            localpkg.lastchecked = int(time())
      self._aur = AURPackage(self.name, self._config.getint("timeout"))
      self._local.lastchecked = int(time())
    except Exception as exp:
            error("Unable to get AUR package info: %s" % exp)
            continue
      self.error(f"Unable to get AUR package info: {exp}")
      return
    # few debug printing
          debug("AUR last modified: %s" % aurpkg.lastmodified)
          debug("Local last success lastmodified: %s" % localpkg.lastbuild)
          debug("Local last failed lastmodified: %s" % localpkg.lastfailed)
          debug("Local last build time: %s" % localpkg.lastbuild)
    self.debug(f"AUR last modified: {self._aur.lastmodified}")
    self.debug(f"Local last success lastmodified: {self._local.lastbuild}")
    self.debug(f"Local last failed lastmodified: {self._local.lastfailed}")
    self.debug(f"Local last build time: {self._local.lastbuild}")
    # check if package need to be updated
          if localpkg.lastsuccess >= aurpkg.lastmodified:
            if "force" in pkgconfig:
              info("Up to date, but force value is present.")
              if pkgconfig["force"].isdigit() is False:
                warning("Invalid force value, ignore it")
                continue
    if self._local.lastsuccess >= self._aur.lastmodified:
      if "force" in self._config:
        self.info("Up to date, but force value is present.")
        if self._config["force"].isdigit() is False:
          self.warn("Invalid force value, ignore it")
          return
        # if lastbuild not exists, it will be equal to 0
        # too small to be > to time() even with big force time
        now = int(time())
              force = int(pkgconfig["force"])
              debug("Force is: %ss" % force)
              force_delta = localpkg.lastbuild - now + force
              debug("Force Delta is: %ss" % force_delta)
        force = int(self._config["force"])
        self.debug(f"Force is: {force}s")
        force_delta = self._local.lastbuild - now + force
        self.debug(f"Force Delta is: {force_delta}s")
        if force_delta < 0:
                info("Forced update")
                self.update(pkgconfig, localpkg, aurpkg)
          self.info("Forced update")
          self.update()
        else:
                info("Next forced update in %ss" % force_delta)
          self.info(f"Next forced update in {force_delta}s")
      else:
              info("Up to date, nothing to do.")
          elif localpkg.lastfailed >= aurpkg.lastmodified:
            warning("Last build has failed, skipping. Remove lastfailed file to retry.")
        self.info("Up to date, nothing to do.")
    elif self._local.lastfailed >= self._aur.lastmodified:
      self.warn("Last build has failed, skipping. Remove lastfailed file to retry.")
    else:
            info("New version available: %s" % aurpkg.version)
            self.update(pkgconfig, localpkg, aurpkg)
        # sleep until next check
        # len(next_checks) is 0 when there is no package configured
        timeout = min(next_checks) if len(next_checks) > 0 else self.DEFAULT_CHECK_INTERVAL
        debug("waiting for %ds" % timeout)
        sleep(timeout)
      except InterruptedError:
        pass
      self.info(f"New version available: {self._aur.version}")
      self.update()
    # return updated check_delta
    return self.check_delta()


class Robot():
  '''AUR Package Builder Robot.'''

  DEFAULT_CONFIG_FILE = "/etc/aurbot.conf"

  @staticmethod
  def sighup_handler(signum, frame):
    '''Handler for HUP signal (a.k.a reload)'''
    info("SIGHUP received")
  # since python 3.5 we need to raise an exception to prevent python to EINTR
  # see https://www.python.org/dev/peps/pep-0475/
    # Since python 3.5 we need to raise an exception to prevent python to EINTR,
    # see https://www.python.org/dev/peps/pep-0475/.
    raise InterruptedError()

def parse_argv():
  def __init__(self):
    # Set logger config.
    hdlr = StreamHandler()
    hdlr.setFormatter(ABFormatter())
    getLogger().addHandler(hdlr)
    # Early debugging mode.
    getLogger().setLevel(DEBUG if "AURBOT_DEBUG" in environ else INFO)
    # Do not run as root.
    if geteuid() == 0 and "AURBOT_RUN_AS_ROOT" not in environ:
      raise Error("Do not run as root")
    # Use sighup to unblock sleep syscall.
    signal(SIGHUP, self.sighup_handler)
    # Parse command line.
    self._parse_argv()
    # Late debugging mode.
    if self._args.debug:
      getLogger().setLevel(DEBUG)
    # Load config.
    self._parse_config()
    # Tell to systemd we are ready.
    notify("READY=1\n")

  def _parse_argv(self):
    '''Parse command line arguments'''
  # load parser
    # Load parser.
    parser = ArgumentParser()
    parser.add_argument("-c", "--config", help="config file path",
      default=environ.get("AURBOT_CONFIG", self.DEFAULT_CONFIG_FILE))
    parser.add_argument("-d", "--debug", action="store_true", help="debug mode")
  parser.epilog = "You could set $XDG_DATA_HOME to change the path of the local package cache."
  # parse it!
  args = parser.parse_args()
  # set global debug mode
  if args.debug:
    getLogger().setLevel(DEBUG)
  return args
    # Parse it!
    self._args = parser.parse_args()

def main():
  '''Program entry point'''
  def _parse_config(self):
    '''Parse the config file.'''
    try:
    # set logger config
    hdlr = StreamHandler()
    hdlr.setFormatter(ABFormatter())
    getLogger().addHandler(hdlr)
    # Early debugging mode
    getLogger().setLevel(DEBUG if "AURBOT_DEBUG" in environ else INFO)
    # do not run as root
    if geteuid() == 0:
      raise Error("Do not run as root")
    # use sighup to unblock sleep syscall
    signal(SIGHUP, sighup_handler)
    # parse command line
    args = parse_argv()
    # create the bot object
    bot = Aurbot(args.config)
    # tell to systemd we are ready
    notify("READY=1\n")
    # start the bot
    bot.start()
      # Get the modification time of the config file.
      mtime = stat(self._args.config).st_mtime
      # Reload only when file has been modified.
      if not hasattr(self, "_config") or mtime > self._config_mtime:
        self._config = ConfigParser()
        self._config.read(self._args.config)
        self._config_mtime = mtime
        info(f"Config file loaded: {self._args.config}")
        if len(self._config.sections()) == 0:
          raise Error("Empty configuration")
    except Exception as exp:
      raise Error(f"Unable to load config file: {exp}")

  def start(self):
    '''Start the robot rock.'''
    while True:
      try:
        # Check for config update.
        self._parse_config()
        next_checks = set()
        for pkgname in self._config.sections():
          pkg = Package(pkgname, self._config[pkgname])
          next_checks.add(pkg.check())
        # Sleep until next check.
        timeout = min(next_checks)
        debug(f"Waiting for {timeout}s")
        sleep(timeout)
      except InterruptedError:
        pass


if __name__ == '__main__':
  try:
    Robot().start()
  except KeyboardInterrupt:
    exit(Error.ERR_ABORT)
  except Error as exp:
@@ -494,6 +468,3 @@ def main():
    else:
      raise
    exit(Error.ERR_UNKNOWN)

if __name__ == '__main__':
  main()