|
| 1 | +# RPi-Jukebox-RFID Version 3 |
| 2 | +# Copyright (c) See file LICENSE in project root folder |
| 3 | + |
| 4 | +import os |
| 5 | +import re |
| 6 | +import logging |
| 7 | +import jukebox.cfghandler |
| 8 | +import jukebox.plugs as plugin |
| 9 | +from jukebox.multitimer import (GenericEndlessTimerClass, GenericMultiTimerClass) |
| 10 | + |
| 11 | + |
| 12 | +logger = logging.getLogger('jb.timers.idle_shutdown_timer') |
| 13 | +cfg = jukebox.cfghandler.get_handler('jukebox') |
| 14 | + |
| 15 | +SSH_CHILD_RE = re.compile(r'sshd: [^/].*') |
| 16 | +PATHS = ['shared/settings', |
| 17 | + 'shared/audiofolders'] |
| 18 | + |
| 19 | +IDLE_SHUTDOWN_TIMER_MIN_TIMEOUT_SECONDS = 60 |
| 20 | +EXTEND_IDLE_TIMEOUT = 60 |
| 21 | +IDLE_CHECK_INTERVAL = 10 |
| 22 | + |
| 23 | + |
| 24 | +def get_seconds_since_boot(): |
| 25 | + # We may not have a stable clock source when there is no network |
| 26 | + # connectivity (yet). As we only need to measure the relative time which |
| 27 | + # has passed, we can just calculate based on the seconds since boot. |
| 28 | + with open('/proc/uptime') as f: |
| 29 | + line = f.read() |
| 30 | + seconds_since_boot, _ = line.split(' ', 1) |
| 31 | + return float(seconds_since_boot) |
| 32 | + |
| 33 | + |
| 34 | +class IdleShutdownTimer: |
| 35 | + def __init__(self, package: str, idle_timeout: int) -> None: |
| 36 | + self.private_timer_idle_shutdown = None |
| 37 | + self.private_timer_idle_check = None |
| 38 | + self.idle_timeout = 0 |
| 39 | + self.package = package |
| 40 | + self.idle_check_interval = IDLE_CHECK_INTERVAL |
| 41 | + |
| 42 | + self.set_idle_timeout(idle_timeout) |
| 43 | + self.init_idle_shutdown() |
| 44 | + self.init_idle_check() |
| 45 | + |
| 46 | + def set_idle_timeout(self, idle_timeout): |
| 47 | + try: |
| 48 | + self.idle_timeout = int(idle_timeout) |
| 49 | + except ValueError: |
| 50 | + logger.warning(f'invalid timers.idle_shutdown.timeout_sec value {repr(idle_timeout)}') |
| 51 | + |
| 52 | + if self.idle_timeout < IDLE_SHUTDOWN_TIMER_MIN_TIMEOUT_SECONDS: |
| 53 | + logger.info('disabling idle shutdown timer; set ' |
| 54 | + 'timers.idle_shutdown.timeout_sec to at least ' |
| 55 | + f'{IDLE_SHUTDOWN_TIMER_MIN_TIMEOUT_SECONDS} seconds to enable') |
| 56 | + self.idle_timeout = 0 |
| 57 | + |
| 58 | + # Using GenericMultiTimerClass instead of GenericTimerClass as it supports classes rather than functions |
| 59 | + # Calling GenericMultiTimerClass with iterations=1 is the same as GenericTimerClass |
| 60 | + def init_idle_shutdown(self): |
| 61 | + self.private_timer_idle_shutdown = GenericMultiTimerClass( |
| 62 | + name=f"{self.package}.private_timer_idle_shutdown", |
| 63 | + iterations=1, |
| 64 | + wait_seconds_per_iteration=self.idle_timeout, |
| 65 | + callee=IdleShutdown |
| 66 | + ) |
| 67 | + self.private_timer_idle_shutdown.__doc__ = "Timer to shutdown after system is idle for a given time" |
| 68 | + plugin.register(self.private_timer_idle_shutdown, name='private_timer_idle_shutdown', package=self.package) |
| 69 | + |
| 70 | + # Regularly check if player has activity, if not private_timer_idle_check will start/cancel private_timer_idle_shutdown |
| 71 | + def init_idle_check(self): |
| 72 | + idle_check_timer_instance = IdleCheck() |
| 73 | + self.private_timer_idle_check = GenericEndlessTimerClass( |
| 74 | + name=f"{self.package}.private_timer_idle_check", |
| 75 | + wait_seconds_per_iteration=self.idle_check_interval, |
| 76 | + function=idle_check_timer_instance |
| 77 | + ) |
| 78 | + self.private_timer_idle_check.__doc__ = 'Timer to check if system is idle' |
| 79 | + if self.idle_timeout: |
| 80 | + self.private_timer_idle_check.start() |
| 81 | + |
| 82 | + plugin.register(self.private_timer_idle_check, name='private_timer_idle_check', package=self.package) |
| 83 | + |
| 84 | + @plugin.tag |
| 85 | + def start(self, wait_seconds: int): |
| 86 | + """Sets idle_shutdown timeout_sec stored in jukebox.yaml""" |
| 87 | + cfg.setn('timers', 'idle_shutdown', 'timeout_sec', value=wait_seconds) |
| 88 | + plugin.call_ignore_errors('timers', 'private_timer_idle_check', 'start') |
| 89 | + |
| 90 | + @plugin.tag |
| 91 | + def cancel(self): |
| 92 | + """Cancels all idle timers and disables idle shutdown in jukebox.yaml""" |
| 93 | + plugin.call_ignore_errors('timers', 'private_timer_idle_check', 'cancel') |
| 94 | + plugin.call_ignore_errors('timers', 'private_timer_idle_shutdown', 'cancel') |
| 95 | + cfg.setn('timers', 'idle_shutdown', 'timeout_sec', value=0) |
| 96 | + |
| 97 | + @plugin.tag |
| 98 | + def get_state(self): |
| 99 | + """Returns the current state of Idle Shutdown""" |
| 100 | + idle_check_state = plugin.call_ignore_errors('timers', 'private_timer_idle_check', 'get_state') |
| 101 | + idle_shutdown_state = plugin.call_ignore_errors('timers', 'private_timer_idle_shutdown', 'get_state') |
| 102 | + |
| 103 | + return { |
| 104 | + 'enabled': idle_check_state['enabled'], |
| 105 | + 'running': idle_shutdown_state['enabled'], |
| 106 | + 'remaining_seconds': idle_shutdown_state['remaining_seconds'], |
| 107 | + 'wait_seconds': idle_shutdown_state['wait_seconds_per_iteration'], |
| 108 | + } |
| 109 | + |
| 110 | + |
| 111 | +class IdleCheck: |
| 112 | + def __init__(self) -> None: |
| 113 | + self.last_player_status = plugin.call('player', 'ctrl', 'playerstatus') |
| 114 | + logger.debug('Started IdleCheck with initial state: {}'.format(self.last_player_status)) |
| 115 | + |
| 116 | + # Run function |
| 117 | + def __call__(self): |
| 118 | + player_status = plugin.call('player', 'ctrl', 'playerstatus') |
| 119 | + |
| 120 | + if self.last_player_status == player_status: |
| 121 | + plugin.call_ignore_errors('timers', 'private_timer_idle_shutdown', 'start') |
| 122 | + else: |
| 123 | + plugin.call_ignore_errors('timers', 'private_timer_idle_shutdown', 'cancel') |
| 124 | + |
| 125 | + self.last_player_status = player_status.copy() |
| 126 | + return self.last_player_status |
| 127 | + |
| 128 | + |
| 129 | +class IdleShutdown(): |
| 130 | + files_num_entries: int = 0 |
| 131 | + files_latest_mtime: float = 0 |
| 132 | + |
| 133 | + def __init__(self) -> None: |
| 134 | + self.base_path = os.path.join(os.path.dirname(__file__), '..', '..', '..', '..') |
| 135 | + |
| 136 | + def __call__(self): |
| 137 | + logger.debug('Last checks before shutting down') |
| 138 | + if self._has_active_ssh_sessions(): |
| 139 | + logger.debug('Active SSH sessions found, will not shutdown now') |
| 140 | + plugin.call_ignore_errors('timers', 'private_timer_idle_shutdown', 'set_timeout', args=[int(EXTEND_IDLE_TIMEOUT)]) |
| 141 | + return |
| 142 | + # if self._has_changed_files(): |
| 143 | + # logger.debug('Changes files found, will not shutdown now') |
| 144 | + # plugin.call_ignore_errors( |
| 145 | + # 'timers', |
| 146 | + # 'private_timer_idle_shutdown', |
| 147 | + # 'set_timeout', |
| 148 | + # args=[int(EXTEND_IDLE_TIMEOUT)]) |
| 149 | + # return |
| 150 | + |
| 151 | + logger.info('No activity, shutting down') |
| 152 | + plugin.call_ignore_errors('timers', 'private_timer_idle_check', 'cancel') |
| 153 | + plugin.call_ignore_errors('timers', 'private_timer_idle_shutdown', 'cancel') |
| 154 | + plugin.call_ignore_errors('host', 'shutdown') |
| 155 | + |
| 156 | + @staticmethod |
| 157 | + def _has_active_ssh_sessions(): |
| 158 | + logger.debug('Checking for SSH activity') |
| 159 | + with os.scandir('/proc') as proc_dir: |
| 160 | + for proc_path in proc_dir: |
| 161 | + if not proc_path.is_dir(): |
| 162 | + continue |
| 163 | + try: |
| 164 | + with open(os.path.join(proc_path, 'cmdline')) as f: |
| 165 | + cmdline = f.read() |
| 166 | + except (FileNotFoundError, PermissionError): |
| 167 | + continue |
| 168 | + if SSH_CHILD_RE.match(cmdline): |
| 169 | + return True |
| 170 | + |
| 171 | + def _has_changed_files(self): |
| 172 | + # This is a rather expensive check, but it only runs twice |
| 173 | + # when an idle shutdown is initiated. |
| 174 | + # Only when there are actual changes (file transfers via |
| 175 | + # SFTP, Samba, etc.), the check may run multiple times. |
| 176 | + logger.debug('Scanning for file changes') |
| 177 | + latest_mtime = 0 |
| 178 | + num_entries = 0 |
| 179 | + for path in PATHS: |
| 180 | + for root, dirs, files in os.walk(os.path.join(self.base_path, path)): |
| 181 | + for p in dirs + files: |
| 182 | + mtime = os.stat(os.path.join(root, p)).st_mtime |
| 183 | + latest_mtime = max(latest_mtime, mtime) |
| 184 | + num_entries += 1 |
| 185 | + |
| 186 | + logger.debug(f'Completed file scan ({num_entries} entries, latest_mtime={latest_mtime})') |
| 187 | + if self.files_latest_mtime != latest_mtime or self.files_num_entries != num_entries: |
| 188 | + # We compare the number of entries to have a chance to detect file |
| 189 | + # deletions as well. |
| 190 | + self.files_latest_mtime = latest_mtime |
| 191 | + self.files_num_entries = num_entries |
| 192 | + return True |
| 193 | + |
| 194 | + return False |
0 commit comments