Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions src/jukebox/components/timers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ def finalize():
plugin.register(timer_stop_player, name='timer_stop_player', package=plugin.loaded_as(__name__))

# Volume Fadeout and Shutdown Timer
global timer_fade_volume
timer_fade_volume = VolumeFadoutAndShutdown(
name=f"{plugin.loaded_as(__name__)}.timer_fade_volume"
)
Expand Down Expand Up @@ -93,13 +94,8 @@ def atexit(**ignored_kwargs):
timer_fade_volume.cancel()
global timer_idle_shutdown
timer_idle_shutdown.cancel()
global timer_idle_check
timer_idle_check.cancel()
ret = [
timer_shutdown.timer_thread,
timer_stop_player.timer_thread,
timer_fade_volume.timer_thread,
timer_idle_shutdown.timer_thread,
timer_idle_check.timer_thread
]
return ret
233 changes: 94 additions & 139 deletions src/jukebox/components/timers/idle_shutdown_timer.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,192 +3,147 @@

import os
import re
from glob import iglob
import logging
import jukebox.cfghandler
import jukebox.plugs as plugin
from jukebox.multitimer import (GenericEndlessTimerClass, GenericMultiTimerClass)

from jukebox.multitimer import GenericEndlessTimerClass
# Use monotonic time to avoid issues with system time changes
from time import monotonic as time

logger = logging.getLogger('jb.timers.idle_shutdown_timer')
cfg = jukebox.cfghandler.get_handler('jukebox')
base_path = os.path.join(os.path.dirname(__file__), '..', '..', '..', '..')

SSH_CHILD_RE = re.compile(r'sshd: [^/].*')
PATHS = ['shared/settings',
'shared/audiofolders']
SSH_CHILD_RE = re.compile(r'sshd: [^/].*')

IDLE_SHUTDOWN_TIMER_MIN_TIMEOUT_SECONDS = 60
EXTEND_IDLE_TIMEOUT = 60
IDLE_CHECK_INTERVAL = 10


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)
def playback_active():
# Returns True if audio is currently playing
player_status = plugin.call('player', 'ctrl', 'playerstatus')
return player_status['state'] == 'play'


def ssh_activity():
# Returns True if there is an active SSH session
logger.debug('Checking for SSH activity')

for cmdline in iglob('/proc/*/cmdline'):
try:
with open(cmdline) as f:
cmdline = f.read()
except PermissionError:
continue
if SSH_CHILD_RE.match(cmdline):
logger.info('SSH activity detected, resetting idle timer')
return True
return False


class FSWatcher:
# Minimal filesystem watcher that needs to be polled to detect changes.
def __init__(self, paths):
self.paths = paths
self.previous_state = None
self.has_changed() # Initialize state

def has_changed(self):
# Returns True if the directory state has changed since the last call/initialization.
logger.debug('Collecting directory state')
latest_mtime = 0
num_entries = 0
for path in self.paths:
for root, dirs, files in os.walk(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})')
has_changed = self.previous_state != (num_entries, latest_mtime)
self.previous_state = (num_entries, latest_mtime)
return has_changed


class IdleShutdownTimer:
def __init__(self, package: str, idle_timeout: int) -> None:
self.private_timer_idle_shutdown = None
self.private_timer_idle_check = None
self.idle_timeout = 0
self.idle_timeout = idle_timeout
self.package = package
self.idle_check_interval = IDLE_CHECK_INTERVAL
self.last_activity_time = time()
self.fswatcher = FSWatcher([os.path.join(base_path, path) for path in PATHS])
self.idle_state = False # Idle state as determined in last idle_check()

self.set_idle_timeout(idle_timeout)
self.init_idle_shutdown()
self.init_idle_check()
self.init_idle_check(idle_timeout)

def set_idle_timeout(self, idle_timeout):
try:
self.idle_timeout = int(idle_timeout)
except ValueError:
logger.warning(f'invalid timers.idle_shutdown.timeout_sec value {repr(idle_timeout)}')
def init_idle_check(self, idle_timeout):
self.idle_timeout = int(idle_timeout)

