diff --git a/documentation/developers/status.md b/documentation/developers/status.md index 0a40f8125..146e5c50c 100644 --- a/documentation/developers/status.md +++ b/documentation/developers/status.md @@ -166,7 +166,8 @@ Topics marked _in progress_ are already in the process of implementation by comm - [x] Publish mechanism of timer status - [x] Change multitimer function call interface such that endless timer etc. won't pass the `iteration` kwarg - [ ] Make timer settings persistent -- [ ] Idle timer +- [x] Idle timer (basic implementation covering player, SSH, config and audio content changes) +- [ ] Idle timer: Do we need further extensions? - This needs clearer specification: Idle is when no music is playing and no user interaction is taking place - i.e., needs information from RPC AND from player status. Let's do this when we see a little clearer about Spotify diff --git a/resources/default-settings/jukebox.default.yaml b/resources/default-settings/jukebox.default.yaml index c087cc024..d87c676fa 100644 --- a/resources/default-settings/jukebox.default.yaml +++ b/resources/default-settings/jukebox.default.yaml @@ -102,6 +102,10 @@ gpioz: enable: false config_file: ../../shared/settings/gpio.yaml timers: + idle_shutdown: + # If you want the box to shutdown on inactivity automatically, configure timeout_sec with a number of seconds (at least 60). + # Inactivity is defined as: no music playing, no active SSH sessions, no changes in configs or audio content. + timeout_sec: 0 # These timers are always disabled after start # The following values only give the default values. # These can be changed when enabling the respective timer on a case-by-case basis w/o saving diff --git a/src/jukebox/components/timers/__init__.py b/src/jukebox/components/timers/__init__.py index bef7ccf81..28e054f37 100644 --- a/src/jukebox/components/timers/__init__.py +++ b/src/jukebox/components/timers/__init__.py @@ -5,11 +5,14 @@ import logging import jukebox.cfghandler import jukebox.plugs as plugin +from .idle_shutdown_timer import IdleShutdownTimer logger = logging.getLogger('jb.timers') cfg = jukebox.cfghandler.get_handler('jukebox') +IDLE_SHUTDOWN_TIMER_MIN_TIMEOUT_SECONDS = 60 + # --------------------------------------------------------------------------- # Action functions for Timers @@ -46,6 +49,7 @@ def __call__(self, iteration): timer_shutdown: GenericTimerClass timer_stop_player: GenericTimerClass timer_fade_volume: GenericMultiTimerClass +timer_idle_shutdown: IdleShutdownTimer @plugin.finalize @@ -77,6 +81,25 @@ def finalize(): timer_fade_volume.__doc__ = "Timer step-wise volume fade out and shutdown" plugin.register(timer_fade_volume, name='timer_fade_volume', package=plugin.loaded_as(__name__)) + global timer_idle_shutdown + timeout = cfg.setndefault('timers', 'idle_shutdown', 'timeout_sec', value=0) + try: + timeout = int(timeout) + except ValueError: + logger.warning(f'invalid timers.idle_shutdown.timeout_sec value {repr(timeout)}') + timeout = 0 + if timeout < IDLE_SHUTDOWN_TIMER_MIN_TIMEOUT_SECONDS: + logger.info('disabling idle shutdown timer; set ' + 'timers.idle_shutdown.timeout_sec to at least ' + f'{IDLE_SHUTDOWN_TIMER_MIN_TIMEOUT_SECONDS} seconds to enable') + timeout = 0 + if not timeout: + timer_idle_shutdown = None + else: + timer_idle_shutdown = IdleShutdownTimer(timeout) + timer_idle_shutdown.__doc__ = 'Timer for automatic shutdown on idle' + timer_idle_shutdown.start() + # The idle Timer does work in a little sneaky way # Idle is when there are no calls through the plugin module # Ahh, but also when music is playing this is not idle... @@ -101,4 +124,9 @@ def atexit(**ignored_kwargs): timer_stop_player.cancel() global timer_fade_volume timer_fade_volume.cancel() - return [timer_shutdown.timer_thread, timer_stop_player.timer_thread, timer_fade_volume.timer_thread] + ret = [timer_shutdown.timer_thread, timer_stop_player.timer_thread, timer_fade_volume.timer_thread] + global timer_idle_shutdown + if timer_idle_shutdown is not None: + timer_idle_shutdown.cancel() + ret += [timer_idle_shutdown] + return ret diff --git a/src/jukebox/components/timers/idle_shutdown_timer.py b/src/jukebox/components/timers/idle_shutdown_timer.py new file mode 100644 index 000000000..e6a2dddad --- /dev/null +++ b/src/jukebox/components/timers/idle_shutdown_timer.py @@ -0,0 +1,131 @@ +# RPi-Jukebox-RFID Version 3 +# Copyright (c) See file LICENSE in project root folder + +import time +import os +import re +import logging +import threading +import jukebox.plugs as plugin + + +logger = logging.getLogger('jb.timers.idle_shutdown_timer') +SSH_CHILD_RE = re.compile(r'sshd: [^/].*') +PATHS = ['shared/settings', + 'shared/audiofolders'] + + +def get_seconds_since_boot(): + # We may not have a stable clock source when there is no network + # connectivity (yet). As we only need to measure the relative time which + # has passed, we can just calculate based on the seconds since boot. + with open('/proc/uptime') as f: + line = f.read() + seconds_since_boot, _ = line.split(' ', 1) + return float(seconds_since_boot) + + +class IdleShutdownTimer(threading.Thread): + """ + Shuts down the system if no activity is detected. + The following activity is covered: + - playing music + - active SSH sessions + - changes of configs or audio content + + Note: This does not use one of the generic timers as there don't seem + to be any benefits here. The shutdown timer is kind of special in that it + is a timer which is expected *not* to fire most of the time, because some + activity would restart it. Using threading.Thread directly allows us to + keep using a single, persistent thread. + """ + shutdown_after_seconds: int + last_activity: float = 0 + files_num_entries: int = 0 + files_latest_mtime: float = 0 + running: bool = True + last_player_status = None + SLEEP_INTERVAL_SECONDS: int = 10 + + def __init__(self, timeout_seconds): + super().__init__(name=__class__.__name__) + self.shutdown_after_seconds = timeout_seconds + self.base_path = os.path.join(os.path.dirname(__file__), '..', '..', '..', '..') + self.record_activity() + logger.debug('Started IdleShutdownTimer') + + def record_activity(self): + self.last_activity = get_seconds_since_boot() + + def check(self): + if self.last_activity + self.shutdown_after_seconds > get_seconds_since_boot(): + return + logger.info('No player activity, starting further checks') + if self._has_active_ssh_sessions(): + logger.info('Active SSH sessions found, will not shutdown now') + self.record_activity() + return + if self._has_changed_files(): + logger.info('Changes files found, will not shutdown now') + self.record_activity() + return + logger.info(f'No activity since {self.shutdown_after_seconds} seconds, shutting down') + plugin.call_ignore_errors('host', 'shutdown') + + def run(self): + # We need this once as a baseline: + self._has_changed_files() + # We rely on playerstatus being sent in regular intervals. If this + # is no longer the case at some point, we would need an additional + # timer thread. + while self.running: + time.sleep(self.SLEEP_INTERVAL_SECONDS) + player_status = plugin.call('player', 'ctrl', 'playerstatus') + if player_status == self.last_player_status: + self.check() + else: + self.record_activity() + self.last_player_status = player_status.copy() + + def cancel(self): + self.running = False + + @staticmethod + def _has_active_ssh_sessions(): + logger.debug('Checking for SSH activity') + with os.scandir('/proc') as proc_dir: + for proc_path in proc_dir: + if not proc_path.is_dir(): + continue + try: + with open(os.path.join(proc_path, 'cmdline')) as f: + cmdline = f.read() + except (FileNotFoundError, PermissionError): + continue + if SSH_CHILD_RE.match(cmdline): + return True + + def _has_changed_files(self): + # This is a rather expensive check, but it only runs twice + # when an idle shutdown is initiated. + # Only when there are actual changes (file transfers via + # SFTP, Samba, etc.), the check may run multiple times. + logger.debug('Scanning for file changes') + latest_mtime = 0 + num_entries = 0 + for path in PATHS: + for root, dirs, files in os.walk(os.path.join(self.base_path, path)): + for p in dirs + files: + mtime = os.stat(os.path.join(root, p)).st_mtime + latest_mtime = max(latest_mtime, mtime) + num_entries += 1 + + logger.debug(f'Completed file scan ({num_entries} entries, latest_mtime={latest_mtime})') + if self.files_latest_mtime != latest_mtime or self.files_num_entries != num_entries: + # We compare the number of entries to have a chance to detect file + # deletions as well. + self.files_latest_mtime = latest_mtime + self.files_num_entries = num_entries + return True + + return False