#!/usr/bin/env python3
"""Reboot after an update, but only once nobody is using the computer.

The nightly update check (ekklesia-classroom-update.timer, 03:00, and
Persistent so that a computer switched off at 03:00 checks when it is next
switched on) used to reboot the moment an update was installed. At 03:00
that is harmless. Switched on in the morning after a night off, it meant a
classroom computer restarting itself half an hour into the day, in front
of a teacher.

So the updater installs, then starts this, and this reboots only when:

    nothing has touched any keyboard, mouse, touchpad or touchscreen for
    IDLE_SECONDS — seen here as kernel input events, which is what every
    one of them produces, whatever the desktop is doing; and

    no sound has played for as long — a video to the room has no input for
    an hour and is not an idle computer, and the end of it is not the
    moment to restart either. Seen as the kernel's own playback state
    (/proc/asound/.../status says RUNNING while a stream plays).

At 03:00 that is about 03:30. In the middle of a morning it is half an
hour after the teacher last touched it. If the computer is switched off
first, nothing is lost: the update is installed, and the next start is on
it. Until then the running session carries on as it was.

The time starts when this starts: input from before cannot be seen, so a
computer is only called idle after this has watched it be idle.

    classroom-reboot-when-idle [--idle-seconds N] [--reboot-command CMD...]

The options are for the lab; the service runs it with none.
"""

import argparse
import glob
import os
import select
import subprocess
import sys
import time

IDLE_SECONDS = 30 * 60

# How often it looks: input is noticed at once, the rest once a minute is
# plenty for a half-hour decision.
CHECK_SECONDS = 60

INPUT_GLOB = "/dev/input/event*"
ASOUND_ROOT = "/proc/asound"


def log(message):
    print(f"[classroom-reboot-when-idle] {message}", flush=True)


def audio_playing(root=ASOUND_ROOT):
    """Is any sound card playing right now?"""
    for status in glob.glob(os.path.join(root, "card*", "pcm*p", "sub*", "status")):
        try:
            with open(status, encoding="utf-8", errors="replace") as handle:
                if handle.readline().strip() == "state: RUNNING":
                    return True
        except OSError:
            continue

    return False


def should_reboot(now, last_input, idle_seconds, playing):
    """The decision: long enough without input, and nothing playing."""
    return not playing and now - last_input >= idle_seconds


class Inputs:
    """Every input device the kernel has, reopened as they come and go."""

    def __init__(self, pattern=INPUT_GLOB):
        self.pattern = pattern
        self.open = {}

    def rescan(self):
        present = set(glob.glob(self.pattern))

        for fd, path in list(self.open.items()):
            if path not in present:
                os.close(fd)
                del self.open[fd]

        known = set(self.open.values())

        for path in sorted(present - known):
            try:
                self.open[os.open(path, os.O_RDONLY | os.O_NONBLOCK)] = path
            except OSError:
                continue

    def wait(self, seconds):
        """True if anything happened on any device within SECONDS."""
        if not self.open:
            time.sleep(seconds)
            return False

        ready, _, _ = select.select(list(self.open), [], [], seconds)
        seen = False

        for fd in ready:
            try:
                while os.read(fd, 4096):
                    seen = True
            except BlockingIOError:
                pass
            except OSError:
                # A device that went away mid-read; the next rescan drops it.
                pass

        return seen


def main(argv=None):
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("--idle-seconds", type=float, default=IDLE_SECONDS)
    parser.add_argument("--check-seconds", type=float, default=CHECK_SECONDS)
    parser.add_argument("--input-glob", default=INPUT_GLOB)
    parser.add_argument("--asound-root", default=ASOUND_ROOT)
    parser.add_argument("--reboot-command", nargs="+", default=["systemctl", "reboot"])
    args = parser.parse_args(argv)

    inputs = Inputs(args.input_glob)
    inputs.rescan()
    last_input = time.monotonic()
    log(f"an update is installed; rebooting once nobody has used this computer for "
        f"{round(args.idle_seconds / 60)} minutes and nothing is playing "
        f"(watching {len(inputs.open)} input devices)")

    while True:
        deadline = time.monotonic() + args.check_seconds

        while time.monotonic() < deadline:
            if inputs.wait(max(0.0, min(5.0, deadline - time.monotonic()))):
                last_input = time.monotonic()

        inputs.rescan()
        playing = audio_playing(args.asound_root)

        if playing:
            # Sound counts as someone there: a 40-minute video ending is
            # not the moment to restart, so the half hour starts after it.
            last_input = time.monotonic()

        if should_reboot(time.monotonic(), last_input, args.idle_seconds, playing):
            log("nobody has used it, and nothing is playing: rebooting")
            return subprocess.run(args.reboot_command).returncode


if __name__ == "__main__":
    sys.exit(main())
