The update check has been daily at 03:00, Persistent, since 0.1.5 — the lab's installed stable machine logged it at 03:01 and 03:04 on successive days — and it stays so. What changes is the reboot after an update. It happened the moment the update was installed: harmless at 03:00, and a room restarting itself mid-morning when a computer switched off at 03:00 caught up after being switched on. Now the updater installs, with every check it had (the device's own channel, a newer version, the manifest's SHA-256, name and version), and starts classroom-reboot-when-idle, which reboots once no input device has produced an event for 30 minutes and no sound card has played for as long. At 03:00 that is about 03:30; switched off first, the next start is on the new version. The service has no [Install] and nothing enables it. The release gate checks after every install and upgrade that the timer is enabled, daily at 03:00, Persistent, with no boot or repeat trigger and no drop-in, and that the updater no longer reboots by itself. Co-Authored-By: Claude Opus 5.5 <[email protected]>
158 lines
5.3 KiB
Python
Executable File
158 lines
5.3 KiB
Python
Executable File
#!/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())
|