# Prevent bricking the system by shutting down immediately after boot
if self.idle_timeout < IDLE_SHUTDOWN_TIMER_MIN_TIMEOUT_SECONDS:
logger.info('disabling idle shutdown timer; set '
'timers.idle_shutdown.timeout_sec to at least '
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')
self.idle_timeout = 0

# Using GenericMultiTimerClass instead of GenericTimerClass as it supports classes rather than functions
# Calling GenericMultiTimerClass with iterations=1 is the same as GenericTimerClass
def init_idle_shutdown(self):
self.private_timer_idle_shutdown = GenericMultiTimerClass(
name=f"{self.package}.private_timer_idle_shutdown",
iterations=1,
wait_seconds_per_iteration=self.idle_timeout,
callee=IdleShutdown
)
self.private_timer_idle_shutdown.__doc__ = "Timer to shutdown after system is idle for a given time"
plugin.register(self.private_timer_idle_shutdown, name='private_timer_idle_shutdown', package=self.package)

# Regularly check if player has activity, if not private_timer_idle_check will start/cancel private_timer_idle_shutdown
def init_idle_check(self):
idle_check_timer_instance = IdleCheck()
self.private_timer_idle_check = GenericEndlessTimerClass(
name=f"{self.package}.private_timer_idle_check",
wait_seconds_per_iteration=self.idle_check_interval,
function=idle_check_timer_instance
wait_seconds_per_iteration=IDLE_CHECK_INTERVAL,
function=self.idle_check
)
self.private_timer_idle_check.__doc__ = 'Timer to check if system is idle'
if self.idle_timeout:
self.private_timer_idle_check.start()

plugin.register(self.private_timer_idle_check, name='private_timer_idle_check', package=self.package)

def time_left(self):
if not self.idle_timeout:
return None
return self.idle_timeout - (time() - self.last_activity_time)

def idle_check(self):
# Regularly check for activity

# Lazily evaluate activity functions to avoid unnecessary expensive checks.
# Note this can lead to FSWatcher.has_changed returning True once when playback and ssh go inactive, effectively
# extending the idle timeout by one check interval. This is acceptable.
if playback_active() or ssh_activity() or self.fswatcher.has_changed():
self.idle_state = False
# Be generous and mark this whole interval as active by adding IDLE_CHECK_INTERVAL
self.last_activity_time = time() + IDLE_CHECK_INTERVAL
else:
self.idle_state = True
time_left = self.time_left()
if time_left > 0:
logger.debug(f'No activity detected, shutting down in {int(time_left)} seconds')
else:
logger.debug('No activity detected, initiating shutdown sequence')
plugin.call_ignore_errors('host', 'shutdown')

@plugin.tag
def start(self, wait_seconds: int):
"""Sets idle_shutdown timeout_sec stored in jukebox.yaml"""
"""Updates idle_shutdown timeout_sec (also in jukebox.yaml), starts the idle timer in case it wasn't running yet"""
cfg.setn('timers', 'idle_shutdown', 'timeout_sec', value=wait_seconds)
plugin.call_ignore_errors('timers', 'private_timer_idle_check', 'start')
self.private_timer_idle_check.start()

@plugin.tag
def cancel(self):
"""Cancels all idle timers and disables idle shutdown in jukebox.yaml"""
plugin.call_ignore_errors('timers', 'private_timer_idle_check', 'cancel')
plugin.call_ignore_errors('timers', 'private_timer_idle_shutdown', 'cancel')
cfg.setn('timers', 'idle_shutdown', 'timeout_sec', value=0)
"""Cancels the idle timer"""
self.private_timer_idle_check.cancel()

@plugin.tag
def get_state(self):
"""Returns the current state of Idle Shutdown"""
idle_check_state = plugin.call_ignore_errors('timers', 'private_timer_idle_check', 'get_state')
idle_shutdown_state = plugin.call_ignore_errors('timers', 'private_timer_idle_shutdown', 'get_state')
idle_check_state = self.private_timer_idle_check.get_state()

# Field names compatible to previous version:
return {
'enabled': idle_check_state['enabled'],
'running': idle_shutdown_state['enabled'],
'remaining_seconds': idle_shutdown_state['remaining_seconds'],
'wait_seconds': idle_shutdown_state['wait_seconds_per_iteration'],
'running': self.idle_state,
'remaining_seconds': self.time_left(),
}


class IdleCheck:
def __init__(self) -> None:
self.last_player_status = plugin.call('player', 'ctrl', 'playerstatus')
logger.debug('Started IdleCheck with initial state: {}'.format(self.last_player_status))

# Run function
def __call__(self):
player_status = plugin.call('player', 'ctrl', 'playerstatus')

if self.last_player_status == player_status:
plugin.call_ignore_errors('timers', 'private_timer_idle_shutdown', 'start')
else:
plugin.call_ignore_errors('timers', 'private_timer_idle_shutdown', 'cancel')

self.last_player_status = player_status.copy()
return self.last_player_status


class IdleShutdown():
files_num_entries: int = 0
files_latest_mtime: float = 0

def __init__(self) -> None:
self.base_path = os.path.join(os.path.dirname(__file__), '..', '..', '..', '..')

def __call__(self):
logger.debug('Last checks before shutting down')
if self._has_active_ssh_sessions():
logger.debug('Active SSH sessions found, will not shutdown now')
plugin.call_ignore_errors('timers', 'private_timer_idle_shutdown', 'set_timeout', args=[int(EXTEND_IDLE_TIMEOUT)])
return
# if self._has_changed_files():
# logger.debug('Changes files found, will not shutdown now')
# plugin.call_ignore_errors(
# 'timers',
# 'private_timer_idle_shutdown',
# 'set_timeout',
# args=[int(EXTEND_IDLE_TIMEOUT)])
# return

logger.info('No activity, shutting down')
plugin.call_ignore_errors('timers', 'private_timer_idle_check', 'cancel')
plugin.call_ignore_errors('timers', 'private_timer_idle_shutdown', 'cancel')
plugin.call_ignore_errors('host', 'shutdown')

@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
Loading