Skip to content

Commit

Permalink
feat: Add Idle Shutdown Timer support
Browse files Browse the repository at this point in the history
This adds an optional idle shutdown timer which can be enabled
via timers.idle_shutdown.timeout_sec in the jukebox.yaml config.

The system will shut down after the given number of seconds if no
activity has been detected during that time. Activity is defined as:
- music playing
- active SSH sessions
- changes in configs or audio content.

Fixes: #1970
  • Loading branch information
hoffie committed Apr 15, 2024
1 parent 3865c5a commit 09e99cb
Show file tree
Hide file tree
Showing 4 changed files with 166 additions and 2 deletions.
3 changes: 2 additions & 1 deletion documentation/developers/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions resources/default-settings/jukebox.default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 29 additions & 1 deletion src/jukebox/components/timers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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...
Expand All @@ -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
131 changes: 131 additions & 0 deletions src/jukebox/components/timers/idle_shutdown_timer.py
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 09e99cb

Please sign in to comment.